import frappe from frappe import _ # Mapping of material types to service categories HDD_MATERIALS = { "Loose Hard Drive", "External Hard Drive", "Printers/Copiers Hard Drives", "Loose SSD or mSATA Drive", "Server", "Desktop", "Laptop", "Chromebook / Notebook", "All In One", "HPStream", "Thin Clients", "Tablet", "Cell Phone / Smart Phone", "Gaming Systems", "Smart TV", "POS", "POS Terminals", "DVR", "Switch", "Network / Modems / Routers", "Office/ IP Phone", "Personal Electronics / PDA", "CRT TV", "Printers/Copiers", "USB Drive", "SD Cards", "GPS" } TAPE_MATERIALS = {"CD / Floppy / DVD / Tapes"} def _get_service_item(destruction_method, has_hardware=True): """Map destruction method to service item code.""" if destruction_method == "Wipe": if has_hardware: return "SVR-HDD-WIPE-1PASS" # Default to 1-pass; user can override return "SVR-HDD-WIPE-3PASS-NOHW" elif destruction_method in ("Shred", "Degauss"): if has_hardware: return "SVR-HDD-SERIAL-WIPE-HW" return "SVR-HDD-SERIAL-WIPE-NOHW" elif destruction_method == "None": return None return "SVR-HDD-WIPE-1PASS" def _get_tape_item(destruction_method, has_hardware=True): if has_hardware: return "SVR-TAPE-SHRED" return "SVR-TAPE-SHRED-NOHW" def _get_onsite_item(has_hardware=True): return "SVR-HDD-ONSITE-HW" if has_hardware else "SVR-HDD-ONSITE-NOHW" def _calculate_tier_price(item_code, qty): """Return unit rate for given qty based on tier pricing.""" tiers = { "SVR-HDD-WIPE-1PASS": [(1,10,7.00), (11,30,6.00), (31,50,4.50), (51,99,3.50), (100,999999,3.00)], "SVR-HDD-WIPE-3PASS-HW": [(1,10,8.50), (11,30,7.00), (31,50,5.25), (51,99,4.25), (100,999999,3.50)], "SVR-HDD-WIPE-3PASS-NOHW": [(1,10,14.00), (11,30,11.50), (31,50,8.40), (51,99,7.25), (100,999999,6.00)], "SVR-HDD-SERIAL-WIPE-HW": [(1,10,9.00), (11,30,7.50), (31,50,6.00), (51,99,5.00), (100,999999,4.25)], "SVR-HDD-SERIAL-WIPE-NOHW": [(1,10,14.50), (11,30,12.00), (31,50,10.00), (51,99,8.00), (100,999999,6.50)], "SVR-HDD-ONSITE-HW": [(1,100,500.00), (101,999999,3.50)], "SVR-HDD-ONSITE-NOHW": [(1,100,850.00), (101,999999,6.00)], "SVR-TAPE-SHRED": [(1,10,4.00), (11,30,3.30), (31,50,2.70), (51,99,2.00), (100,999999,1.50)], "SVR-TAPE-SHRED-NOHW": [(1,10,6.65), (11,30,5.50), (31,50,4.50), (51,99,3.35), (100,999999,2.50)], "SVR-VIDEO-RECORD": [(1,999999,3.50)], } for item, tlist in tiers.items(): if item == item_code: for min_qty, max_qty, rate in tlist: if min_qty <= qty <= max_qty: return rate # Fallback to Item Price rate = frappe.db.get_value("Item Price", {"item_code": item_code, "price_list": "2025 Service Rates", "selling": 1}, "price_list_rate") return rate or 0 @frappe.whitelist() def generate_service_invoice(load_name): """Generate a Sales Invoice from a Load document.""" load = frappe.get_doc("Load", load_name) if load.invoice_generated: frappe.throw(_("Invoice already generated for this load.")) if not load.customer: frappe.throw(_("Load must have a Customer linked.")) # Gather quantities per service item service_qty = {} hdd_count = 0 tape_count = 0 total_items = 0 for item in load.material_items or []: mt = item.material_type or "" qty = item.total_count or 0 if qty <= 0: continue total_items += qty if mt in HDD_MATERIALS: hdd_count += qty elif mt in TAPE_MATERIALS: tape_count += qty # Determine if on-site is_onsite = (load.service_type or "") == "On-site" destruction = load.destruction_method or "Wipe" # For simplicity, assume "has hardware" = True unless explicitly set otherwise. # TODO: Add custom field `has_hardware` on Load if needed. has_hardware = True invoice_items = [] if is_onsite and hdd_count > 0: onsite_item = _get_onsite_item(has_hardware) base_rate = _calculate_tier_price(onsite_item, hdd_count) # Onsite: base fee + per-drive for extras above 100 if hdd_count <= 100: invoice_items.append({ "item_code": onsite_item, "qty": 1, "rate": base_rate, "description": f"On-site shredding for {hdd_count} drives" }) else: # One base fee + per-drive extras invoice_items.append({ "item_code": onsite_item, "qty": 1, "rate": base_rate, "description": f"On-site base fee (1-100 drives)" }) extra = hdd_count - 100 per_drive_rate = _calculate_tier_price(onsite_item, hdd_count) invoice_items.append({ "item_code": onsite_item, "qty": extra, "rate": per_drive_rate, "description": f"Additional on-site drives ({extra})" }) else: # Standard pickup/mail-in pricing if hdd_count > 0: hdd_item = _get_service_item(destruction, has_hardware) if hdd_item: rate = _calculate_tier_price(hdd_item, hdd_count) invoice_items.append({ "item_code": hdd_item, "qty": hdd_count, "rate": rate, "description": f"{hdd_item} for {hdd_count} drives" }) if tape_count > 0: tape_item = _get_tape_item(destruction, has_hardware) rate = _calculate_tier_price(tape_item, tape_count) invoice_items.append({ "item_code": tape_item, "qty": tape_count, "rate": rate, "description": f"{tape_item} for {tape_count} tapes" }) # Video recording surcharge if load.video_recording and total_items > 0: vid_rate = _calculate_tier_price("SVR-VIDEO-RECORD", total_items) invoice_items.append({ "item_code": "SVR-VIDEO-RECORD", "qty": total_items, "rate": vid_rate, "description": f"Video recording surcharge for {total_items} items" }) if not invoice_items: frappe.throw(_("No billable items found in this load.")) # Create Sales Invoice si = frappe.new_doc("Sales Invoice") si.customer = load.customer si.posting_date = frappe.utils.today() si.due_date = frappe.utils.today() si.price_list = "2025 Service Rates" si.selling_price_list = "2025 Service Rates" si.currency = "USD" si.set_warehouse = None si.update_stock = 0 for it in invoice_items: si.append("items", { "item_code": it["item_code"], "qty": it["qty"], "rate": it["rate"], "description": it.get("description", ""), "uom": "Unit" }) si.save() # Do NOT submit automatically; let user review # Update Load load.invoice_generated = 1 load.sales_invoice = si.name load.save() frappe.db.commit() return {"status": "ok", "sales_invoice": si.name}