Merge pull request 'Close review gaps: gate fixes, cylinder register, roll tracking, costing, Uganda setup' (#1) from feat/review-backlog-fixes into develop
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -12,6 +12,33 @@ bench get-app $URL_OF_THIS_REPO --branch develop
|
|||||||
bench install-app makap_backend
|
bench install-app makap_backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Setting up a site for Makap Uganda
|
||||||
|
|
||||||
|
`bench migrate` creates the custom fields, roles and role profiles. Company setup is a separate, manual step, because it creates accounts and must never run by accident. Run it as a dry run first, read the plan it prints, then apply it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bench --site $SITE execute makap_backend.makap_backend.setup_uganda.run
|
||||||
|
bench --site $SITE execute makap_backend.makap_backend.setup_uganda.run --kwargs "{'confirm': 1}"
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates a UGX company on the Standard chart, a July–June fiscal year, VAT 18% templates, stage WIP, Quarantine, Returns Hold and Rejected warehouses, and sets Manufacturing Settings to backflush on transferred material. It never deletes anything. Remove any older company by hand once the new one has been checked.
|
||||||
|
|
||||||
|
### Defaults Makap still has to confirm
|
||||||
|
|
||||||
|
The code ships with these defaults; each one is a business decision, not a technical one:
|
||||||
|
|
||||||
|
- **Roll identity:** one Batch per physical roll (Batch → Roll Details).
|
||||||
|
- **Cylinder ownership:** recorded per cylinder (Makap, Customer, or Charged to Customer).
|
||||||
|
- **Consumption costing:** backflush on "Material Transferred for Manufacture", so the Work Order Cost Variance report shows actual use.
|
||||||
|
- **Company:** "Makap Uganda Limited" (MUL), fiscal year July–June.
|
||||||
|
- **EFRIS:** not integrated. Sales Invoices carry a manual fiscalisation status until the integration route is chosen.
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bench --site $TEST_SITE run-tests --app makap_backend
|
||||||
|
```
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
|
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ app_email = "josephnjuki833@gmail.com"
|
|||||||
app_license = "mit"
|
app_license = "mit"
|
||||||
|
|
||||||
# MAKAP Module 3 child-table registration
|
# MAKAP Module 3 child-table registration
|
||||||
after_migrate = ["makap_backend.makap_backend.setup.ensure_makap_functional_roles", "makap_backend.makap_backend.setup.ensure_makap_object_permissions", "makap_backend.makap_backend.setup_module3.ensure_module3_child_tables", "makap_backend.makap_backend.setup_extensions.ensure_custom_fields", "makap_backend.makap_backend.setup_module8_dashboard.ensure_module8_number_cards", "makap_backend.makap_backend.setup_workspace_dashboard.ensure_workspace_number_cards", "makap_backend.makap_backend.workspace_setup.ensure_makap_workspace", "makap_backend.makap_backend.setup_module8_permissions.ensure_module8_role_permissions", "makap_backend.makap_backend.portal_shortcut.ensure_portal_shortcut"]
|
after_migrate = ["makap_backend.makap_backend.setup.ensure_makap_functional_roles", "makap_backend.makap_backend.setup.ensure_makap_object_permissions", "makap_backend.makap_backend.setup_module3.ensure_module3_child_tables", "makap_backend.makap_backend.setup_extensions.ensure_custom_fields", "makap_backend.makap_backend.setup_module8_dashboard.ensure_module8_number_cards", "makap_backend.makap_backend.setup_workspace_dashboard.ensure_workspace_number_cards", "makap_backend.makap_backend.workspace_setup.ensure_makap_workspace", "makap_backend.makap_backend.setup_module8_permissions.ensure_module8_role_permissions", "makap_backend.makap_backend.setup_roles.ensure_shopfloor_permissions", "makap_backend.makap_backend.setup_roles.ensure_makap_role_profiles", "makap_backend.makap_backend.portal_shortcut.ensure_portal_shortcut"]
|
||||||
|
|
||||||
# MAKAP cross-module server-side controls
|
# MAKAP cross-module server-side controls
|
||||||
doc_events = {
|
doc_events = {
|
||||||
@@ -22,6 +22,9 @@ doc_events = {
|
|||||||
"Work Order": {
|
"Work Order": {
|
||||||
"validate": "makap_backend.makap_backend.controls.validate_work_order",
|
"validate": "makap_backend.makap_backend.controls.validate_work_order",
|
||||||
},
|
},
|
||||||
|
"Job Card": {
|
||||||
|
"validate": "makap_backend.makap_backend.controls.validate_job_card",
|
||||||
|
},
|
||||||
"Quality Inspection": {
|
"Quality Inspection": {
|
||||||
"validate": "makap_backend.makap_backend.controls.validate_quality_inspection",
|
"validate": "makap_backend.makap_backend.controls.validate_quality_inspection",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
from frappe.utils import flt
|
||||||
|
|
||||||
|
from makap_backend.makap_backend import work_order_readiness
|
||||||
|
|
||||||
|
|
||||||
def _is_manufactured_item(item_code):
|
def _is_manufactured_item(item_code):
|
||||||
@@ -71,6 +74,60 @@ def validate_quotation(doc, method=None):
|
|||||||
line.makap_specification_revision = spec.revision
|
line.makap_specification_revision = spec.revision
|
||||||
line.makap_technical_summary = spec.customer_facing_technical_summary
|
line.makap_technical_summary = spec.customer_facing_technical_summary
|
||||||
|
|
||||||
|
_estimate_line_cost(line)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_line_cost(line):
|
||||||
|
"""Cost a quotation line from its estimating BOM: material (grossed up for
|
||||||
|
expected waste) + conversion (BOM operations) + set-up + cylinder charge.
|
||||||
|
|
||||||
|
The rate stays the salesperson's decision; this only shows the cost and
|
||||||
|
margin behind it, in company currency.
|
||||||
|
"""
|
||||||
|
if not line.get("makap_estimate_bom"):
|
||||||
|
for field in ("makap_material_cost", "makap_conversion_cost", "makap_estimated_cost", "makap_estimated_margin_pct"):
|
||||||
|
line.set(field, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
bom = frappe.db.get_value(
|
||||||
|
"BOM",
|
||||||
|
line.makap_estimate_bom,
|
||||||
|
["item", "quantity", "raw_material_cost", "operating_cost", "docstatus"],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
if not bom or bom.docstatus == 2:
|
||||||
|
frappe.throw(f"Estimating BOM {line.makap_estimate_bom} does not exist or is cancelled.")
|
||||||
|
if bom.item != line.item_code:
|
||||||
|
frappe.throw(f"Estimating BOM {line.makap_estimate_bom} is for {bom.item}, not {line.item_code}.")
|
||||||
|
|
||||||
|
qty = flt(line.stock_qty or line.qty)
|
||||||
|
per_unit = 1 / flt(bom.quantity or 1)
|
||||||
|
waste_factor = 1 + flt(line.makap_waste_pct) / 100
|
||||||
|
|
||||||
|
line.makap_material_cost = flt(bom.raw_material_cost) * per_unit * qty * waste_factor
|
||||||
|
line.makap_conversion_cost = flt(bom.operating_cost) * per_unit * qty
|
||||||
|
line.makap_estimated_cost = (
|
||||||
|
line.makap_material_cost
|
||||||
|
+ line.makap_conversion_cost
|
||||||
|
+ flt(line.makap_setup_cost)
|
||||||
|
+ flt(line.makap_cylinder_charge)
|
||||||
|
)
|
||||||
|
revenue = flt(line.base_net_amount or line.base_amount)
|
||||||
|
line.makap_estimated_margin_pct = (revenue - line.makap_estimated_cost) / revenue * 100 if revenue else 0
|
||||||
|
|
||||||
|
|
||||||
|
def validate_job_card(doc, method=None):
|
||||||
|
setup = flt(doc.get("makap_setup_waste_kg"))
|
||||||
|
running = flt(doc.get("makap_running_waste_kg"))
|
||||||
|
output = flt(doc.get("makap_output_weight_kg"))
|
||||||
|
if setup < 0 or running < 0 or output < 0:
|
||||||
|
frappe.throw("Output and waste weights cannot be negative.")
|
||||||
|
|
||||||
|
waste = setup + running
|
||||||
|
doc.makap_waste_pct = waste / (output + waste) * 100 if (output + waste) else 0
|
||||||
|
if waste and not doc.get("makap_waste_reason"):
|
||||||
|
frappe.throw("Record the main waste reason when waste is entered.")
|
||||||
|
|
||||||
|
|
||||||
def validate_sales_order(doc, method=None):
|
def validate_sales_order(doc, method=None):
|
||||||
if doc.docstatus == 1 and not doc.makap_customer_acceptance_basis:
|
if doc.docstatus == 1 and not doc.makap_customer_acceptance_basis:
|
||||||
@@ -93,24 +150,17 @@ def validate_sales_order(doc, method=None):
|
|||||||
|
|
||||||
|
|
||||||
def validate_work_order(doc, method=None):
|
def validate_work_order(doc, method=None):
|
||||||
|
work_order_readiness.apply_specification(doc)
|
||||||
|
blockers = work_order_readiness.release_blockers(doc)
|
||||||
|
|
||||||
if doc.docstatus != 1:
|
if doc.docstatus != 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
if doc.makap_manufacturing_readiness_status not in {"Ready", "Released"}:
|
# Re-derived here rather than trusting the stored readiness status, so a
|
||||||
frappe.throw("Submitted Work Order requires Manufacturing Readiness Status Ready or Released.")
|
# stale "Ready" set before the spec or tooling changed can't release a job.
|
||||||
|
if blockers:
|
||||||
if doc.makap_packaging_specification:
|
frappe.throw("Work Order cannot be released:\n" + "\n".join(blockers))
|
||||||
spec_status = frappe.db.get_value(
|
doc.makap_manufacturing_readiness_status = "Released"
|
||||||
"Packaging Specification", doc.makap_packaging_specification, "specification_status"
|
|
||||||
)
|
|
||||||
if spec_status != "Approved for Manufacture":
|
|
||||||
frappe.throw("Work Order requires a Packaging Specification approved for manufacture.")
|
|
||||||
|
|
||||||
if doc.makap_bom_compatibility_status not in {"Compatible", "Not Applicable"}:
|
|
||||||
frappe.throw("Work Order requires a compatible BOM decision before submission.")
|
|
||||||
|
|
||||||
if doc.makap_process_compatibility_status not in {"Compatible", "Not Applicable"}:
|
|
||||||
frappe.throw("Work Order requires a compatible process decision before submission.")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_quality_inspection(doc, method=None):
|
def validate_quality_inspection(doc, method=None):
|
||||||
@@ -134,15 +184,35 @@ def _is_controlled_item(item_code):
|
|||||||
return control_class in {"Controlled", "High Traceability"}
|
return control_class in {"Controlled", "High Traceability"}
|
||||||
|
|
||||||
|
|
||||||
def _latest_quality_disposition(item_code, batch_no=None):
|
DISPATCH_GATE_STAGES = ["Final", "Outgoing"]
|
||||||
"""Best-effort read of the most recent relevant Quality Inspection for this
|
|
||||||
item/batch. Real batch-population matching (M8 section 8/9) is intentionally
|
|
||||||
left as a follow-up once Makap's actual QI-to-batch linkage in this bench is
|
def _line_batches(line):
|
||||||
confirmed — this covers the common case where batch_no is captured on the QI.
|
"""Batches on a stock line, whether held directly or in a Serial and Batch Bundle."""
|
||||||
|
if line.get("batch_no"):
|
||||||
|
return [line.batch_no]
|
||||||
|
if line.get("serial_and_batch_bundle"):
|
||||||
|
return frappe.get_all(
|
||||||
|
"Serial and Batch Entry",
|
||||||
|
filters={"parent": line.serial_and_batch_bundle, "batch_no": ["is", "set"]},
|
||||||
|
pluck="batch_no",
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_quality_disposition(item_code, batch_no):
|
||||||
|
"""Most recent submitted final/outgoing Quality Inspection for this exact batch.
|
||||||
|
|
||||||
|
Matching on the batch is mandatory: an item-only match would let a pass on
|
||||||
|
one roll release a different roll that was never inspected.
|
||||||
"""
|
"""
|
||||||
filters = {"item_code": item_code, "docstatus": 1}
|
filters = {
|
||||||
if batch_no:
|
"item_code": item_code,
|
||||||
filters["batch_no"] = batch_no
|
"batch_no": batch_no,
|
||||||
|
"docstatus": 1,
|
||||||
|
"makap_quality_gate_stage": ["in", DISPATCH_GATE_STAGES],
|
||||||
|
}
|
||||||
rows = frappe.get_all(
|
rows = frappe.get_all(
|
||||||
"Quality Inspection",
|
"Quality Inspection",
|
||||||
filters=filters,
|
filters=filters,
|
||||||
@@ -164,6 +234,8 @@ def _compute_dispatch_gate(doc):
|
|||||||
if not doc.customer:
|
if not doc.customer:
|
||||||
blockers.append("No Customer on Delivery Note.")
|
blockers.append("No Customer on Delivery Note.")
|
||||||
|
|
||||||
|
reservation_enabled = frappe.db.get_single_value("Stock Settings", "enable_stock_reservation")
|
||||||
|
|
||||||
for line in doc.get("items") or []:
|
for line in doc.get("items") or []:
|
||||||
if not _is_controlled_item(line.item_code):
|
if not _is_controlled_item(line.item_code):
|
||||||
continue
|
continue
|
||||||
@@ -173,19 +245,27 @@ def _compute_dispatch_gate(doc):
|
|||||||
if not line.makap_dispatch_allocation_mode:
|
if not line.makap_dispatch_allocation_mode:
|
||||||
blockers.append(prefix + "Dispatch Allocation Mode (Reserved/Pooled) not set.")
|
blockers.append(prefix + "Dispatch Allocation Mode (Reserved/Pooled) not set.")
|
||||||
|
|
||||||
if line.makap_dispatch_allocation_mode == "Reserved" and not line.makap_stock_reservation_entry:
|
if line.makap_dispatch_allocation_mode == "Reserved":
|
||||||
blockers.append(prefix + "Reserved mode requires a Stock Reservation Entry reference.")
|
if not reservation_enabled:
|
||||||
|
blockers.append(prefix + "Reserved mode needs Stock Reservation enabled in Stock Settings; use Pooled.")
|
||||||
|
elif not line.makap_stock_reservation_entry:
|
||||||
|
blockers.append(prefix + "Reserved mode requires a Stock Reservation Entry reference.")
|
||||||
|
|
||||||
if not line.qty or line.qty <= 0:
|
if not line.qty or line.qty <= 0:
|
||||||
blockers.append(prefix + "Delivery quantity must be greater than zero.")
|
blockers.append(prefix + "Delivery quantity must be greater than zero.")
|
||||||
|
|
||||||
disposition = _latest_quality_disposition(line.item_code, getattr(line, "batch_no", None))
|
batches = _line_batches(line)
|
||||||
if not disposition or disposition.makap_quality_eligibility != "Eligible":
|
if not batches:
|
||||||
blockers.append(prefix + "No current Quality Eligibility = Eligible found for this item/batch.")
|
blockers.append(prefix + "Controlled stock must be dispatched by batch/roll; no batch selected.")
|
||||||
elif disposition.makap_quality_disposition not in {"Release", None, ""}:
|
for batch in batches:
|
||||||
blockers.append(
|
disposition = _latest_quality_disposition(line.item_code, batch)
|
||||||
prefix + f"Latest Quality Inspection disposition is '{disposition.makap_quality_disposition}', not Release."
|
if not disposition or disposition.makap_quality_eligibility != "Eligible":
|
||||||
)
|
blockers.append(prefix + f"Batch {batch} has no final Quality Inspection marked Eligible.")
|
||||||
|
elif disposition.makap_quality_disposition not in {"Release", None, ""}:
|
||||||
|
blockers.append(
|
||||||
|
prefix
|
||||||
|
+ f"Batch {batch}: latest final inspection is '{disposition.makap_quality_disposition}', not Release."
|
||||||
|
)
|
||||||
|
|
||||||
if doc.makap_dispatch_material_accountability_status != "Cleared":
|
if doc.makap_dispatch_material_accountability_status != "Cleared":
|
||||||
blockers.append("Dispatch Material Accountability is not Cleared.")
|
blockers.append("Dispatch Material Accountability is not Cleared.")
|
||||||
@@ -219,7 +299,8 @@ def validate_delivery_note(doc, method=None):
|
|||||||
# directly back into a dispatchable FG Released location, and require a
|
# directly back into a dispatchable FG Released location, and require a
|
||||||
# fresh Quality disposition before the stock is reusable (M8-FR-072/073/074).
|
# fresh Quality disposition before the stock is reusable (M8-FR-072/073/074).
|
||||||
if doc.is_return:
|
if doc.is_return:
|
||||||
if not _returns_hold_warehouse(line.warehouse) and not _returns_hold_warehouse(line.t_warehouse):
|
# On a return Delivery Note, line.warehouse is where goods come back in.
|
||||||
|
if not _returns_hold_warehouse(line.warehouse):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
f"Return item {line.item_code} must be received into a warehouse flagged "
|
f"Return item {line.item_code} must be received into a warehouse flagged "
|
||||||
"as Returns Hold, not directly into a dispatchable location."
|
"as Returns Hold, not directly into a dispatchable location."
|
||||||
@@ -291,12 +372,6 @@ def _recompute_billing_and_collection(sales_order_name):
|
|||||||
|
|
||||||
delivered_unbilled = bool(so.per_delivered and so.per_delivered > 0 and (so.per_billed or 0) < so.per_delivered)
|
delivered_unbilled = bool(so.per_delivered and so.per_delivered > 0 and (so.per_billed or 0) < so.per_delivered)
|
||||||
|
|
||||||
invoices = frappe.get_all(
|
|
||||||
"Sales Invoice",
|
|
||||||
filters={"docstatus": 1},
|
|
||||||
fields=["name", "outstanding_amount", "grand_total", "is_return"],
|
|
||||||
or_filters=None,
|
|
||||||
)
|
|
||||||
# Sales Invoice doesn't carry sales_order directly at header level in all cases;
|
# Sales Invoice doesn't carry sales_order directly at header level in all cases;
|
||||||
# use the child table linkage to scope invoices to this Sales Order.
|
# use the child table linkage to scope invoices to this Sales Order.
|
||||||
linked_invoice_names = frappe.get_all(
|
linked_invoice_names = frappe.get_all(
|
||||||
@@ -305,7 +380,15 @@ def _recompute_billing_and_collection(sales_order_name):
|
|||||||
pluck="parent",
|
pluck="parent",
|
||||||
distinct=True,
|
distinct=True,
|
||||||
)
|
)
|
||||||
linked_invoices = [i for i in invoices if i.name in set(linked_invoice_names)]
|
linked_invoices = (
|
||||||
|
frappe.get_all(
|
||||||
|
"Sales Invoice",
|
||||||
|
filters={"name": ["in", linked_invoice_names], "docstatus": 1},
|
||||||
|
fields=["name", "outstanding_amount", "grand_total", "is_return"],
|
||||||
|
)
|
||||||
|
if linked_invoice_names
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
if not linked_invoices:
|
if not linked_invoices:
|
||||||
collection_status = "Not Yet Due"
|
collection_status = "Not Yet Due"
|
||||||
@@ -335,34 +418,9 @@ def _sales_orders_on_invoice(doc):
|
|||||||
|
|
||||||
|
|
||||||
def validate_sales_invoice(doc, method=None):
|
def validate_sales_invoice(doc, method=None):
|
||||||
if doc.is_return and doc.return_against:
|
# M8-BR-14 (no double credit for one return) is enforced by ERPNext's own
|
||||||
# M8-BR-14: one physical return event = one stock consequence. Block a second
|
# return validation, which caps cumulative credited qty at the invoiced qty.
|
||||||
# Credit Note crediting the same original invoice for a controlled item.
|
# A second, partial Credit Note for the same item is legitimate and allowed.
|
||||||
for line in doc.get("items") or []:
|
|
||||||
if not _is_controlled_item(line.item_code):
|
|
||||||
continue
|
|
||||||
existing = frappe.get_all(
|
|
||||||
"Sales Invoice",
|
|
||||||
filters={
|
|
||||||
"is_return": 1,
|
|
||||||
"return_against": doc.return_against,
|
|
||||||
"docstatus": 1,
|
|
||||||
"name": ["!=", doc.name],
|
|
||||||
},
|
|
||||||
pluck="name",
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
credited_items = frappe.get_all(
|
|
||||||
"Sales Invoice Item",
|
|
||||||
filters={"parent": ["in", existing], "item_code": line.item_code},
|
|
||||||
pluck="name",
|
|
||||||
)
|
|
||||||
if credited_items:
|
|
||||||
frappe.throw(
|
|
||||||
f"Item {line.item_code} against {doc.return_against} already has a Credit "
|
|
||||||
f"Note ({existing[0]}) — this looks like a duplicate return credit."
|
|
||||||
)
|
|
||||||
|
|
||||||
for line in doc.get("items") or []:
|
for line in doc.get("items") or []:
|
||||||
if not _is_controlled_item(line.item_code):
|
if not _is_controlled_item(line.item_code):
|
||||||
continue
|
continue
|
||||||
|
|||||||
+26
-15
@@ -1,7 +1,6 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
# Dimensions that gate Operating Close. Collection is deliberately excluded —
|
# Dimensions that gate Operating Close. Collection is deliberately excluded —
|
||||||
# per the package, a legitimate credit sale can be Operating Closed while the
|
# per the package, a legitimate credit sale can be Operating Closed while the
|
||||||
# receivable remains open (Operating Reopen != Accounting Period Reopen).
|
# receivable remains open (Operating Reopen != Accounting Period Reopen).
|
||||||
@@ -26,6 +25,14 @@ class OrderClosureAndMarginView(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def recompute(self):
|
def recompute(self):
|
||||||
|
self.check_permission("write")
|
||||||
|
self._recompute()
|
||||||
|
return {
|
||||||
|
"operating_close": self.operating_close,
|
||||||
|
"primary_blocker": self.primary_blocker,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _recompute(self):
|
||||||
"""Best-effort recomputation from linked M4-M7 records already in the bench.
|
"""Best-effort recomputation from linked M4-M7 records already in the bench.
|
||||||
Cost/Margin fields are intentionally left as manual entry — deriving those
|
Cost/Margin fields are intentionally left as manual entry — deriving those
|
||||||
correctly needs a real pass over Stock Ledger/GL/Landed Cost Voucher data,
|
correctly needs a real pass over Stock Ledger/GL/Landed Cost Voucher data,
|
||||||
@@ -102,24 +109,32 @@ class OrderClosureAndMarginView(Document):
|
|||||||
)
|
)
|
||||||
self.quality_close = "Open" if open_quality else "Closed"
|
self.quality_close = "Open" if open_quality else "Closed"
|
||||||
|
|
||||||
# Supply Close — first-pass only. Real M7 lineage (Material Request ->
|
# Supply Close — purchases raised for this order through its Production
|
||||||
# Production Plan -> Sales Order) isn't traversed here yet; this checks
|
# Plan(s): Sales Order -> Production Plan -> Material Request -> PO.
|
||||||
# Purchase Order Items sharing this order's items as a rough proxy and
|
plans = frappe.get_all(
|
||||||
# should be replaced once the actual M7 linkage is confirmed against data.
|
"Production Plan Sales Order", filters={"sales_order": so_name}, pluck="parent", distinct=True
|
||||||
item_codes = frappe.get_all(
|
)
|
||||||
"Sales Order Item", filters={"parent": so_name}, pluck="item_code", distinct=True
|
requests = (
|
||||||
|
frappe.get_all(
|
||||||
|
"Material Request Item",
|
||||||
|
filters={"production_plan": ["in", plans], "docstatus": 1},
|
||||||
|
pluck="parent",
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
if plans
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
at_risk_po = (
|
at_risk_po = (
|
||||||
frappe.get_all(
|
frappe.get_all(
|
||||||
"Purchase Order Item",
|
"Purchase Order Item",
|
||||||
filters={
|
filters={
|
||||||
"item_code": ["in", item_codes],
|
"material_request": ["in", requests],
|
||||||
"makap_supplier_confirmation_status": ["in", ["At Risk", "Declined"]],
|
"makap_supplier_confirmation_status": ["in", ["At Risk", "Declined"]],
|
||||||
"docstatus": 1,
|
"docstatus": 1,
|
||||||
},
|
},
|
||||||
limit=1,
|
limit=1,
|
||||||
)
|
)
|
||||||
if item_codes
|
if requests
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
self.supply_close = "Open" if at_risk_po else "Closed"
|
self.supply_close = "Open" if at_risk_po else "Closed"
|
||||||
@@ -127,11 +142,7 @@ class OrderClosureAndMarginView(Document):
|
|||||||
self._derive_operating_close()
|
self._derive_operating_close()
|
||||||
self.last_recomputed_by = frappe.session.user
|
self.last_recomputed_by = frappe.session.user
|
||||||
self.last_recomputed_on = frappe.utils.now_datetime()
|
self.last_recomputed_on = frappe.utils.now_datetime()
|
||||||
self.save(ignore_permissions=True)
|
self.save()
|
||||||
return {
|
|
||||||
"operating_close": self.operating_close,
|
|
||||||
"primary_blocker": self.primary_blocker,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -139,7 +150,7 @@ def get_or_create(sales_order):
|
|||||||
if not frappe.db.exists("Order Closure And Margin View", sales_order):
|
if not frappe.db.exists("Order Closure And Margin View", sales_order):
|
||||||
doc = frappe.get_doc(
|
doc = frappe.get_doc(
|
||||||
{"doctype": "Order Closure And Margin View", "sales_order": sales_order}
|
{"doctype": "Order Closure And Margin View", "sales_order": sales_order}
|
||||||
).insert(ignore_permissions=True)
|
).insert()
|
||||||
else:
|
else:
|
||||||
doc = frappe.get_doc("Order Closure And Margin View", sales_order)
|
doc = frappe.get_doc("Order Closure And Margin View", sales_order)
|
||||||
doc.recompute()
|
doc.recompute()
|
||||||
|
|||||||
+8
-2
@@ -403,8 +403,14 @@
|
|||||||
"options": "Critical Acceptance Requirement"
|
"options": "Critical Acceptance Requirement"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [],
|
"links": [
|
||||||
"modified": "2026-09-02 00:05:48.067955",
|
{
|
||||||
|
"group": "Tooling",
|
||||||
|
"link_doctype": "Print Cylinder",
|
||||||
|
"link_fieldname": "packaging_specification"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modified": "2026-10-09 00:00:00",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "makap_backend",
|
"module": "makap_backend",
|
||||||
"name": "Packaging Specification",
|
"name": "Packaging Specification",
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"autoname": "field:cylinder_id",
|
||||||
|
"creation": "2026-10-09 00:00:00",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"identity_section",
|
||||||
|
"cylinder_id",
|
||||||
|
"tooling_type",
|
||||||
|
"status",
|
||||||
|
"identity_col",
|
||||||
|
"packaging_specification",
|
||||||
|
"item",
|
||||||
|
"customer",
|
||||||
|
"colour_station",
|
||||||
|
"colour_name",
|
||||||
|
"dimensions_section",
|
||||||
|
"circumference_mm",
|
||||||
|
"face_width_mm",
|
||||||
|
"dimensions_col",
|
||||||
|
"repeat_length_mm",
|
||||||
|
"engraving_reference",
|
||||||
|
"ownership_section",
|
||||||
|
"ownership",
|
||||||
|
"engraver",
|
||||||
|
"ownership_col",
|
||||||
|
"purchase_cost",
|
||||||
|
"customer_charge",
|
||||||
|
"condition_section",
|
||||||
|
"location",
|
||||||
|
"condition",
|
||||||
|
"last_used_on",
|
||||||
|
"last_used_work_order",
|
||||||
|
"condition_col",
|
||||||
|
"impression_count",
|
||||||
|
"impression_limit",
|
||||||
|
"retirement_reason",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "identity_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Identity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "cylinder_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Cylinder / Plate ID",
|
||||||
|
"reqd": 1,
|
||||||
|
"unique": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"description": "The number engraved or labelled on the cylinder, sleeve or plate."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "tooling_type",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Tooling Type",
|
||||||
|
"options": "Gravure Cylinder\nFlexo Plate\nFlexo Sleeve\nAnilox Roll\nCutting Die",
|
||||||
|
"reqd": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "status",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Status",
|
||||||
|
"options": "Ordered\nIn Prepress\nReady\nIn Use\nUnder Repair\nRetired",
|
||||||
|
"default": "Ordered",
|
||||||
|
"reqd": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "identity_col",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "packaging_specification",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Packaging Specification",
|
||||||
|
"options": "Packaging Specification",
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "item",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Item",
|
||||||
|
"options": "Item",
|
||||||
|
"fetch_from": "packaging_specification.item",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Customer",
|
||||||
|
"options": "Customer",
|
||||||
|
"fetch_from": "packaging_specification.customer",
|
||||||
|
"read_only": 1,
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "colour_station",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"label": "Colour Station"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "colour_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Colour",
|
||||||
|
"in_list_view": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "dimensions_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Dimensions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "circumference_mm",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Circumference (mm)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "face_width_mm",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Face Width (mm)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "dimensions_col",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "repeat_length_mm",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Repeat Length (mm)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "engraving_reference",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Engraving / Screen Reference",
|
||||||
|
"description": "Screen ruling, cell depth or plate recipe reference."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "ownership_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Ownership and Cost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "ownership",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Ownership",
|
||||||
|
"options": "Makap\nCustomer\nCharged to Customer",
|
||||||
|
"reqd": 1,
|
||||||
|
"default": "Makap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "engraver",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Engraver / Supplier",
|
||||||
|
"options": "Supplier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "ownership_col",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "purchase_cost",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Purchase Cost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "customer_charge",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Charged to Customer",
|
||||||
|
"depends_on": "eval:doc.ownership=='Charged to Customer'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "condition_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Location and Condition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "location",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Rack / Location",
|
||||||
|
"in_list_view": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "condition",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Condition",
|
||||||
|
"options": "New\nGood\nWorn\nNeeds Re-chrome\nDamaged",
|
||||||
|
"default": "New"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "last_used_on",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Last Used On"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "last_used_work_order",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Last Used On Work Order",
|
||||||
|
"options": "Work Order"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "condition_col",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "impression_count",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Impressions to Date",
|
||||||
|
"default": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "impression_limit",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Impression Limit",
|
||||||
|
"description": "Re-chrome or replace at this count. Leave empty if not tracked."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "retirement_reason",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"label": "Retirement Reason",
|
||||||
|
"depends_on": "eval:doc.status=='Retired'",
|
||||||
|
"mandatory_depends_on": "eval:doc.status=='Retired'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "notes",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"label": "Notes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"modified": "2026-10-09 00:00:00",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "makap_backend",
|
||||||
|
"name": "Print Cylinder",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"role": "System Manager",
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 1,
|
||||||
|
"email": 1,
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Manufacturing Manager",
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 1,
|
||||||
|
"delete": 0,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 1,
|
||||||
|
"email": 1,
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Technical Authority",
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 1,
|
||||||
|
"delete": 0,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 1,
|
||||||
|
"email": 1,
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Production Authority",
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 0,
|
||||||
|
"delete": 0,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 1,
|
||||||
|
"email": 1,
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Manufacturing User",
|
||||||
|
"read": 1,
|
||||||
|
"write": 0,
|
||||||
|
"create": 0,
|
||||||
|
"delete": 0,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 0,
|
||||||
|
"email": 0,
|
||||||
|
"share": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Stock User",
|
||||||
|
"read": 1,
|
||||||
|
"write": 0,
|
||||||
|
"create": 0,
|
||||||
|
"delete": 0,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 0,
|
||||||
|
"email": 0,
|
||||||
|
"share": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Sales User",
|
||||||
|
"read": 1,
|
||||||
|
"write": 0,
|
||||||
|
"create": 0,
|
||||||
|
"delete": 0,
|
||||||
|
"print": 1,
|
||||||
|
"report": 1,
|
||||||
|
"export": 0,
|
||||||
|
"email": 0,
|
||||||
|
"share": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"search_fields": "colour_name,packaging_specification,customer",
|
||||||
|
"show_title_field_in_link": 0,
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "cylinder_id",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
UNFIT_CONDITIONS = {"Needs Re-chrome", "Damaged"}
|
||||||
|
|
||||||
|
|
||||||
|
class PrintCylinder(Document):
|
||||||
|
def validate(self):
|
||||||
|
# Work Order release reads "Ready" as fit to print, so a cylinder that
|
||||||
|
# isn't can't be marked Ready.
|
||||||
|
if self.status == "Ready" and self.condition in UNFIT_CONDITIONS:
|
||||||
|
frappe.throw(f"A cylinder in condition '{self.condition}' cannot be Ready. Send it for repair first.")
|
||||||
|
|
||||||
|
if self.impression_limit and (self.impression_count or 0) >= self.impression_limit:
|
||||||
|
frappe.msgprint(
|
||||||
|
f"{self.name} has reached its impression limit ({self.impression_limit:,.0f}). Plan a re-chrome or replacement.",
|
||||||
|
indicator="orange",
|
||||||
|
alert=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
from makap_backend.makap_backend import operations, work_order_readiness
|
||||||
|
|
||||||
|
ITEM = "_Makap Test Printed Pouch"
|
||||||
|
CUSTOMER = "_Makap Test Customer"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_masters():
|
||||||
|
if not frappe.db.exists("Item", ITEM):
|
||||||
|
frappe.get_doc(
|
||||||
|
{"doctype": "Item", "item_code": ITEM, "item_group": "All Item Groups", "stock_uom": "Nos"}
|
||||||
|
).insert()
|
||||||
|
if not frappe.db.exists("Customer", CUSTOMER):
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Customer",
|
||||||
|
"customer_name": CUSTOMER,
|
||||||
|
"customer_group": "All Customer Groups",
|
||||||
|
"territory": "All Territories",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_spec(print_tooling_required=1):
|
||||||
|
return frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Packaging Specification",
|
||||||
|
"specification_series": "TEST-POUCH",
|
||||||
|
"revision": "A",
|
||||||
|
"customer": CUSTOMER,
|
||||||
|
"item": ITEM,
|
||||||
|
"product_family": "Pouch",
|
||||||
|
"specification_status": "Approved for Manufacture",
|
||||||
|
"quote_eligible": 1,
|
||||||
|
"quote_eligible_by": "Administrator",
|
||||||
|
"quote_eligible_on": frappe.utils.now_datetime(),
|
||||||
|
"approved_by": "Administrator",
|
||||||
|
"approved_on": frappe.utils.now_datetime(),
|
||||||
|
"print_tooling_required": print_tooling_required,
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder(spec, cylinder_id, status="Ready", condition="Good"):
|
||||||
|
return frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Print Cylinder",
|
||||||
|
"cylinder_id": cylinder_id,
|
||||||
|
"tooling_type": "Gravure Cylinder",
|
||||||
|
"status": status,
|
||||||
|
"condition": condition,
|
||||||
|
"ownership": "Makap",
|
||||||
|
"packaging_specification": spec.name,
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrintCylinder(FrappeTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_ensure_masters()
|
||||||
|
|
||||||
|
def test_damaged_cylinder_cannot_be_ready(self):
|
||||||
|
spec = _approved_spec()
|
||||||
|
self.assertRaises(frappe.ValidationError, _cylinder, spec, "CYL-T-DMG", "Ready", "Damaged")
|
||||||
|
|
||||||
|
def test_tooling_blocks_until_every_cylinder_is_ready(self):
|
||||||
|
spec = _approved_spec()
|
||||||
|
self.assertEqual(work_order_readiness.tooling_status(spec.name)[0], "Blocked")
|
||||||
|
|
||||||
|
_cylinder(spec, "CYL-T-1", "Ready")
|
||||||
|
second = _cylinder(spec, "CYL-T-2", "In Prepress")
|
||||||
|
self.assertEqual(work_order_readiness.tooling_status(spec.name)[0], "Blocked")
|
||||||
|
|
||||||
|
second.status = "Ready"
|
||||||
|
second.save()
|
||||||
|
self.assertEqual(work_order_readiness.tooling_status(spec.name), ("Ready", []))
|
||||||
|
|
||||||
|
def test_retired_cylinders_are_ignored(self):
|
||||||
|
spec = _approved_spec()
|
||||||
|
_cylinder(spec, "CYL-T-R1", "Ready")
|
||||||
|
retired = _cylinder(spec, "CYL-T-R2", "Ordered")
|
||||||
|
retired.status = "Retired"
|
||||||
|
retired.retirement_reason = "Replaced"
|
||||||
|
retired.save()
|
||||||
|
self.assertEqual(work_order_readiness.tooling_status(spec.name)[0], "Ready")
|
||||||
|
|
||||||
|
def test_no_tooling_needed(self):
|
||||||
|
spec = _approved_spec(print_tooling_required=0)
|
||||||
|
self.assertEqual(work_order_readiness.tooling_status(spec.name), ("Not Required", []))
|
||||||
|
|
||||||
|
|
||||||
|
class TestOperationsNeedRoles(FrappeTestCase):
|
||||||
|
USER = "makap-test-operator@example.com"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
if not frappe.db.exists("User", self.USER):
|
||||||
|
user = frappe.get_doc({"doctype": "User", "email": self.USER, "first_name": "Operator"}).insert()
|
||||||
|
user.add_roles("Manufacturing User")
|
||||||
|
frappe.set_user(self.USER)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
frappe.set_user("Administrator")
|
||||||
|
|
||||||
|
def test_operator_cannot_set_quality_disposition(self):
|
||||||
|
self.assertRaises(frappe.PermissionError, operations.apply_quality_disposition, "ANY-QI", "Hold")
|
||||||
|
|
||||||
|
def test_operator_cannot_mark_work_order_ready(self):
|
||||||
|
self.assertRaises(frappe.PermissionError, operations.evaluate_work_order_readiness, "ANY-WO")
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
from frappe.utils import cint
|
||||||
|
|
||||||
|
from makap_backend.makap_backend import work_order_readiness
|
||||||
|
|
||||||
|
READINESS_ROLES = ("Production Authority", "Manufacturing Manager")
|
||||||
|
MATERIAL_CHECK_ROLES = ("Production Authority", "Manufacturing Manager", "Materials Controller", "Stock Manager")
|
||||||
|
DISPOSITION_ROLES = ("Quality Manager",)
|
||||||
|
CONCESSION_ROLES = ("Concession Authority",)
|
||||||
|
|
||||||
|
|
||||||
def _require_doc(doctype, name):
|
def _require_doc(doctype, name):
|
||||||
@@ -7,53 +15,52 @@ def _require_doc(doctype, name):
|
|||||||
return frappe.get_doc(doctype, name)
|
return frappe.get_doc(doctype, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(doc, values):
|
||||||
|
"""Store derived evidence under the caller's own permissions.
|
||||||
|
|
||||||
|
Submitted documents can't be re-saved, so evidence on them is written
|
||||||
|
directly; the caller has already passed the role and write checks.
|
||||||
|
"""
|
||||||
|
doc.check_permission("write")
|
||||||
|
if doc.docstatus == 1:
|
||||||
|
doc.db_set(values)
|
||||||
|
else:
|
||||||
|
doc.update(values)
|
||||||
|
doc.save()
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def evaluate_work_order_readiness(work_order_name, save=True):
|
def evaluate_work_order_readiness(work_order_name, save=True):
|
||||||
"""Derive bounded readiness fields from standard Work Order/BOM/specification data."""
|
"""Derive readiness from the same rules the Work Order submit gate enforces."""
|
||||||
|
frappe.only_for(READINESS_ROLES)
|
||||||
work_order = _require_doc("Work Order", work_order_name)
|
work_order = _require_doc("Work Order", work_order_name)
|
||||||
blockers = []
|
blockers = work_order_readiness.release_blockers(work_order)
|
||||||
|
status = "Blocked" if blockers else "Ready"
|
||||||
|
|
||||||
spec_status = None
|
if cint(save):
|
||||||
if work_order.makap_packaging_specification:
|
_record(
|
||||||
spec_status = frappe.db.get_value(
|
work_order,
|
||||||
"Packaging Specification", work_order.makap_packaging_specification, "specification_status"
|
{
|
||||||
|
"makap_manufacturing_readiness_status": status,
|
||||||
|
"makap_manufacturing_readiness_blockers": "\n".join(blockers),
|
||||||
|
"makap_tooling_readiness_status": work_order.makap_tooling_readiness_status,
|
||||||
|
"makap_readiness_checked_by": frappe.session.user,
|
||||||
|
"makap_readiness_checked_on": frappe.utils.now_datetime(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if spec_status != "Approved for Manufacture":
|
|
||||||
blockers.append("Packaging Specification is not Approved for Manufacture")
|
|
||||||
elif work_order.production_item:
|
|
||||||
requires_spec = frappe.db.get_value(
|
|
||||||
"Item", work_order.production_item, "makap_requires_packaging_specification"
|
|
||||||
)
|
|
||||||
if requires_spec:
|
|
||||||
blockers.append("Production Item requires a Packaging Specification")
|
|
||||||
|
|
||||||
if not work_order.bom_no:
|
|
||||||
blockers.append("No BOM is selected")
|
|
||||||
elif work_order.makap_bom_compatibility_status not in {"Compatible", "Not Applicable"}:
|
|
||||||
blockers.append("BOM compatibility is not confirmed")
|
|
||||||
|
|
||||||
if work_order.makap_process_compatibility_status not in {"Compatible", "Not Applicable"}:
|
|
||||||
blockers.append("Process compatibility is not confirmed")
|
|
||||||
|
|
||||||
work_order.makap_manufacturing_readiness_status = "Blocked" if blockers else "Ready"
|
|
||||||
work_order.makap_manufacturing_readiness_blockers = "\n".join(blockers)
|
|
||||||
work_order.makap_readiness_checked_by = frappe.session.user
|
|
||||||
work_order.makap_readiness_checked_on = frappe.utils.now_datetime()
|
|
||||||
if save:
|
|
||||||
work_order.save(ignore_permissions=True)
|
|
||||||
frappe.db.commit()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"work_order": work_order.name,
|
"work_order": work_order.name,
|
||||||
"status": work_order.makap_manufacturing_readiness_status,
|
"status": status,
|
||||||
"blockers": blockers,
|
"blockers": blockers,
|
||||||
"specification_status": spec_status,
|
"tooling_status": work_order.makap_tooling_readiness_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def evaluate_material_availability(work_order_name, save=True):
|
def evaluate_material_availability(work_order_name, save=True):
|
||||||
"""Compare standard Work Order required quantities with standard Bin actual quantity."""
|
"""Compare standard Work Order required quantities with standard Bin actual quantity."""
|
||||||
|
frappe.only_for(MATERIAL_CHECK_ROLES)
|
||||||
work_order = _require_doc("Work Order", work_order_name)
|
work_order = _require_doc("Work Order", work_order_name)
|
||||||
shortages = []
|
shortages = []
|
||||||
partially_available = False
|
partially_available = False
|
||||||
@@ -82,13 +89,16 @@ def evaluate_material_availability(work_order_name, save=True):
|
|||||||
else:
|
else:
|
||||||
status = "Available"
|
status = "Available"
|
||||||
|
|
||||||
work_order.makap_material_availability_status = status
|
if cint(save):
|
||||||
work_order.makap_material_availability_checked_by = frappe.session.user
|
_record(
|
||||||
work_order.makap_material_availability_checked_on = frappe.utils.now_datetime()
|
work_order,
|
||||||
work_order.makap_material_availability_blocker_summary = "\n".join(shortages)
|
{
|
||||||
if save:
|
"makap_material_availability_status": status,
|
||||||
work_order.save(ignore_permissions=True)
|
"makap_material_availability_checked_by": frappe.session.user,
|
||||||
frappe.db.commit()
|
"makap_material_availability_checked_on": frappe.utils.now_datetime(),
|
||||||
|
"makap_material_availability_blocker_summary": "\n".join(shortages),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return {"work_order": work_order.name, "status": status, "shortages": shortages}
|
return {"work_order": work_order.name, "status": status, "shortages": shortages}
|
||||||
|
|
||||||
@@ -96,7 +106,10 @@ def evaluate_material_availability(work_order_name, save=True):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def apply_quality_disposition(quality_inspection_name, disposition, quantity=None, reason=None):
|
def apply_quality_disposition(quality_inspection_name, disposition, quantity=None, reason=None):
|
||||||
"""Record bounded quality disposition evidence on a standard Quality Inspection."""
|
"""Record bounded quality disposition evidence on a standard Quality Inspection."""
|
||||||
|
frappe.only_for(CONCESSION_ROLES + DISPOSITION_ROLES if disposition == "Concession" else DISPOSITION_ROLES)
|
||||||
inspection = _require_doc("Quality Inspection", quality_inspection_name)
|
inspection = _require_doc("Quality Inspection", quality_inspection_name)
|
||||||
|
if inspection.docstatus != 0:
|
||||||
|
frappe.throw("Disposition can only be recorded on a draft Quality Inspection; amend it to change a submitted one.")
|
||||||
allowed = {"Pending", "Release", "Hold", "Reject", "Rework", "Scrap", "Concession"}
|
allowed = {"Pending", "Release", "Hold", "Reject", "Rework", "Scrap", "Concession"}
|
||||||
if disposition not in allowed:
|
if disposition not in allowed:
|
||||||
frappe.throw("Invalid quality disposition.")
|
frappe.throw("Invalid quality disposition.")
|
||||||
@@ -114,14 +127,17 @@ def apply_quality_disposition(quality_inspection_name, disposition, quantity=Non
|
|||||||
inspection.makap_quality_eligibility = "Eligible"
|
inspection.makap_quality_eligibility = "Eligible"
|
||||||
elif disposition in {"Hold", "Reject", "Rework", "Scrap"}:
|
elif disposition in {"Hold", "Reject", "Rework", "Scrap"}:
|
||||||
inspection.makap_quality_eligibility = "Hold" if disposition == "Hold" else "Ineligible"
|
inspection.makap_quality_eligibility = "Hold" if disposition == "Hold" else "Ineligible"
|
||||||
inspection.save(ignore_permissions=True)
|
inspection.check_permission("write")
|
||||||
frappe.db.commit()
|
inspection.save()
|
||||||
return {"quality_inspection": inspection.name, "disposition": disposition}
|
return {"quality_inspection": inspection.name, "disposition": disposition}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_supply_assurance_snapshot(item_code, warehouse=None):
|
def get_supply_assurance_snapshot(item_code, warehouse=None):
|
||||||
"""Return a derived supply view from standard stock, material requests, and purchase orders."""
|
"""Return a derived supply view from standard stock, material requests, and purchase orders."""
|
||||||
|
for doctype in ("Bin", "Material Request", "Purchase Order"):
|
||||||
|
if not frappe.has_permission(doctype, "read"):
|
||||||
|
frappe.throw(f"You do not have permission to read {doctype}.", frappe.PermissionError)
|
||||||
bins = frappe.get_all(
|
bins = frappe.get_all(
|
||||||
"Bin",
|
"Bin",
|
||||||
filters={"item_code": item_code, **({"warehouse": warehouse} if warehouse else {})},
|
filters={"item_code": item_code, **({"warehouse": warehouse} if warehouse else {})},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
frappe.query_reports["Roll Genealogy"] = {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
fieldname: "batch_no",
|
||||||
|
label: __("Batch / Roll"),
|
||||||
|
fieldtype: "Link",
|
||||||
|
options: "Batch",
|
||||||
|
reqd: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: "direction",
|
||||||
|
label: __("Direction"),
|
||||||
|
fieldtype: "Select",
|
||||||
|
options: [
|
||||||
|
{ value: "Backward", label: __("Backward: what went into it") },
|
||||||
|
{ value: "Forward", label: __("Forward: where it was used") },
|
||||||
|
],
|
||||||
|
default: "Backward",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formatter(value, row, column, data, default_formatter) {
|
||||||
|
value = default_formatter(value, row, column, data);
|
||||||
|
if (column.fieldname === "batch_no" && data && data.level) {
|
||||||
|
value = " ".repeat(data.level * 4) + value;
|
||||||
|
}
|
||||||
|
if (column.fieldname === "quality" && data && ["Hold", "Reject", "Scrap"].includes(data.quality)) {
|
||||||
|
value = `<span class="text-danger">${value}</span>`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"add_total_row": 0,
|
||||||
|
"columns": [],
|
||||||
|
"creation": "2026-10-09 00:00:00",
|
||||||
|
"disabled": 0,
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Report",
|
||||||
|
"filters": [],
|
||||||
|
"is_standard": "Yes",
|
||||||
|
"letterhead": null,
|
||||||
|
"modified": "2026-10-09 00:00:00",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "makap_backend",
|
||||||
|
"name": "Roll Genealogy",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"prepared_report": 0,
|
||||||
|
"ref_doctype": "Batch",
|
||||||
|
"report_name": "Roll Genealogy",
|
||||||
|
"report_type": "Script Report",
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "Stock User"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Stock Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Quality Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Manufacturing User"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Manufacturing Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Sales User"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Trace a roll/batch through every Manufacture entry that made or used it.
|
||||||
|
|
||||||
|
Backward answers "what went into this carton?" (complaint investigation);
|
||||||
|
forward answers "where did this resin lot end up?" (recall). Genealogy comes
|
||||||
|
from submitted Manufacture Stock Entries, so it needs no extra data entry:
|
||||||
|
every input batch on an entry is a parent of every output batch on it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
|
||||||
|
MAX_DEPTH = 12
|
||||||
|
|
||||||
|
# One row per (stock entry row, batch), whether the batch sits on the row
|
||||||
|
# itself or inside its Serial and Batch Bundle.
|
||||||
|
MOVEMENTS = """
|
||||||
|
select
|
||||||
|
sed.parent as stock_entry, sed.item_code,
|
||||||
|
coalesce(sbe.batch_no, sed.batch_no) as batch_no,
|
||||||
|
abs(coalesce(sbe.qty, sed.transfer_qty)) as qty,
|
||||||
|
ifnull(sed.s_warehouse, '') != '' as is_input,
|
||||||
|
se.work_order, se.posting_date
|
||||||
|
from `tabStock Entry Detail` sed
|
||||||
|
join `tabStock Entry` se on se.name = sed.parent
|
||||||
|
left join `tabSerial and Batch Entry` sbe on sbe.parent = sed.serial_and_batch_bundle
|
||||||
|
where se.docstatus = 1
|
||||||
|
and se.purpose = 'Manufacture'
|
||||||
|
and coalesce(sbe.batch_no, sed.batch_no) is not null
|
||||||
|
and sed.is_scrap_item = 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _movements(condition, values):
|
||||||
|
return frappe.db.sql(f"{MOVEMENTS} and {condition}", values, as_dict=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _links(batch_no, backward):
|
||||||
|
"""Batches one step away: inputs of the entries that produced this batch,
|
||||||
|
or outputs of the entries that consumed it."""
|
||||||
|
found = _movements(
|
||||||
|
"coalesce(sbe.batch_no, sed.batch_no) = %(batch)s and (ifnull(sed.s_warehouse, '') != '') = %(is_input)s",
|
||||||
|
{"batch": batch_no, "is_input": 0 if backward else 1},
|
||||||
|
)
|
||||||
|
entries = list({row.stock_entry for row in found})
|
||||||
|
if not entries:
|
||||||
|
return []
|
||||||
|
return _movements(
|
||||||
|
"sed.parent in %(entries)s and (ifnull(sed.s_warehouse, '') != '') = %(is_input)s",
|
||||||
|
{"entries": entries, "is_input": 1 if backward else 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _final_disposition(item_code, batch_no):
|
||||||
|
row = frappe.get_all(
|
||||||
|
"Quality Inspection",
|
||||||
|
filters={"item_code": item_code, "batch_no": batch_no, "docstatus": 1},
|
||||||
|
fields=["makap_quality_disposition"],
|
||||||
|
order_by="creation desc",
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
return row[0].makap_quality_disposition if row else _("Not inspected")
|
||||||
|
|
||||||
|
|
||||||
|
def _row(level, movement):
|
||||||
|
roll = frappe.db.get_value(
|
||||||
|
"Batch",
|
||||||
|
movement.batch_no,
|
||||||
|
["item", "makap_roll_stage", "makap_width_mm", "makap_length_m", "makap_net_weight_kg", "makap_supplier_lot"],
|
||||||
|
as_dict=True,
|
||||||
|
) or {}
|
||||||
|
item_code = movement.get("item_code") or roll.get("item")
|
||||||
|
return {
|
||||||
|
"level": level,
|
||||||
|
"indent": level,
|
||||||
|
"batch_no": movement.batch_no,
|
||||||
|
"item_code": item_code,
|
||||||
|
"item_name": frappe.db.get_value("Item", item_code, "item_name") if item_code else None,
|
||||||
|
"roll_stage": roll.get("makap_roll_stage"),
|
||||||
|
"width_mm": roll.get("makap_width_mm"),
|
||||||
|
"length_m": roll.get("makap_length_m"),
|
||||||
|
"net_weight_kg": roll.get("makap_net_weight_kg"),
|
||||||
|
"supplier_lot": roll.get("makap_supplier_lot"),
|
||||||
|
"qty": movement.get("qty"),
|
||||||
|
"stock_entry": movement.get("stock_entry"),
|
||||||
|
"work_order": movement.get("work_order"),
|
||||||
|
"posting_date": movement.get("posting_date"),
|
||||||
|
"quality": _final_disposition(item_code, movement.batch_no) if item_code else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute(filters=None):
|
||||||
|
filters = frappe._dict(filters or {})
|
||||||
|
if not filters.batch_no:
|
||||||
|
frappe.throw(_("Select a Batch / Roll to trace."))
|
||||||
|
backward = filters.get("direction", "Backward") == "Backward"
|
||||||
|
|
||||||
|
rows = [_row(0, frappe._dict(batch_no=filters.batch_no))]
|
||||||
|
visited = {filters.batch_no}
|
||||||
|
|
||||||
|
def walk(batch_no, level):
|
||||||
|
if level > MAX_DEPTH:
|
||||||
|
return
|
||||||
|
for movement in _links(batch_no, backward):
|
||||||
|
if movement.batch_no in visited:
|
||||||
|
continue
|
||||||
|
visited.add(movement.batch_no)
|
||||||
|
rows.append(_row(level, movement))
|
||||||
|
walk(movement.batch_no, level + 1)
|
||||||
|
|
||||||
|
walk(filters.batch_no, 1)
|
||||||
|
return _columns(backward), rows
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(backward):
|
||||||
|
via = _("Made In") if not backward else _("Consumed In")
|
||||||
|
return [
|
||||||
|
{"fieldname": "level", "label": _("Level"), "fieldtype": "Int", "width": 60},
|
||||||
|
{"fieldname": "batch_no", "label": _("Batch / Roll"), "fieldtype": "Link", "options": "Batch", "width": 170},
|
||||||
|
{"fieldname": "item_code", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 140},
|
||||||
|
{"fieldname": "item_name", "label": _("Item Name"), "fieldtype": "Data", "width": 180},
|
||||||
|
{"fieldname": "roll_stage", "label": _("Stage"), "fieldtype": "Data", "width": 120},
|
||||||
|
{"fieldname": "width_mm", "label": _("Width (mm)"), "fieldtype": "Float", "width": 90},
|
||||||
|
{"fieldname": "length_m", "label": _("Length (m)"), "fieldtype": "Float", "width": 90},
|
||||||
|
{"fieldname": "net_weight_kg", "label": _("Net (kg)"), "fieldtype": "Float", "width": 90},
|
||||||
|
{"fieldname": "supplier_lot", "label": _("Supplier Lot"), "fieldtype": "Data", "width": 120},
|
||||||
|
{"fieldname": "qty", "label": _("Qty"), "fieldtype": "Float", "width": 90},
|
||||||
|
{"fieldname": "stock_entry", "label": via, "fieldtype": "Link", "options": "Stock Entry", "width": 150},
|
||||||
|
{"fieldname": "work_order", "label": _("Work Order"), "fieldtype": "Link", "options": "Work Order", "width": 150},
|
||||||
|
{"fieldname": "posting_date", "label": _("Date"), "fieldtype": "Date", "width": 100},
|
||||||
|
{"fieldname": "quality", "label": _("Latest QI Disposition"), "fieldtype": "Data", "width": 140},
|
||||||
|
]
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
frappe.query_reports["Work Order Cost Variance"] = {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
fieldname: "company",
|
||||||
|
label: __("Company"),
|
||||||
|
fieldtype: "Link",
|
||||||
|
options: "Company",
|
||||||
|
default: frappe.defaults.get_user_default("Company"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: "from_date",
|
||||||
|
label: __("Completed From"),
|
||||||
|
fieldtype: "Date",
|
||||||
|
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: "to_date",
|
||||||
|
label: __("Completed To"),
|
||||||
|
fieldtype: "Date",
|
||||||
|
default: frappe.datetime.get_today(),
|
||||||
|
},
|
||||||
|
{ fieldname: "sales_order", label: __("Sales Order"), fieldtype: "Link", options: "Sales Order" },
|
||||||
|
{ fieldname: "work_order", label: __("Work Order"), fieldtype: "Link", options: "Work Order" },
|
||||||
|
{ fieldname: "production_item", label: __("Item"), fieldtype: "Link", options: "Item" },
|
||||||
|
],
|
||||||
|
formatter(value, row, column, data, default_formatter) {
|
||||||
|
value = default_formatter(value, row, column, data);
|
||||||
|
const variance = ["material_variance", "operating_variance", "total_variance", "variance_pct"];
|
||||||
|
if (variance.includes(column.fieldname) && data && data[column.fieldname] > 0) {
|
||||||
|
value = `<span class="text-danger">${value}</span>`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"add_total_row": 1,
|
||||||
|
"columns": [],
|
||||||
|
"creation": "2026-10-09 00:00:00",
|
||||||
|
"disabled": 0,
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Report",
|
||||||
|
"filters": [],
|
||||||
|
"is_standard": "Yes",
|
||||||
|
"letterhead": null,
|
||||||
|
"modified": "2026-10-09 00:00:00",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "makap_backend",
|
||||||
|
"name": "Work Order Cost Variance",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"prepared_report": 0,
|
||||||
|
"ref_doctype": "Work Order",
|
||||||
|
"report_name": "Work Order Cost Variance",
|
||||||
|
"report_type": "Script Report",
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "Manufacturing Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Accounts User"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Accounts Manager"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
"""Planned versus actual cost per completed Work Order (PRD 4 stage 5).
|
||||||
|
|
||||||
|
Planned = the Work Order's BOM costs scaled to the quantity actually produced.
|
||||||
|
Actual = material consumed on submitted Manufacture / Material Consumption
|
||||||
|
entries, plus operating cost from Job Card time logs. Values are in company
|
||||||
|
currency. Material actuals are only meaningful when Manufacturing Settings
|
||||||
|
backflush on "Material Transferred for Manufacture", not on BOM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.utils import flt
|
||||||
|
|
||||||
|
CONSUMING_PURPOSES = ("Manufacture", "Material Consumption for Manufacture")
|
||||||
|
|
||||||
|
|
||||||
|
def execute(filters=None):
|
||||||
|
filters = frappe._dict(filters or {})
|
||||||
|
work_orders = _work_orders(filters)
|
||||||
|
if not work_orders:
|
||||||
|
return _columns(), []
|
||||||
|
|
||||||
|
names = [wo.name for wo in work_orders]
|
||||||
|
actuals = {row.work_order: row for row in _stock_actuals(names)}
|
||||||
|
waste = {row.work_order: row.waste_kg for row in _waste(names)}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for wo in work_orders:
|
||||||
|
share = flt(wo.produced_qty) / flt(wo.qty) if wo.qty else 0
|
||||||
|
per_unit = flt(wo.produced_qty) / flt(wo.bom_qty or 1)
|
||||||
|
planned_material = flt(wo.raw_material_cost) * per_unit
|
||||||
|
planned_scrap = flt(wo.scrap_material_cost) * per_unit
|
||||||
|
planned_operating = flt(wo.planned_operating_cost) * share
|
||||||
|
actual = actuals.get(wo.name) or frappe._dict()
|
||||||
|
actual_material = flt(actual.material)
|
||||||
|
actual_operating = flt(wo.actual_operating_cost)
|
||||||
|
scrap = flt(actual.scrap)
|
||||||
|
|
||||||
|
planned_total = planned_material + planned_operating - planned_scrap
|
||||||
|
actual_total = actual_material + actual_operating - scrap
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"work_order": wo.name,
|
||||||
|
"production_item": wo.production_item,
|
||||||
|
"sales_order": wo.sales_order,
|
||||||
|
"produced_qty": wo.produced_qty,
|
||||||
|
"planned_material": planned_material,
|
||||||
|
"actual_material": actual_material,
|
||||||
|
"material_variance": actual_material - planned_material,
|
||||||
|
"planned_operating": planned_operating,
|
||||||
|
"actual_operating": actual_operating,
|
||||||
|
"operating_variance": actual_operating - planned_operating,
|
||||||
|
"scrap_value": scrap,
|
||||||
|
"waste_kg": flt(waste.get(wo.name)),
|
||||||
|
"total_variance": actual_total - planned_total,
|
||||||
|
"variance_pct": (actual_total - planned_total) / planned_total * 100 if planned_total else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _columns(), rows
|
||||||
|
|
||||||
|
|
||||||
|
def _work_orders(filters):
|
||||||
|
conditions = ["wo.docstatus = 1", "wo.produced_qty > 0"]
|
||||||
|
for key in ("company", "work_order", "sales_order", "production_item"):
|
||||||
|
if filters.get(key):
|
||||||
|
column = "name" if key == "work_order" else key
|
||||||
|
conditions.append(f"wo.{column} = %({key})s")
|
||||||
|
if filters.get("from_date"):
|
||||||
|
conditions.append("date(coalesce(wo.actual_end_date, wo.modified)) >= %(from_date)s")
|
||||||
|
if filters.get("to_date"):
|
||||||
|
conditions.append("date(coalesce(wo.actual_end_date, wo.modified)) <= %(to_date)s")
|
||||||
|
|
||||||
|
return frappe.db.sql(
|
||||||
|
f"""
|
||||||
|
select wo.name, wo.production_item, wo.sales_order, wo.qty, wo.produced_qty,
|
||||||
|
wo.planned_operating_cost, wo.actual_operating_cost,
|
||||||
|
bom.raw_material_cost, bom.scrap_material_cost, bom.quantity as bom_qty
|
||||||
|
from `tabWork Order` wo
|
||||||
|
left join `tabBOM` bom on bom.name = wo.bom_no
|
||||||
|
where {" and ".join(conditions)}
|
||||||
|
order by coalesce(wo.actual_end_date, wo.modified) desc
|
||||||
|
""",
|
||||||
|
filters,
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_actuals(work_orders):
|
||||||
|
return frappe.db.sql(
|
||||||
|
"""
|
||||||
|
select se.work_order,
|
||||||
|
sum(case when ifnull(sed.s_warehouse, '') != '' then sed.amount else 0 end) as material,
|
||||||
|
sum(case when sed.is_scrap_item = 1 then sed.amount else 0 end) as scrap
|
||||||
|
from `tabStock Entry` se
|
||||||
|
join `tabStock Entry Detail` sed on sed.parent = se.name
|
||||||
|
where se.docstatus = 1 and se.purpose in %(purposes)s and se.work_order in %(work_orders)s
|
||||||
|
group by se.work_order
|
||||||
|
""",
|
||||||
|
{"purposes": CONSUMING_PURPOSES, "work_orders": work_orders},
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _waste(work_orders):
|
||||||
|
return frappe.db.sql(
|
||||||
|
"""
|
||||||
|
select work_order,
|
||||||
|
sum(ifnull(makap_setup_waste_kg, 0) + ifnull(makap_running_waste_kg, 0)) as waste_kg
|
||||||
|
from `tabJob Card`
|
||||||
|
where docstatus = 1 and work_order in %(work_orders)s
|
||||||
|
group by work_order
|
||||||
|
""",
|
||||||
|
{"work_orders": work_orders},
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _columns():
|
||||||
|
money = {"fieldtype": "Currency", "width": 130}
|
||||||
|
return [
|
||||||
|
{"fieldname": "work_order", "label": _("Work Order"), "fieldtype": "Link", "options": "Work Order", "width": 150},
|
||||||
|
{"fieldname": "production_item", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 140},
|
||||||
|
{"fieldname": "sales_order", "label": _("Sales Order"), "fieldtype": "Link", "options": "Sales Order", "width": 140},
|
||||||
|
{"fieldname": "produced_qty", "label": _("Produced Qty"), "fieldtype": "Float", "width": 100},
|
||||||
|
{"fieldname": "planned_material", "label": _("Planned Material"), **money},
|
||||||
|
{"fieldname": "actual_material", "label": _("Actual Material"), **money},
|
||||||
|
{"fieldname": "material_variance", "label": _("Material Variance"), **money},
|
||||||
|
{"fieldname": "planned_operating", "label": _("Planned Operating"), **money},
|
||||||
|
{"fieldname": "actual_operating", "label": _("Actual Operating"), **money},
|
||||||
|
{"fieldname": "operating_variance", "label": _("Operating Variance"), **money},
|
||||||
|
{"fieldname": "scrap_value", "label": _("Scrap Recovered"), **money},
|
||||||
|
{"fieldname": "waste_kg", "label": _("Waste (kg)"), "fieldtype": "Float", "width": 100},
|
||||||
|
{"fieldname": "total_variance", "label": _("Total Variance"), **money},
|
||||||
|
{"fieldname": "variance_pct", "label": _("Variance %"), "fieldtype": "Percent", "width": 100},
|
||||||
|
]
|
||||||
@@ -103,6 +103,9 @@ def ensure_makap_functional_roles():
|
|||||||
"Commercial Authority",
|
"Commercial Authority",
|
||||||
# Module 8B
|
# Module 8B
|
||||||
"Credit Authority",
|
"Credit Authority",
|
||||||
|
# Shop floor and prepress (PRD Section 3)
|
||||||
|
"Machine Operator",
|
||||||
|
"Prepress",
|
||||||
]
|
]
|
||||||
created = []
|
created = []
|
||||||
for role_name in role_names:
|
for role_name in role_names:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||||
|
|
||||||
FIELDS = [
|
FIELDS = [
|
||||||
# Module 2 — commercial/product linkage
|
# Module 2 — commercial/product linkage
|
||||||
@@ -13,6 +13,7 @@ FIELDS = [
|
|||||||
("Sales Order", "makap_acceptance_reference", "Acceptance Reference", "Data", None, 0),
|
("Sales Order", "makap_acceptance_reference", "Acceptance Reference", "Data", None, 0),
|
||||||
# Module 4 — manufacturing readiness and process classification
|
# Module 4 — manufacturing readiness and process classification
|
||||||
("Operation", "makap_process_type", "Makap Process Type", "Select", "Printing — Gravure\nPrinting — Flexographic\nLamination\nSlitting\nRewinding\nConversion / Bag Making\nFinishing\nOther", 0),
|
("Operation", "makap_process_type", "Makap Process Type", "Select", "Printing — Gravure\nPrinting — Flexographic\nLamination\nSlitting\nRewinding\nConversion / Bag Making\nFinishing\nOther", 0),
|
||||||
|
("Work Order", "makap_release_section", "Release Readiness", "Section Break", None, 0),
|
||||||
("Work Order", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 1),
|
("Work Order", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 1),
|
||||||
("Work Order", "makap_specification_revision", "Specification Revision", "Data", None, 1),
|
("Work Order", "makap_specification_revision", "Specification Revision", "Data", None, 1),
|
||||||
("Work Order", "makap_sales_order_item_reference", "Sales Order Item Reference", "Data", None, 0),
|
("Work Order", "makap_sales_order_item_reference", "Sales Order Item Reference", "Data", None, 0),
|
||||||
@@ -25,9 +26,10 @@ FIELDS = [
|
|||||||
("Work Order", "makap_bom_compatibility_confirmed_by", "BOM Compatibility Confirmed By", "Link", "User", 1),
|
("Work Order", "makap_bom_compatibility_confirmed_by", "BOM Compatibility Confirmed By", "Link", "User", 1),
|
||||||
("Work Order", "makap_bom_compatibility_confirmed_on", "BOM Compatibility Confirmed On", "Datetime", None, 1),
|
("Work Order", "makap_bom_compatibility_confirmed_on", "BOM Compatibility Confirmed On", "Datetime", None, 1),
|
||||||
("Work Order", "makap_process_compatibility_status", "Process Compatibility Status", "Select", "Not Checked\nCompatible\nIncompatible\nNot Applicable", 0),
|
("Work Order", "makap_process_compatibility_status", "Process Compatibility Status", "Select", "Not Checked\nCompatible\nIncompatible\nNot Applicable", 0),
|
||||||
("Work Order", "makap_tooling_readiness_status", "Tooling Readiness Status", "Select", "Not Checked\nReady\nBlocked\nNot Required", 0),
|
("Work Order", "makap_tooling_readiness_status", "Tooling Readiness Status", "Select", "Not Checked\nReady\nBlocked\nNot Required", 1),
|
||||||
# Module 5 — bounded material accountability controls
|
# Module 5 — bounded material accountability controls
|
||||||
("Item", "makap_material_control_class", "Material Control Class", "Select", "Standard\nControlled\nHigh Traceability", 0),
|
("Item", "makap_material_control_class", "Material Control Class", "Select", "Standard\nControlled\nHigh Traceability", 0),
|
||||||
|
("Work Order", "makap_material_section", "Material Accountability", "Section Break", None, 0),
|
||||||
("Work Order", "makap_material_availability_status", "Material Availability Status", "Select", "Not Checked\nBlocked\nPartially Available\nAvailable", 0),
|
("Work Order", "makap_material_availability_status", "Material Availability Status", "Select", "Not Checked\nBlocked\nPartially Available\nAvailable", 0),
|
||||||
("Work Order", "makap_material_availability_checked_by", "Material Availability Checked By", "Link", "User", 1),
|
("Work Order", "makap_material_availability_checked_by", "Material Availability Checked By", "Link", "User", 1),
|
||||||
("Work Order", "makap_material_availability_checked_on", "Material Availability Checked On", "Datetime", None, 1),
|
("Work Order", "makap_material_availability_checked_on", "Material Availability Checked On", "Datetime", None, 1),
|
||||||
@@ -42,6 +44,7 @@ FIELDS = [
|
|||||||
("Quality Inspection", "makap_work_order", "Work Order", "Link", "Work Order", 0),
|
("Quality Inspection", "makap_work_order", "Work Order", "Link", "Work Order", 0),
|
||||||
("Quality Inspection", "makap_quality_gate_stage", "Quality Gate Stage", "Select", "First-Off\nIn-Process\nFinal\nIncoming\nOutgoing\nNot Applicable", 0),
|
("Quality Inspection", "makap_quality_gate_stage", "Quality Gate Stage", "Select", "First-Off\nIn-Process\nFinal\nIncoming\nOutgoing\nNot Applicable", 0),
|
||||||
("Quality Inspection", "makap_inspection_trigger", "Inspection Trigger", "Select", "Routine\nFirst-Off\nPeriodic\nPost-Adjustment\nPost-Rework\nComplaint\nRetrospective Hold", 0),
|
("Quality Inspection", "makap_inspection_trigger", "Inspection Trigger", "Select", "Routine\nFirst-Off\nPeriodic\nPost-Adjustment\nPost-Rework\nComplaint\nRetrospective Hold", 0),
|
||||||
|
("Quality Inspection", "makap_eligibility_section", "Eligibility and Disposition", "Section Break", None, 0),
|
||||||
("Quality Inspection", "makap_quality_eligibility", "Quality Eligibility", "Select", "Not Determined\nEligible\nIneligible\nHold", 0),
|
("Quality Inspection", "makap_quality_eligibility", "Quality Eligibility", "Select", "Not Determined\nEligible\nIneligible\nHold", 0),
|
||||||
("Quality Inspection", "makap_inspection_population_quantity", "Inspection Population Quantity", "Float", None, 0),
|
("Quality Inspection", "makap_inspection_population_quantity", "Inspection Population Quantity", "Float", None, 0),
|
||||||
("Quality Inspection", "makap_population_uom", "Population UOM", "Link", "UOM", 0),
|
("Quality Inspection", "makap_population_uom", "Population UOM", "Link", "UOM", 0),
|
||||||
@@ -53,6 +56,7 @@ FIELDS = [
|
|||||||
("Quality Inspection", "makap_disposition_on", "Disposition On", "Datetime", None, 1),
|
("Quality Inspection", "makap_disposition_on", "Disposition On", "Datetime", None, 1),
|
||||||
("Quality Inspection", "makap_disposition_reason", "Disposition Reason", "Small Text", None, 0),
|
("Quality Inspection", "makap_disposition_reason", "Disposition Reason", "Small Text", None, 0),
|
||||||
("Quality Inspection", "makap_defect_failure_category", "Defect / Failure Category", "Data", None, 0),
|
("Quality Inspection", "makap_defect_failure_category", "Defect / Failure Category", "Data", None, 0),
|
||||||
|
("Quality Inspection", "makap_exception_section", "Exceptions, Reinspection and Concessions", "Section Break", None, 0),
|
||||||
("Quality Inspection", "makap_quality_exception_type", "Quality Exception Type", "Select", "None\nConcession\nReinspection\nRetrospective Hold\nTraceability Exception", 0),
|
("Quality Inspection", "makap_quality_exception_type", "Quality Exception Type", "Select", "None\nConcession\nReinspection\nRetrospective Hold\nTraceability Exception", 0),
|
||||||
("Quality Inspection", "makap_reinspection_of", "Reinspection Of", "Link", "Quality Inspection", 0),
|
("Quality Inspection", "makap_reinspection_of", "Reinspection Of", "Link", "Quality Inspection", 0),
|
||||||
("Quality Inspection", "makap_reinspection_type", "Reinspection Type", "Select", "Full\nPartial\nTargeted", 0),
|
("Quality Inspection", "makap_reinspection_type", "Reinspection Type", "Select", "Full\nPartial\nTargeted", 0),
|
||||||
@@ -84,6 +88,7 @@ FIELDS = [
|
|||||||
("Delivery Note", "makap_dispatch_accountability_notes", "Dispatch Accountability Notes", "Small Text", None, 0),
|
("Delivery Note", "makap_dispatch_accountability_notes", "Dispatch Accountability Notes", "Small Text", None, 0),
|
||||||
# Placeholder only — real deposit/credit logic lands in Module 8B. Defaults to Not Required
|
# Placeholder only — real deposit/credit logic lands in Module 8B. Defaults to Not Required
|
||||||
# so the Dispatch Gate can already evaluate it without blocking every delivery today.
|
# so the Dispatch Gate can already evaluate it without blocking every delivery today.
|
||||||
|
("Delivery Note", "makap_release_section", "Financial Release and Short-Close", "Section Break", None, 0),
|
||||||
("Delivery Note", "makap_financial_release_status", "Financial Release Status", "Select", "Not Required\nNot Assessed\nBlocked\nCleared\nException Approved", 0),
|
("Delivery Note", "makap_financial_release_status", "Financial Release Status", "Select", "Not Required\nNot Assessed\nBlocked\nCleared\nException Approved", 0),
|
||||||
("Delivery Note", "makap_short_close", "Short-Close", "Check", None, 0),
|
("Delivery Note", "makap_short_close", "Short-Close", "Check", None, 0),
|
||||||
("Delivery Note", "makap_short_close_reason", "Short-Close Reason", "Small Text", None, 0),
|
("Delivery Note", "makap_short_close_reason", "Short-Close Reason", "Small Text", None, 0),
|
||||||
@@ -130,32 +135,107 @@ FIELDS = [
|
|||||||
("Purchase Invoice Item", "makap_quantity_variance", "Quantity Variance", "Float", None, 1),
|
("Purchase Invoice Item", "makap_quantity_variance", "Quantity Variance", "Float", None, 1),
|
||||||
("Purchase Invoice Item", "makap_rate_variance", "Rate Variance", "Float", None, 1),
|
("Purchase Invoice Item", "makap_rate_variance", "Rate Variance", "Float", None, 1),
|
||||||
# Module 8F — manufacturing execution cost interpretation
|
# Module 8F — manufacturing execution cost interpretation
|
||||||
|
("Work Order", "makap_cost_section", "Cost Interpretation", "Section Break", None, 0),
|
||||||
("Work Order", "makap_cost_status", "Cost Status", "Select", "Not Assessed\nProvisional\nReady to Close\nClosed\nRestatement Required", 0),
|
("Work Order", "makap_cost_status", "Cost Status", "Select", "Not Assessed\nProvisional\nReady to Close\nClosed\nRestatement Required", 0),
|
||||||
("Work Order", "makap_cost_confidence", "Cost Confidence", "Select", "Not Assessed\nHigh\nQualified\nLow / Provisional", 0),
|
("Work Order", "makap_cost_confidence", "Cost Confidence", "Select", "Not Assessed\nHigh\nQualified\nLow / Provisional", 0),
|
||||||
("Work Order", "makap_cost_attribution_basis", "Cost Attribution Basis", "Select", "Not Assessed\nExact\nDirectly Derived\nPooled / Valuation-Based\nQualified", 0),
|
("Work Order", "makap_cost_attribution_basis", "Cost Attribution Basis", "Select", "Not Assessed\nExact\nDirectly Derived\nPooled / Valuation-Based\nQualified", 0),
|
||||||
|
# Roll tracking — every film/roll batch carries its physical dimensions (PRD 4, B3)
|
||||||
|
("Batch", "makap_roll_stage", "Roll Stage", "Select", "\nResin / Raw Material\nBase Film\nPrinted Roll\nLaminated Roll\nSlit Reel\nFinished Goods", 0),
|
||||||
|
("Batch", "makap_width_mm", "Width (mm)", "Float", None, 0),
|
||||||
|
("Batch", "makap_length_m", "Length (m)", "Float", None, 0),
|
||||||
|
("Batch", "makap_micron", "Thickness (micron)", "Float", None, 0),
|
||||||
|
("Batch", "makap_roll_col", None, "Column Break", None, 0),
|
||||||
|
("Batch", "makap_net_weight_kg", "Net Weight (kg)", "Float", None, 0),
|
||||||
|
("Batch", "makap_gross_weight_kg", "Gross Weight (kg)", "Float", None, 0),
|
||||||
|
("Batch", "makap_supplier_lot", "Supplier Lot / Roll No", "Data", None, 0),
|
||||||
|
("Batch", "makap_workstation", "Produced On", "Link", "Workstation", 0),
|
||||||
|
("Batch", "makap_work_order", "Work Order", "Link", "Work Order", 0),
|
||||||
|
# Operator capture on each stage's Job Card (PRD 4 stage 3, problem 4)
|
||||||
|
("Job Card", "makap_output_rolls", "Output Rolls", "Int", None, 0),
|
||||||
|
("Job Card", "makap_output_length_m", "Output Length (m)", "Float", None, 0),
|
||||||
|
("Job Card", "makap_output_weight_kg", "Output Weight (kg)", "Float", None, 0),
|
||||||
|
("Job Card", "makap_output_col", None, "Column Break", None, 0),
|
||||||
|
("Job Card", "makap_setup_waste_kg", "Set-up Waste (kg)", "Float", None, 0),
|
||||||
|
("Job Card", "makap_running_waste_kg", "Running Waste (kg)", "Float", None, 0),
|
||||||
|
("Job Card", "makap_waste_pct", "Waste %", "Percent", None, 1),
|
||||||
|
("Job Card", "makap_waste_reason", "Main Waste Reason", "Select", "\nSet-up / Make-ready\nRegistration\nColour Match\nWeb Break\nWrinkles / Creasing\nBond Failure\nSeal Failure\nEdge Trim\nMaterial Defect\nMachine Fault\nOther", 0),
|
||||||
|
("Job Card", "makap_waste_notes", "Waste Notes", "Small Text", None, 0),
|
||||||
|
# Quotation cost estimate from an estimating BOM (PRD 4 stage 1, M4)
|
||||||
|
("Quotation Item", "makap_estimate_bom", "Estimating BOM", "Link", "BOM", 0),
|
||||||
|
("Quotation Item", "makap_waste_pct", "Expected Waste %", "Percent", None, 0),
|
||||||
|
("Quotation Item", "makap_setup_cost", "Set-up Cost", "Currency", None, 0),
|
||||||
|
("Quotation Item", "makap_cylinder_charge", "Cylinder / Plate Charge", "Currency", None, 0),
|
||||||
|
("Quotation Item", "makap_estimate_col", None, "Column Break", None, 0),
|
||||||
|
("Quotation Item", "makap_material_cost", "Estimated Material Cost", "Currency", None, 1),
|
||||||
|
("Quotation Item", "makap_conversion_cost", "Estimated Conversion Cost", "Currency", None, 1),
|
||||||
|
("Quotation Item", "makap_estimated_cost", "Estimated Total Cost", "Currency", None, 1),
|
||||||
|
("Quotation Item", "makap_estimated_margin_pct", "Estimated Margin %", "Percent", None, 1),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def ensure_custom_fields():
|
# Quality decisions: only roles granted permlevel 1 on Quality Inspection
|
||||||
created = []
|
# (QA, see setup_roles) can change these.
|
||||||
|
QUALITY_DECISION_FIELDS = {
|
||||||
|
"makap_quality_eligibility", "makap_quality_disposition", "makap_disposition_quantity",
|
||||||
|
"makap_disposition_uom", "makap_disposition_reason", "makap_concession_scope",
|
||||||
|
"makap_concession_quantity", "makap_concession_uom", "makap_concession_reference",
|
||||||
|
"makap_released_not_before",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Where each DocType's Makap fields live. Parent forms get one "Makap" tab;
|
||||||
|
# child rows get a section. Operator and roll fields sit in the main view
|
||||||
|
# because they are filled in on the shop floor, not reviewed later.
|
||||||
|
CONTAINERS = {
|
||||||
|
"Job Card": ("Section Break", "Output and Waste", "actual_end_date"),
|
||||||
|
"Batch": ("Section Break", "Roll Details", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _end_anchor(meta):
|
||||||
|
"""Last standard field, keeping a trailing Connections tab last."""
|
||||||
|
standard = [df for df in meta.fields if not df.get("is_custom_field")]
|
||||||
|
if len(standard) > 1 and standard[-1].fieldtype == "Tab Break":
|
||||||
|
return standard[-2].fieldname
|
||||||
|
return standard[-1].fieldname
|
||||||
|
|
||||||
|
|
||||||
|
def build_custom_fields():
|
||||||
|
grouped = {}
|
||||||
for dt, fieldname, label, fieldtype, options, read_only in FIELDS:
|
for dt, fieldname, label, fieldtype, options, read_only in FIELDS:
|
||||||
|
field = {"fieldname": fieldname, "label": label, "fieldtype": fieldtype, "options": options, "read_only": read_only}
|
||||||
|
if dt == "Quality Inspection" and fieldname in QUALITY_DECISION_FIELDS:
|
||||||
|
field["permlevel"] = 1
|
||||||
|
grouped.setdefault(dt, []).append(field)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for dt, fields in grouped.items():
|
||||||
if not frappe.db.exists("DocType", dt):
|
if not frappe.db.exists("DocType", dt):
|
||||||
continue
|
continue
|
||||||
if frappe.db.exists("Custom Field", {"dt": dt, "fieldname": fieldname}):
|
meta = frappe.get_meta(dt)
|
||||||
continue
|
if dt in CONTAINERS:
|
||||||
doc = frappe.get_doc(
|
break_type, label, anchor = CONTAINERS[dt]
|
||||||
|
elif meta.istable:
|
||||||
|
break_type, label, anchor = "Section Break", "Makap", None
|
||||||
|
else:
|
||||||
|
break_type, label, anchor = "Tab Break", "Makap", None
|
||||||
|
|
||||||
|
chain = [
|
||||||
{
|
{
|
||||||
"doctype": "Custom Field",
|
"fieldname": "makap_tab" if break_type == "Tab Break" else "makap_section",
|
||||||
"dt": dt,
|
|
||||||
"fieldname": fieldname,
|
|
||||||
"label": label,
|
"label": label,
|
||||||
"fieldtype": fieldtype,
|
"fieldtype": break_type,
|
||||||
"options": options,
|
"insert_after": anchor or _end_anchor(meta),
|
||||||
"read_only": read_only,
|
|
||||||
"insert_after": "amended_from" if dt in {"Sales Order", "Work Order", "Quality Inspection", "Delivery Note", "Sales Invoice", "Purchase Invoice"} else None,
|
|
||||||
}
|
}
|
||||||
)
|
]
|
||||||
doc.insert(ignore_permissions=True)
|
for field in fields:
|
||||||
created.append(f"{dt}.{fieldname}")
|
field["insert_after"] = chain[-1]["fieldname"]
|
||||||
frappe.db.commit()
|
chain.append(field)
|
||||||
return created
|
result[dt] = chain
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_custom_fields():
|
||||||
|
"""Create or update every Makap custom field, including its position on the form."""
|
||||||
|
fields = build_custom_fields()
|
||||||
|
create_custom_fields(fields, update=True)
|
||||||
|
return sorted(f"{dt}.{f['fieldname']}" for dt, rows in fields.items() for f in rows)
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import frappe
|
|||||||
from frappe.permissions import add_permission, update_permission_property
|
from frappe.permissions import add_permission, update_permission_property
|
||||||
|
|
||||||
|
|
||||||
def _grant(doctype, role, **flags):
|
def _grant(doctype, role, permlevel=0, **flags):
|
||||||
if not frappe.db.exists("DocType", doctype):
|
if not frappe.db.exists("DocType", doctype):
|
||||||
return
|
return
|
||||||
if not frappe.db.exists("Role", role):
|
if not frappe.db.exists("Role", role):
|
||||||
return
|
return
|
||||||
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": 0}):
|
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}):
|
||||||
add_permission(doctype, role, permlevel=0)
|
add_permission(doctype, role, permlevel=permlevel)
|
||||||
for ptype, value in flags.items():
|
for ptype, value in flags.items():
|
||||||
update_permission_property(doctype, role, 0, ptype, value)
|
update_permission_property(doctype, role, permlevel, ptype, value)
|
||||||
|
|
||||||
|
|
||||||
def ensure_module8_role_permissions():
|
def ensure_module8_role_permissions():
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Role profiles for the user roles in the PRD (Section 3), and the shop-floor
|
||||||
|
permissions they need. Both are created only when missing, so changes Makap
|
||||||
|
makes later in the desk are never overwritten on migrate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
from makap_backend.makap_backend.setup_module8_permissions import _grant
|
||||||
|
|
||||||
|
ROLE_PROFILES = {
|
||||||
|
"Makap - Sales / Account Manager": ["Sales User", "Commercial Reviewer"],
|
||||||
|
"Makap - Design / Prepress": ["Prepress", "Technical Authority"],
|
||||||
|
"Makap - Production Planner": ["Manufacturing Manager", "Production Authority", "Stock User"],
|
||||||
|
"Makap - Procurement": ["Purchase User", "Purchase Manager", "Supply Assurance Manager", "Stock User"],
|
||||||
|
"Makap - Warehouse": ["Stock User", "Materials Controller", "Dispatch Preparer"],
|
||||||
|
"Makap - Machine Operator": ["Machine Operator"],
|
||||||
|
"Makap - Shift Supervisor": ["Machine Operator", "Manufacturing User"],
|
||||||
|
"Makap - QC / QA": ["Quality Manager", "Concession Authority"],
|
||||||
|
"Makap - Finance": ["Accounts User", "Accounts Manager", "Credit Authority", "Commercial Authority"],
|
||||||
|
"Makap - Management": ["Analytics", "Auditor"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Operators record work on Job Cards and label rolls; they don't plan, buy or release.
|
||||||
|
SHOPFLOOR_GRANTS = [
|
||||||
|
("Job Card", "Machine Operator", dict(read=1, write=1, create=1, submit=1)),
|
||||||
|
("Work Order", "Machine Operator", dict(read=1)),
|
||||||
|
("Operation", "Machine Operator", dict(read=1)),
|
||||||
|
("Workstation", "Machine Operator", dict(read=1)),
|
||||||
|
("Item", "Machine Operator", dict(read=1)),
|
||||||
|
("Batch", "Machine Operator", dict(read=1, write=1, create=1)),
|
||||||
|
("Print Cylinder", "Machine Operator", dict(read=1)),
|
||||||
|
("Quality Inspection", "Machine Operator", dict(read=1, write=1, create=1)),
|
||||||
|
("Print Cylinder", "Prepress", dict(read=1, write=1, create=1)),
|
||||||
|
("Packaging Specification", "Prepress", dict(read=1, write=1)),
|
||||||
|
("Item", "Prepress", dict(read=1)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Eligibility and disposition decide whether stock can move on or ship, so
|
||||||
|
# only QA may set them; everyone who can open an inspection may still read them.
|
||||||
|
QUALITY_DECISION_WRITERS = ["Quality Manager", "Concession Authority", "System Manager"]
|
||||||
|
QUALITY_DECISION_READERS = [
|
||||||
|
"Manufacturing User", "Manufacturing Manager", "Machine Operator", "Stock User",
|
||||||
|
"Dispatch Preparer", "Dispatch Authority", "Production Authority",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_makap_role_profiles():
|
||||||
|
created = []
|
||||||
|
for profile, roles in ROLE_PROFILES.items():
|
||||||
|
if frappe.db.exists("Role Profile", profile):
|
||||||
|
continue
|
||||||
|
doc = frappe.new_doc("Role Profile")
|
||||||
|
doc.role_profile = profile
|
||||||
|
for role in roles:
|
||||||
|
if frappe.db.exists("Role", role):
|
||||||
|
doc.append("roles", {"role": role})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
created.append(profile)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_shopfloor_permissions():
|
||||||
|
for doctype, role, flags in SHOPFLOOR_GRANTS:
|
||||||
|
_grant(doctype, role, **flags)
|
||||||
|
|
||||||
|
for role in QUALITY_DECISION_WRITERS:
|
||||||
|
_grant("Quality Inspection", role, permlevel=1, read=1, write=1)
|
||||||
|
for role in QUALITY_DECISION_READERS:
|
||||||
|
_grant("Quality Inspection", role, permlevel=1, read=1)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""One-off setup of the Ugandan company and its operating structure (backlog 1, 16, 17).
|
||||||
|
|
||||||
|
Never runs on migrate. Run it by hand, dry-run first:
|
||||||
|
|
||||||
|
bench --site <site> execute makap_backend.makap_backend.setup_uganda.run
|
||||||
|
bench --site <site> execute makap_backend.makap_backend.setup_uganda.run --kwargs "{'confirm': 1}"
|
||||||
|
|
||||||
|
It creates and never deletes. The original Kenya/KES company is left alone:
|
||||||
|
once the new company is checked, remove the old one in the desk (it has no
|
||||||
|
transactions) so nobody posts to it by mistake.
|
||||||
|
|
||||||
|
Choices below are recommended defaults for Makap to confirm, not decisions
|
||||||
|
taken for them: company name, abbreviation, July-June fiscal year, VAT at 18%.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.utils import getdate
|
||||||
|
|
||||||
|
COUNTRY = "Uganda"
|
||||||
|
CURRENCY = "UGX"
|
||||||
|
TIME_ZONE = "Africa/Kampala"
|
||||||
|
VAT_RATE = 18
|
||||||
|
|
||||||
|
# Added beside the four warehouses ERPNext creates with every company
|
||||||
|
# (Stores, Work In Progress, Finished Goods, Goods In Transit).
|
||||||
|
WAREHOUSES = [
|
||||||
|
("Raw Materials", {}),
|
||||||
|
("Quarantine", {}),
|
||||||
|
("WIP - Printing", {}),
|
||||||
|
("WIP - Lamination and Curing", {}),
|
||||||
|
("WIP - Slitting", {}),
|
||||||
|
("WIP - Bag Making", {}),
|
||||||
|
("Returns Hold", {"makap_is_returns_hold_warehouse": 1}),
|
||||||
|
("Rejected", {}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Plan:
|
||||||
|
def __init__(self, confirm):
|
||||||
|
self.confirm = confirm
|
||||||
|
self.steps = []
|
||||||
|
|
||||||
|
def do(self, description, action):
|
||||||
|
self.steps.append(("DONE " if self.confirm else "WOULD ") + description)
|
||||||
|
if self.confirm:
|
||||||
|
action()
|
||||||
|
|
||||||
|
def skip(self, description):
|
||||||
|
self.steps.append("SKIP " + description)
|
||||||
|
|
||||||
|
|
||||||
|
def run(company_name="Makap Uganda Limited", abbr="MUL", fiscal_year_start="2026-07-01", confirm=0):
|
||||||
|
plan = Plan(bool(int(confirm)))
|
||||||
|
|
||||||
|
_system_settings(plan)
|
||||||
|
_company(plan, company_name, abbr)
|
||||||
|
_fiscal_year(plan, company_name, getdate(fiscal_year_start))
|
||||||
|
_vat(plan, company_name, abbr)
|
||||||
|
_warehouses(plan, company_name, abbr)
|
||||||
|
_manufacturing_and_stock_settings(plan, abbr)
|
||||||
|
|
||||||
|
if plan.confirm:
|
||||||
|
frappe.db.commit()
|
||||||
|
print("\n".join(plan.steps))
|
||||||
|
if not plan.confirm:
|
||||||
|
print("\nDry run only. Re-run with --kwargs \"{'confirm': 1}\" to apply.")
|
||||||
|
return plan.steps
|
||||||
|
|
||||||
|
|
||||||
|
def _system_settings(plan):
|
||||||
|
def apply():
|
||||||
|
if not frappe.db.get_value("Currency", CURRENCY, "enabled"):
|
||||||
|
frappe.db.set_value("Currency", CURRENCY, "enabled", 1)
|
||||||
|
settings = frappe.get_single("System Settings")
|
||||||
|
settings.country = COUNTRY
|
||||||
|
settings.time_zone = TIME_ZONE
|
||||||
|
settings.currency = CURRENCY
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
plan.do(f"set System Settings to {COUNTRY}, {TIME_ZONE}, {CURRENCY}", apply)
|
||||||
|
|
||||||
|
|
||||||
|
def _company(plan, company_name, abbr):
|
||||||
|
if frappe.db.exists("Company", company_name):
|
||||||
|
plan.skip(f"company {company_name} already exists")
|
||||||
|
return
|
||||||
|
|
||||||
|
def apply():
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Company",
|
||||||
|
"company_name": company_name,
|
||||||
|
"abbr": abbr,
|
||||||
|
"country": COUNTRY,
|
||||||
|
"default_currency": CURRENCY,
|
||||||
|
"create_chart_of_accounts_based_on": "Standard Template",
|
||||||
|
"chart_of_accounts": "Standard",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
defaults = frappe.get_single("Global Defaults")
|
||||||
|
defaults.default_company = company_name
|
||||||
|
defaults.default_currency = CURRENCY
|
||||||
|
defaults.country = COUNTRY
|
||||||
|
defaults.save()
|
||||||
|
|
||||||
|
plan.do(f"create company {company_name} ({abbr}) in {CURRENCY} with the Standard chart, and make it the default", apply)
|
||||||
|
|
||||||
|
|
||||||
|
def _fiscal_year(plan, company_name, start):
|
||||||
|
end = frappe.utils.add_days(frappe.utils.add_months(start, 12), -1)
|
||||||
|
overlapping = frappe.get_all(
|
||||||
|
"Fiscal Year",
|
||||||
|
filters={"year_start_date": ["<=", end], "year_end_date": [">=", start]},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
if overlapping:
|
||||||
|
plan.skip(
|
||||||
|
f"fiscal year {start}..{end}: overlaps {', '.join(overlapping)}. "
|
||||||
|
"Add the company to it, or adjust it, in the desk"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def apply():
|
||||||
|
doc = frappe.new_doc("Fiscal Year")
|
||||||
|
doc.year = f"{start.year}-{end.year}"
|
||||||
|
doc.year_start_date = start
|
||||||
|
doc.year_end_date = end
|
||||||
|
doc.append("companies", {"company": company_name})
|
||||||
|
doc.insert()
|
||||||
|
|
||||||
|
plan.do(f"create fiscal year {start}..{end} for {company_name}", apply)
|
||||||
|
|
||||||
|
|
||||||
|
def _tax_account(company_name, abbr, account_name):
|
||||||
|
parent = frappe.db.get_value(
|
||||||
|
"Account", {"company": company_name, "account_name": "Duties and Taxes", "is_group": 1}, "name"
|
||||||
|
)
|
||||||
|
if not parent:
|
||||||
|
frappe.throw(f"No 'Duties and Taxes' group in {company_name}'s chart of accounts.")
|
||||||
|
name = f"{account_name} - {abbr}"
|
||||||
|
if not frappe.db.exists("Account", name):
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Account",
|
||||||
|
"account_name": account_name,
|
||||||
|
"parent_account": parent,
|
||||||
|
"company": company_name,
|
||||||
|
"account_type": "Tax",
|
||||||
|
"tax_rate": VAT_RATE,
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _vat(plan, company_name, abbr):
|
||||||
|
def apply():
|
||||||
|
for doctype, account_name, title in (
|
||||||
|
("Sales Taxes and Charges Template", "VAT Output", "Uganda VAT 18%"),
|
||||||
|
("Purchase Taxes and Charges Template", "VAT Input", "Uganda VAT 18%"),
|
||||||
|
):
|
||||||
|
account = _tax_account(company_name, abbr, account_name)
|
||||||
|
if frappe.db.exists(doctype, {"title": title, "company": company_name}):
|
||||||
|
continue
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": doctype,
|
||||||
|
"title": title,
|
||||||
|
"company": company_name,
|
||||||
|
"is_default": 1,
|
||||||
|
"taxes": [
|
||||||
|
{
|
||||||
|
"charge_type": "On Net Total",
|
||||||
|
"account_head": account,
|
||||||
|
"description": f"VAT @ {VAT_RATE}%",
|
||||||
|
"rate": VAT_RATE,
|
||||||
|
**({"category": "Total", "add_deduct_tax": "Add"} if doctype.startswith("Purchase") else {}),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
plan.do(f"create VAT Output/Input accounts and default {VAT_RATE}% sales and purchase tax templates", apply)
|
||||||
|
|
||||||
|
|
||||||
|
def _warehouses(plan, company_name, abbr):
|
||||||
|
def apply():
|
||||||
|
parent = frappe.db.get_value("Warehouse", {"company": company_name, "is_group": 1, "parent_warehouse": ["is", "not set"]})
|
||||||
|
for name, extra in WAREHOUSES:
|
||||||
|
if frappe.db.exists("Warehouse", f"{name} - {abbr}"):
|
||||||
|
continue
|
||||||
|
frappe.get_doc(
|
||||||
|
{"doctype": "Warehouse", "warehouse_name": name, "company": company_name, "parent_warehouse": parent, **extra}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
plan.do("create warehouses: " + ", ".join(name for name, _ in WAREHOUSES), apply)
|
||||||
|
|
||||||
|
|
||||||
|
def _manufacturing_and_stock_settings(plan, abbr):
|
||||||
|
def apply():
|
||||||
|
manufacturing = frappe.get_single("Manufacturing Settings")
|
||||||
|
manufacturing.backflush_raw_materials_based_on = "Material Transferred for Manufacture"
|
||||||
|
manufacturing.enforce_time_logs = 1
|
||||||
|
manufacturing.default_wip_warehouse = f"Work In Progress - {abbr}"
|
||||||
|
manufacturing.default_fg_warehouse = f"Finished Goods - {abbr}"
|
||||||
|
manufacturing.save()
|
||||||
|
|
||||||
|
stock = frappe.get_single("Stock Settings")
|
||||||
|
stock.enable_stock_reservation = 1
|
||||||
|
stock.save()
|
||||||
|
|
||||||
|
plan.do(
|
||||||
|
"Manufacturing Settings: backflush on Material Transferred, enforce Job Card time logs, "
|
||||||
|
"default WIP/FG warehouses; Stock Settings: enable stock reservation",
|
||||||
|
apply,
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Gate logic that needs no fixtures: database reads are patched so each rule
|
||||||
|
is tested on its own. Run with `bench --site <test-site> run-tests --app makap_backend`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
from makap_backend.makap_backend import controls, work_order_readiness
|
||||||
|
|
||||||
|
MODULE = "makap_backend.makap_backend"
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(**values):
|
||||||
|
return frappe._dict(values)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatchGate(FrappeTestCase):
|
||||||
|
def _gate(self, line, reservation_enabled=0, disposition=None):
|
||||||
|
doc = _doc(
|
||||||
|
customer="C-1",
|
||||||
|
items=[_doc(item_code="ROLL", qty=10, makap_stock_reservation_entry=None, **line)],
|
||||||
|
makap_dispatch_material_accountability_status="Cleared",
|
||||||
|
makap_financial_release_status="Cleared",
|
||||||
|
makap_short_close=0,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.controls._is_controlled_item", return_value=True),
|
||||||
|
patch(f"{MODULE}.controls._latest_quality_disposition", return_value=disposition),
|
||||||
|
patch.object(frappe.db, "get_single_value", return_value=reservation_enabled),
|
||||||
|
):
|
||||||
|
return controls._compute_dispatch_gate(doc)
|
||||||
|
|
||||||
|
def test_controlled_line_without_batch_is_blocked(self):
|
||||||
|
status, blockers = self._gate({"makap_dispatch_allocation_mode": "Pooled"})
|
||||||
|
self.assertEqual(status, "Blocked")
|
||||||
|
self.assertTrue(any("no batch selected" in b for b in blockers))
|
||||||
|
|
||||||
|
def test_batch_needs_its_own_released_final_inspection(self):
|
||||||
|
status, blockers = self._gate({"makap_dispatch_allocation_mode": "Pooled", "batch_no": "R-1"})
|
||||||
|
self.assertEqual(status, "Blocked")
|
||||||
|
self.assertTrue(any("Batch R-1 has no final Quality Inspection" in b for b in blockers))
|
||||||
|
|
||||||
|
def test_released_batch_passes(self):
|
||||||
|
released = _doc(makap_quality_eligibility="Eligible", makap_quality_disposition="Release")
|
||||||
|
status, blockers = self._gate(
|
||||||
|
{"makap_dispatch_allocation_mode": "Pooled", "batch_no": "R-1"}, disposition=released
|
||||||
|
)
|
||||||
|
self.assertEqual((status, blockers), ("Ready", []))
|
||||||
|
|
||||||
|
def test_reserved_mode_needs_stock_reservation_enabled(self):
|
||||||
|
released = _doc(makap_quality_eligibility="Eligible", makap_quality_disposition="Release")
|
||||||
|
_, blockers = self._gate(
|
||||||
|
{"makap_dispatch_allocation_mode": "Reserved", "batch_no": "R-1"}, disposition=released
|
||||||
|
)
|
||||||
|
self.assertTrue(any("Stock Reservation enabled" in b for b in blockers))
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobCard(FrappeTestCase):
|
||||||
|
def test_waste_needs_a_reason(self):
|
||||||
|
card = _doc(makap_setup_waste_kg=5, makap_running_waste_kg=0, makap_output_weight_kg=95)
|
||||||
|
self.assertRaises(frappe.ValidationError, controls.validate_job_card, card)
|
||||||
|
|
||||||
|
def test_waste_pct_is_share_of_input(self):
|
||||||
|
card = _doc(
|
||||||
|
makap_setup_waste_kg=3, makap_running_waste_kg=2, makap_output_weight_kg=95,
|
||||||
|
makap_waste_reason="Registration",
|
||||||
|
)
|
||||||
|
controls.validate_job_card(card)
|
||||||
|
self.assertAlmostEqual(card.makap_waste_pct, 5.0)
|
||||||
|
|
||||||
|
def test_negative_weights_rejected(self):
|
||||||
|
card = _doc(makap_setup_waste_kg=-1, makap_waste_reason="Other")
|
||||||
|
self.assertRaises(frappe.ValidationError, controls.validate_job_card, card)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuotationEstimate(FrappeTestCase):
|
||||||
|
BOM = _doc(item="BAG", quantity=1000, raw_material_cost=500000, operating_cost=200000, docstatus=1)
|
||||||
|
|
||||||
|
def _line(self, **values):
|
||||||
|
line = _doc(item_code="BAG", stock_qty=10000, base_net_amount=9000000, makap_estimate_bom="BOM-BAG")
|
||||||
|
line.update(values)
|
||||||
|
line.set = lambda field, value: line.__setitem__(field, value)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def test_cost_build_up(self):
|
||||||
|
line = self._line(makap_waste_pct=10, makap_setup_cost=150000, makap_cylinder_charge=850000)
|
||||||
|
with patch.object(frappe.db, "get_value", return_value=self.BOM):
|
||||||
|
controls._estimate_line_cost(line)
|
||||||
|
# material 500 x 10,000 x 1.10 = 5,500,000; conversion 200 x 10,000 = 2,000,000
|
||||||
|
self.assertAlmostEqual(line.makap_material_cost, 5500000)
|
||||||
|
self.assertAlmostEqual(line.makap_conversion_cost, 2000000)
|
||||||
|
self.assertAlmostEqual(line.makap_estimated_cost, 8500000)
|
||||||
|
self.assertAlmostEqual(line.makap_estimated_margin_pct, 500000 / 9000000 * 100)
|
||||||
|
|
||||||
|
def test_bom_must_be_for_the_quoted_item(self):
|
||||||
|
line = self._line(item_code="OTHER")
|
||||||
|
with patch.object(frappe.db, "get_value", return_value=self.BOM):
|
||||||
|
self.assertRaises(frappe.ValidationError, controls._estimate_line_cost, line)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecificationReachesWorkOrder(FrappeTestCase):
|
||||||
|
def test_sub_assembly_order_finds_spec_through_production_plan(self):
|
||||||
|
values = {
|
||||||
|
("Production Plan Sub Assembly Item", "PPSA-1", "production_plan_item"): "PPI-1",
|
||||||
|
("Production Plan Item", "PPI-1", "sales_order_item"): "SOI-1",
|
||||||
|
("Sales Order Item", "SOI-1", "makap_packaging_specification"): "PS-1",
|
||||||
|
("Packaging Specification", "PS-1", "revision"): "B",
|
||||||
|
("Packaging Specification", "PS-1", "customer"): "C-1",
|
||||||
|
}
|
||||||
|
work_order = _doc(production_plan_sub_assembly_item="PPSA-1", makap_packaging_specification=None)
|
||||||
|
with patch.object(frappe.db, "get_value", side_effect=lambda dt, name, field: values.get((dt, name, field))):
|
||||||
|
work_order_readiness.apply_specification(work_order)
|
||||||
|
self.assertEqual(work_order.makap_packaging_specification, "PS-1")
|
||||||
|
self.assertEqual(work_order.makap_specification_revision, "B")
|
||||||
|
self.assertEqual(work_order.makap_sales_order_item_reference, "SOI-1")
|
||||||
|
|
||||||
|
def test_release_blocked_without_bom_or_confirmations(self):
|
||||||
|
work_order = _doc(production_item=None, bom_no=None)
|
||||||
|
blockers = work_order_readiness.release_blockers(work_order)
|
||||||
|
self.assertIn("No BOM is selected", blockers)
|
||||||
|
self.assertIn("Process compatibility is not confirmed", blockers)
|
||||||
|
self.assertEqual(work_order.makap_tooling_readiness_status, "Not Required")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Work Order release rules, shared by the readiness check and the submit gate.
|
||||||
|
|
||||||
|
Both callers run the same rules so a readiness result can never disagree with
|
||||||
|
what submission actually enforces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
RELEASABLE_SPEC_STATUS = "Approved for Manufacture"
|
||||||
|
READY_CYLINDER_STATUS = "Ready"
|
||||||
|
COMPATIBLE = {"Compatible", "Not Applicable"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sales_order_item_for(work_order):
|
||||||
|
"""Find the Sales Order Item a Work Order serves.
|
||||||
|
|
||||||
|
Sub-assembly Work Orders (printed roll, laminated roll) carry no
|
||||||
|
sales_order_item of their own, so walk back through the Production Plan
|
||||||
|
to the finished-goods row that does.
|
||||||
|
"""
|
||||||
|
if work_order.get("sales_order_item"):
|
||||||
|
return work_order.sales_order_item
|
||||||
|
|
||||||
|
plan_item = work_order.get("production_plan_item")
|
||||||
|
if not plan_item and work_order.get("production_plan_sub_assembly_item"):
|
||||||
|
plan_item = frappe.db.get_value(
|
||||||
|
"Production Plan Sub Assembly Item",
|
||||||
|
work_order.production_plan_sub_assembly_item,
|
||||||
|
"production_plan_item",
|
||||||
|
)
|
||||||
|
if plan_item:
|
||||||
|
so_item = frappe.db.get_value("Production Plan Item", plan_item, "sales_order_item")
|
||||||
|
if so_item:
|
||||||
|
return so_item
|
||||||
|
|
||||||
|
if work_order.get("sales_order") and work_order.get("production_item"):
|
||||||
|
matches = frappe.get_all(
|
||||||
|
"Sales Order Item",
|
||||||
|
filters={"parent": work_order.sales_order, "item_code": work_order.production_item},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_specification(work_order):
|
||||||
|
"""Copy the Packaging Specification from the Sales Order Item onto the Work Order."""
|
||||||
|
if work_order.get("makap_packaging_specification"):
|
||||||
|
return
|
||||||
|
so_item = _sales_order_item_for(work_order)
|
||||||
|
if not so_item:
|
||||||
|
return
|
||||||
|
spec = frappe.db.get_value("Sales Order Item", so_item, "makap_packaging_specification")
|
||||||
|
if not spec:
|
||||||
|
return
|
||||||
|
work_order.makap_packaging_specification = spec
|
||||||
|
work_order.makap_specification_revision = frappe.db.get_value("Packaging Specification", spec, "revision")
|
||||||
|
work_order.makap_sales_order_item_reference = so_item
|
||||||
|
work_order.makap_customer = frappe.db.get_value("Packaging Specification", spec, "customer")
|
||||||
|
|
||||||
|
|
||||||
|
def tooling_status(spec_name):
|
||||||
|
"""Return (status, blockers) for the print cylinders/plates a specification needs."""
|
||||||
|
if not spec_name:
|
||||||
|
return "Not Required", []
|
||||||
|
if not frappe.db.get_value("Packaging Specification", spec_name, "print_tooling_required"):
|
||||||
|
return "Not Required", []
|
||||||
|
|
||||||
|
cylinders = frappe.get_all(
|
||||||
|
"Print Cylinder",
|
||||||
|
filters={"packaging_specification": spec_name, "status": ["!=", "Retired"]},
|
||||||
|
fields=["name", "status"],
|
||||||
|
)
|
||||||
|
if not cylinders:
|
||||||
|
return "Blocked", ["Print tooling is required but no cylinders or plates are registered"]
|
||||||
|
not_ready = [c.name for c in cylinders if c.status != READY_CYLINDER_STATUS]
|
||||||
|
if not_ready:
|
||||||
|
return "Blocked", [f"Tooling not Ready: {', '.join(not_ready)}"]
|
||||||
|
return "Ready", []
|
||||||
|
|
||||||
|
|
||||||
|
def release_blockers(work_order):
|
||||||
|
"""Everything that stops this Work Order from being released to the floor."""
|
||||||
|
blockers = []
|
||||||
|
spec = work_order.get("makap_packaging_specification")
|
||||||
|
|
||||||
|
if spec:
|
||||||
|
if frappe.db.get_value("Packaging Specification", spec, "specification_status") != RELEASABLE_SPEC_STATUS:
|
||||||
|
blockers.append("Packaging Specification is not Approved for Manufacture")
|
||||||
|
elif work_order.get("production_item") and frappe.db.get_value(
|
||||||
|
"Item", work_order.production_item, "makap_requires_packaging_specification"
|
||||||
|
):
|
||||||
|
blockers.append("Production Item requires a Packaging Specification")
|
||||||
|
|
||||||
|
if not work_order.get("bom_no"):
|
||||||
|
blockers.append("No BOM is selected")
|
||||||
|
elif work_order.get("makap_bom_compatibility_status") not in COMPATIBLE:
|
||||||
|
blockers.append("BOM compatibility is not confirmed")
|
||||||
|
|
||||||
|
if work_order.get("makap_process_compatibility_status") not in COMPATIBLE:
|
||||||
|
blockers.append("Process compatibility is not confirmed")
|
||||||
|
|
||||||
|
tooling, tooling_blockers = tooling_status(spec)
|
||||||
|
work_order.makap_tooling_readiness_status = tooling
|
||||||
|
blockers.extend(tooling_blockers)
|
||||||
|
return blockers
|
||||||
Reference in New Issue
Block a user