feat: CRM integration - customer intake, service invoice generation, COR print format\n\n- Added customer-intake page (search/create customers, create pallets)\n- Added whitelisted generate_service_invoice() API for native Sales Invoice creation\n- Added COR logo image asset\n- Removed stale fixtures/doctype.json (was causing bench migrate failures)
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user