From eafa1242db798aacd9bf797888aa01e066642f53 Mon Sep 17 00:00:00 2001 From: KabundiNjuki Date: Tue, 1 Sep 2026 22:29:31 +0300 Subject: [PATCH] Implement MAKAP Modules 1-7 backend foundations --- .gitignore | 25 +- makap_backend/hooks.py | 25 + makap_backend/makap_backend/api.py | 232 +++++++++ makap_backend/makap_backend/controls.py | 135 +++++ .../makap_backend/doctype/__init__.py | 0 .../__init__.py | 0 .../critical_acceptance_requirement.json | 67 +++ .../critical_acceptance_requirement.py | 0 .../customer_onboarding_request/__init__.py | 0 .../customer_onboarding_request.json | 261 ++++++++++ .../customer_onboarding_request.py | 25 + .../doctype/material_structure/__init__.py | 0 .../material_structure.json | 69 +++ .../material_structure/material_structure.py | 0 .../packaging_specification/__init__.py | 0 .../packaging_specification.js | 8 + .../packaging_specification.json | 479 ++++++++++++++++++ .../packaging_specification.py | 6 + .../test_packaging_specification.py | 9 + makap_backend/makap_backend/operations.py | 142 ++++++ .../critical_acceptance_requirement.json | 62 +++ .../schema_sources/material_structure.json | 64 +++ makap_backend/makap_backend/setup.py | 178 +++++++ .../makap_backend/setup_extensions.py | 103 ++++ makap_backend/makap_backend/setup_module3.py | 56 ++ .../makap_backend/workspace_setup.py | 349 +++++++++++++ makap_backend/modules.txt | 2 +- 27 files changed, 2295 insertions(+), 2 deletions(-) create mode 100644 makap_backend/makap_backend/api.py create mode 100644 makap_backend/makap_backend/controls.py create mode 100644 makap_backend/makap_backend/doctype/__init__.py create mode 100644 makap_backend/makap_backend/doctype/critical_acceptance_requirement/__init__.py create mode 100644 makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json create mode 100644 makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.py create mode 100644 makap_backend/makap_backend/doctype/customer_onboarding_request/__init__.py create mode 100644 makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.json create mode 100644 makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.py create mode 100644 makap_backend/makap_backend/doctype/material_structure/__init__.py create mode 100644 makap_backend/makap_backend/doctype/material_structure/material_structure.json create mode 100644 makap_backend/makap_backend/doctype/material_structure/material_structure.py create mode 100644 makap_backend/makap_backend/doctype/packaging_specification/__init__.py create mode 100644 makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.js create mode 100644 makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.json create mode 100644 makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.py create mode 100644 makap_backend/makap_backend/doctype/packaging_specification/test_packaging_specification.py create mode 100644 makap_backend/makap_backend/operations.py create mode 100644 makap_backend/makap_backend/schema_sources/critical_acceptance_requirement.json create mode 100644 makap_backend/makap_backend/schema_sources/material_structure.json create mode 100644 makap_backend/makap_backend/setup.py create mode 100644 makap_backend/makap_backend/setup_extensions.py create mode 100644 makap_backend/makap_backend/setup_module3.py create mode 100644 makap_backend/makap_backend/workspace_setup.py diff --git a/.gitignore b/.gitignore index ba04025..3ff37d8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,27 @@ *.swp tags node_modules -__pycache__ \ No newline at end of file +__pycache__ +# Python and local development artifacts +__pycache__/ +*.py[cod] +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Local environment and credentials +.env +.env.* +*.pem +*.key +*.secret + +# Generated frontend/build artifacts +node_modules/ +dist/ + +# OS/editor files +.DS_Store +.vscode/ +.idea/ diff --git a/makap_backend/hooks.py b/makap_backend/hooks.py index 42e4c03..207126e 100644 --- a/makap_backend/hooks.py +++ b/makap_backend/hooks.py @@ -247,3 +247,28 @@ app_license = "mit" # List of apps whose translatable strings should be excluded from this app's translations. # ignore_translatable_strings_from = [] + +# 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 cross-module server-side controls +doc_events = { + "Packaging Specification": { + "validate": "makap_backend.makap_backend.controls.validate_packaging_specification", + }, + "Quotation": { + "validate": "makap_backend.makap_backend.controls.validate_quotation", + }, + "Sales Order": { + "validate": "makap_backend.makap_backend.controls.validate_sales_order", + }, + "Work Order": { + "validate": "makap_backend.makap_backend.controls.validate_work_order", + }, + "Quality Inspection": { + "validate": "makap_backend.makap_backend.controls.validate_quality_inspection", + }, + "Purchase Order": { + "validate": "makap_backend.makap_backend.controls.validate_purchase_order", + }, +} diff --git a/makap_backend/makap_backend/api.py b/makap_backend/makap_backend/api.py new file mode 100644 index 0000000..5fecfb2 --- /dev/null +++ b/makap_backend/makap_backend/api.py @@ -0,0 +1,232 @@ +import frappe +from frappe import _ + + +PUBLIC_FIELDS = { + "organisation_name", + "trading_name", + "customer_type", + "business_tax_identifier", + "contact_name", + "email_id", + "telephone", + "submitted_address", + "location", + "product_interest", + "initial_enquiry", + "privacy_notice_acknowledged", + "privacy_notice_reference_version", +} + + +def _clean_public_payload(payload): + if not isinstance(payload, dict): + frappe.throw(_("Registration payload must be an object.")) + return {key: payload.get(key) for key in PUBLIC_FIELDS if payload.get(key) not in (None, "")} + + +@frappe.whitelist(allow_guest=True) +def submit_customer_onboarding(payload=None): + """Create intake evidence only; never create Customer, Contact, or User.""" + if isinstance(payload, str): + payload = frappe.parse_json(payload) + + values = _clean_public_payload(payload or {}) + values.update( + { + "doctype": "Customer Onboarding Request", + "registration_source": "Website", + "onboarding_status": "Submitted", + "customer_match_result": "Not Checked", + "portal_access_status": "Not Provisioned", + } + ) + + request = frappe.get_doc(values) + request.insert(ignore_permissions=True) + frappe.db.commit() + + return { + "success": True, + "reference": request.name, + "message": "Registration received for review. This is not Customer approval.", + } + + +def _find_existing_contact(email_id, phone=None): + filters = {"email_id": email_id} + contact_name = frappe.db.get_value("Contact", filters, "name") + if contact_name: + return contact_name + if phone: + return frappe.db.get_value("Contact Phone", {"phone": phone}, "parent") + return None + + +def approve_customer_onboarding(request_name, create_new_customer=False, customer=None): + """Reviewer action: resolve identity, approve, and idempotently create/link Customer + Contact.""" + request = frappe.get_doc("Customer Onboarding Request", request_name) + if request.onboarding_status not in {"Under Review", "Approved"}: + frappe.throw(_("Only requests Under Review or already Approved can be completed.")) + + if create_new_customer: + request.customer_match_result = "New Customer Confirmed" + elif customer: + request.customer_match_result = "Existing Customer Confirmed" + request.possible_existing_customer = customer + else: + frappe.throw(_("Provide an existing Customer or explicitly confirm a new Customer.")) + + if customer: + if not frappe.db.exists("Customer", customer): + frappe.throw(_("The selected Customer does not exist.")) + customer_name = customer + else: + customer_name = request.created_linked_customer + if not customer_name: + customer_name = frappe.db.get_value( + "Customer", {"customer_name": request.organisation_name}, "name" + ) + if not customer_name: + customer_doc = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": request.organisation_name, + "customer_type": request.customer_type or "Company", + "customer_group": frappe.defaults.get_global_default("customer_group") or "Commercial", + "territory": frappe.defaults.get_global_default("territory") or "All Territories", + } + ) + customer_doc.insert(ignore_permissions=True) + customer_name = customer_doc.name + + contact_name = request.created_linked_contact or _find_existing_contact( + request.email_id, request.telephone + ) + if not contact_name: + contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": request.contact_name, + "email_ids": [{"email_id": request.email_id, "is_primary": 1}], + "phone_nos": [{"phone": request.telephone, "is_primary_phone": 1}], + "links": [{"link_doctype": "Customer", "link_name": customer_name}], + } + ) + contact.insert(ignore_permissions=True) + contact_name = contact.name + else: + contact = frappe.get_doc("Contact", contact_name) + linked = {(row.link_doctype, row.link_name) for row in contact.links} + if ("Customer", customer_name) not in linked: + contact.append("links", {"link_doctype": "Customer", "link_name": customer_name}) + contact.save(ignore_permissions=True) + + request.created_linked_customer = customer_name + request.created_linked_contact = contact_name + request.onboarding_status = "Completed" + request.save(ignore_permissions=True) + frappe.db.commit() + + return {"request": request.name, "customer": customer_name, "contact": contact_name} + + +def _customer_names_for_logged_in_user(): + if frappe.session.user in {"Guest", "Administrator"}: + return [] + + user_email = frappe.db.get_value("User", frappe.session.user, "email") or frappe.session.user + contact_names = set( + frappe.get_all( + "Contact", + filters={"email_id": user_email}, + pluck="name", + ) + ) + contact_names.update( + frappe.get_all( + "Contact Email", + filters={"email_id": user_email}, + pluck="parent", + ) + ) + + if not contact_names: + return [] + + return frappe.get_all( + "Dynamic Link", + filters={ + "parenttype": "Contact", + "parent": ["in", list(contact_names)], + "link_doctype": "Customer", + }, + pluck="link_name", + ) + + +@frappe.whitelist() +def get_my_customer_context(): + """Return only Customers linked to the authenticated portal user.""" + customer_names = _customer_names_for_logged_in_user() + if not customer_names: + return [] + return frappe.get_all( + "Customer", + filters={"name": ["in", customer_names]}, + fields=["name", "customer_name", "customer_group", "territory"], + order_by="customer_name asc", + ) + + +@frappe.whitelist() +def get_my_sales_orders(limit=50): + customer_names = _customer_names_for_logged_in_user() + if not customer_names: + return [] + return frappe.get_all( + "Sales Order", + filters={"customer": ["in", customer_names], "docstatus": ["<", 2]}, + fields=["name", "transaction_date", "delivery_date", "status", "grand_total", "currency"], + order_by="transaction_date desc", + limit_page_length=min(int(limit), 100), + ) + + +@frappe.whitelist() +def get_my_quotations(limit=50): + customer_names = _customer_names_for_logged_in_user() + if not customer_names: + return [] + return frappe.get_all( + "Quotation", + filters={"party_name": ["in", customer_names], "docstatus": ["<", 2]}, + fields=["name", "transaction_date", "valid_till", "status", "grand_total", "currency"], + order_by="transaction_date desc", + limit_page_length=min(int(limit), 100), + ) + + +@frappe.whitelist() +def create_my_opportunity(subject, opportunity_type="Sales", opportunity_from="Customer", customer=None, message=None): + """Create a standard Opportunity only for a Customer authorised to the logged-in user.""" + customer_names = _customer_names_for_logged_in_user() + if not customer or customer not in customer_names: + frappe.throw("You are not authorised to create an enquiry for this Customer.") + if not subject or not subject.strip(): + frappe.throw("Enquiry subject is required.") + + opportunity = frappe.get_doc( + { + "doctype": "Opportunity", + "opportunity_from": opportunity_from, + "party_name": customer, + "opportunity_type": opportunity_type, + "title": subject.strip(), + "enquiry_from": "Customer", + "notes": message or "", + } + ) + opportunity.insert() + frappe.db.commit() + return {"name": opportunity.name, "customer": customer, "status": opportunity.status} diff --git a/makap_backend/makap_backend/controls.py b/makap_backend/makap_backend/controls.py new file mode 100644 index 0000000..87cf1d1 --- /dev/null +++ b/makap_backend/makap_backend/controls.py @@ -0,0 +1,135 @@ +import frappe + + +def _is_manufactured_item(item_code): + if not item_code or not frappe.db.exists("Item", item_code): + return False + item = frappe.db.get_value( + "Item", item_code, ["is_stock_item", "is_sales_item", "item_group"], as_dict=True + ) + return bool(item and item.is_stock_item and item.is_sales_item) + + +def _spec_for_line(line): + return getattr(line, "makap_packaging_specification", None) + + +def validate_packaging_specification(doc, method=None): + status = doc.specification_status + + if status == "Quote Eligible": + if not doc.quote_eligible: + frappe.throw("Quote Eligible status requires Quote Eligible to be checked.") + if not doc.quote_eligible_by or not doc.quote_eligible_on: + frappe.throw("Quote Eligible status requires user and timestamp evidence.") + + if status == "Approved for Manufacture": + required = [ + (doc.quote_eligible, "Quote Eligible"), + (doc.approved_by, "Approved By"), + (doc.approved_on, "Approved On"), + ] + for value, label in required: + if not value: + frappe.throw(f"Approved for Manufacture requires {label}.") + + if status == "Withdrawn" and not doc.withdrawal_reason: + frappe.throw("A withdrawn specification must include a Withdrawal Reason.") + + if doc.commercially_referenced and doc.is_new() and status != "Draft": + frappe.throw("A new specification cannot be marked as commercially referenced.") + + +def validate_quotation(doc, method=None): + for line in doc.get("items") or []: + if not _is_manufactured_item(line.item_code): + continue + + spec_name = _spec_for_line(line) + requires_spec = frappe.db.get_value( + "Item", line.item_code, "makap_requires_packaging_specification" + ) + if requires_spec and not spec_name: + frappe.throw( + f"Quotation item {line.item_code} requires a Packaging Specification." + ) + + if spec_name: + spec = frappe.get_doc("Packaging Specification", spec_name) + if spec.item != line.item_code: + frappe.throw( + f"Packaging Specification {spec.name} does not belong to Item {line.item_code}." + ) + if doc.party_name and spec.customer and spec.customer != doc.party_name: + frappe.throw( + f"Packaging Specification {spec.name} does not belong to Customer {doc.party_name}." + ) + if spec.specification_status not in {"Quote Eligible", "Approved for Manufacture"}: + frappe.throw( + f"Packaging Specification {spec.name} is not Quote Eligible." + ) + line.makap_specification_revision = spec.revision + line.makap_technical_summary = spec.customer_facing_technical_summary + + +def validate_sales_order(doc, method=None): + if doc.docstatus == 1 and not doc.makap_customer_acceptance_basis: + frappe.throw("Submitted Sales Order requires Customer Acceptance Basis.") + + for line in doc.get("items") or []: + spec_name = _spec_for_line(line) + if not spec_name: + continue + spec = frappe.get_doc("Packaging Specification", spec_name) + if doc.customer and spec.customer and spec.customer != doc.customer: + frappe.throw( + f"Packaging Specification {spec.name} does not belong to Customer {doc.customer}." + ) + if spec.item != line.item_code: + frappe.throw( + f"Packaging Specification {spec.name} does not belong to Item {line.item_code}." + ) + line.makap_specification_revision = spec.revision + + +def validate_work_order(doc, method=None): + if doc.docstatus != 1: + return + + if doc.makap_manufacturing_readiness_status not in {"Ready", "Released"}: + frappe.throw("Submitted Work Order requires Manufacturing Readiness Status Ready or Released.") + + if doc.makap_packaging_specification: + spec_status = frappe.db.get_value( + "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): + if doc.makap_quality_disposition == "Release": + if doc.makap_quality_eligibility != "Eligible": + frappe.throw("A Release disposition requires Quality Eligibility Eligible.") + + if doc.makap_quality_disposition == "Concession": + if not doc.makap_concession_reference or not doc.makap_concession_scope: + frappe.throw("A Concession requires scope and reference evidence.") + + if doc.makap_quality_disposition in {"Hold", "Reject", "Rework", "Scrap"}: + if doc.makap_quality_eligibility == "Eligible": + frappe.throw("Held, rejected, rework, or scrap stock cannot be marked Eligible.") + + +def validate_purchase_order(doc, method=None): + for line in doc.get("items") or []: + if line.makap_supplier_confirmation_status == "Confirmed" and not line.makap_supplier_confirmation_reference: + frappe.throw( + f"Purchase Order item {line.item_code} requires Supplier Confirmation Reference." + ) diff --git a/makap_backend/makap_backend/doctype/__init__.py b/makap_backend/makap_backend/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/critical_acceptance_requirement/__init__.py b/makap_backend/makap_backend/doctype/critical_acceptance_requirement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json b/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json new file mode 100644 index 0000000..7bc257f --- /dev/null +++ b/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.json @@ -0,0 +1,67 @@ +{ + "actions": [], + "creation": "2026-09-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "characteristic", + "requirement_nominal", + "unit", + "minimum_lower_tolerance", + "maximum_upper_tolerance", + "notes" + ], + "fields": [ + { + "fieldname": "characteristic", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Characteristic", + "reqd": 1 + }, + { + "fieldname": "requirement_nominal", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Requirement / Nominal", + "reqd": 1 + }, + { + "fieldname": "unit", + "fieldtype": "Data", + "label": "Unit" + }, + { + "fieldname": "minimum_lower_tolerance", + "fieldtype": "Float", + "label": "Minimum / Lower Tolerance" + }, + { + "fieldname": "maximum_upper_tolerance", + "fieldtype": "Float", + "label": "Maximum / Upper Tolerance" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-09-01 21:47:35.807459", + "modified_by": "Administrator", + "module": "makap_backend", + "name": "Critical Acceptance Requirement", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.py b/makap_backend/makap_backend/doctype/critical_acceptance_requirement/critical_acceptance_requirement.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/customer_onboarding_request/__init__.py b/makap_backend/makap_backend/doctype/customer_onboarding_request/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.json b/makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.json new file mode 100644 index 0000000..f2333f5 --- /dev/null +++ b/makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.json @@ -0,0 +1,261 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2026-09-01 00:00:00", + "doctype": "DocType", + "autoname": "naming_series:", + "engine": "InnoDB", + "field_order": [ + "organisation_section", + "naming_series", + "organisation_name", + "trading_name", + "customer_type", + "business_tax_identifier", + "primary_contact_section", + "contact_name", + "email_id", + "telephone", + "submitted_address", + "location", + "requirement_section", + "product_interest", + "initial_enquiry", + "intake_section", + "registration_source", + "assisted_channel", + "review_section", + "onboarding_status", + "customer_match_result", + "possible_existing_customer", + "created_linked_customer", + "created_linked_contact", + "portal_access_status", + "review_notes", + "privacy_section", + "privacy_notice_acknowledged", + "acknowledgement_timestamp", + "privacy_notice_reference_version" + ], + "fields": [ + { + "fieldname": "organisation_section", + "fieldtype": "Section Break", + "label": "Organisation" + }, + { + "default": "COR-.YYYY.-.#####", + "fieldname": "naming_series", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Reference Series", + "options": "COR-.YYYY.-.#####", + "reqd": 1 + }, + { + "fieldname": "organisation_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Organisation Name", + "reqd": 1 + }, + { + "fieldname": "trading_name", + "fieldtype": "Data", + "label": "Trading Name" + }, + { + "fieldname": "customer_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Customer Type", + "options": "Company\nIndividual", + "reqd": 1 + }, + { + "fieldname": "business_tax_identifier", + "fieldtype": "Data", + "label": "Business / Tax Identifier" + }, + { + "fieldname": "primary_contact_section", + "fieldtype": "Section Break", + "label": "Primary Contact" + }, + { + "fieldname": "contact_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Contact Name", + "reqd": 1 + }, + { + "fieldname": "email_id", + "fieldtype": "Data", + "label": "Email", + "options": "Email", + "reqd": 1 + }, + { + "fieldname": "telephone", + "fieldtype": "Data", + "label": "Telephone", + "reqd": 1 + }, + { + "fieldname": "submitted_address", + "fieldtype": "Small Text", + "label": "Submitted Address" + }, + { + "fieldname": "location", + "fieldtype": "Data", + "label": "Location" + }, + { + "fieldname": "requirement_section", + "fieldtype": "Section Break", + "label": "Requirement" + }, + { + "fieldname": "product_interest", + "fieldtype": "Small Text", + "label": "Product Interest" + }, + { + "fieldname": "initial_enquiry", + "fieldtype": "Small Text", + "label": "Initial Enquiry" + }, + { + "fieldname": "intake_section", + "fieldtype": "Section Break", + "label": "Intake" + }, + { + "fieldname": "registration_source", + "fieldtype": "Select", + "label": "Registration Source", + "options": "Website\nAssisted", + "reqd": 1 + }, + { + "depends_on": "eval:doc.registration_source == 'Assisted'", + "fieldname": "assisted_channel", + "fieldtype": "Data", + "label": "Assisted Channel" + }, + { + "fieldname": "review_section", + "fieldtype": "Section Break", + "label": "Review and Resolution" + }, + { + "default": "Submitted", + "fieldname": "onboarding_status", + "fieldtype": "Select", + "label": "Onboarding Status", + "options": "Submitted\nUnder Review\nApproved\nRejected\nCompleted", + "reqd": 1 + }, + { + "default": "Not Checked", + "fieldname": "customer_match_result", + "fieldtype": "Select", + "label": "Customer Match Result", + "options": "Not Checked\nPossible Match\nExisting Customer Confirmed\nNew Customer Confirmed", + "reqd": 1 + }, + { + "fieldname": "possible_existing_customer", + "fieldtype": "Link", + "label": "Possible Existing Customer", + "options": "Customer" + }, + { + "fieldname": "created_linked_customer", + "fieldtype": "Link", + "label": "Created / Linked Customer", + "options": "Customer" + }, + { + "fieldname": "created_linked_contact", + "fieldtype": "Link", + "label": "Created / Linked Contact", + "options": "Contact" + }, + { + "default": "Not Provisioned", + "fieldname": "portal_access_status", + "fieldtype": "Select", + "label": "Portal Access Status", + "options": "Not Provisioned\nPending\nActive\nRevoked" + }, + { + "fieldname": "review_notes", + "fieldtype": "Small Text", + "label": "Review Notes" + }, + { + "fieldname": "privacy_section", + "fieldtype": "Section Break", + "label": "Privacy Evidence" + }, + { + "default": "0", + "fieldname": "privacy_notice_acknowledged", + "fieldtype": "Check", + "label": "Privacy Notice Acknowledged" + }, + { + "depends_on": "eval:doc.privacy_notice_acknowledged == 1", + "fieldname": "acknowledgement_timestamp", + "fieldtype": "Datetime", + "label": "Acknowledgement Timestamp" + }, + { + "fieldname": "privacy_notice_reference_version", + "fieldtype": "Data", + "label": "Privacy Notice Reference / Version" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-09-01 23:04:15.841393", + "modified_by": "Administrator", + "module": "makap_backend", + "name": "Customer Onboarding Request", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Commercial Reviewer", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "search_fields": "organisation_name,email_id,telephone,business_tax_identifier", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.py b/makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.py new file mode 100644 index 0000000..40d1a1d --- /dev/null +++ b/makap_backend/makap_backend/doctype/customer_onboarding_request/customer_onboarding_request.py @@ -0,0 +1,25 @@ +import frappe +from frappe.model.document import Document + + +class CustomerOnboardingRequest(Document): + def before_insert(self): + self.onboarding_status = self.onboarding_status or "Submitted" + self.customer_match_result = self.customer_match_result or "Not Checked" + self.portal_access_status = self.portal_access_status or "Not Provisioned" + if self.privacy_notice_acknowledged and not self.acknowledgement_timestamp: + self.acknowledgement_timestamp = frappe.utils.now_datetime() + + def validate(self): + if self.registration_source not in {"Website", "Assisted"}: + frappe.throw("Registration Source must be Website or Assisted.") + if self.registration_source == "Assisted" and not self.assisted_channel: + frappe.throw("Assisted Channel is required for an Assisted registration.") + if self.registration_source == "Website" and self.assisted_channel: + frappe.throw("Assisted Channel must be empty for a Website registration.") + if self.onboarding_status in {"Approved", "Completed"} and self.customer_match_result not in {"Existing Customer Confirmed", "New Customer Confirmed"}: + frappe.throw("Approval requires confirmed Customer identity resolution.") + if self.onboarding_status == "Completed" and (not self.created_linked_customer or not self.created_linked_contact): + frappe.throw("A Completed request requires linked Customer and Contact.") + if self.onboarding_status == "Rejected" and (self.created_linked_customer or self.created_linked_contact): + frappe.throw("A Rejected request cannot contain Customer or Contact links.") diff --git a/makap_backend/makap_backend/doctype/material_structure/__init__.py b/makap_backend/makap_backend/doctype/material_structure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/material_structure/material_structure.json b/makap_backend/makap_backend/doctype/material_structure/material_structure.json new file mode 100644 index 0000000..c153570 --- /dev/null +++ b/makap_backend/makap_backend/doctype/material_structure/material_structure.json @@ -0,0 +1,69 @@ +{ + "actions": [], + "creation": "2026-09-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "sequence", + "material_film_description", + "raw_material_item", + "nominal_thickness_requirement", + "uom", + "layer_role_notes" + ], + "fields": [ + { + "fieldname": "sequence", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Sequence", + "reqd": 1 + }, + { + "fieldname": "material_film_description", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Material / Film Description", + "reqd": 1 + }, + { + "fieldname": "raw_material_item", + "fieldtype": "Link", + "label": "Raw Material Item", + "options": "Item" + }, + { + "fieldname": "nominal_thickness_requirement", + "fieldtype": "Float", + "label": "Nominal Thickness / Requirement" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM" + }, + { + "fieldname": "layer_role_notes", + "fieldtype": "Small Text", + "label": "Layer Role / Notes" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-09-01 21:47:35.458192", + "modified_by": "Administrator", + "module": "makap_backend", + "name": "Material Structure", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/makap_backend/makap_backend/doctype/material_structure/material_structure.py b/makap_backend/makap_backend/doctype/material_structure/material_structure.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/packaging_specification/__init__.py b/makap_backend/makap_backend/doctype/packaging_specification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.js b/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.js new file mode 100644 index 0000000..5b4b64c --- /dev/null +++ b/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, Joseph njuki and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Packaging Specification", { +// refresh(frm) { + +// }, +// }); diff --git a/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.json b/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.json new file mode 100644 index 0000000..ba5f030 --- /dev/null +++ b/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.json @@ -0,0 +1,479 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2026-09-01 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "identity_section", + "naming_series", + "specification_series", + "revision", + "customer", + "item", + "product_family", + "specification_status", + "supersedes", + "superseded_by", + "current_approved_revision", + "control_section", + "quote_eligible", + "quote_eligible_by", + "quote_eligible_on", + "commercially_referenced", + "approved_by", + "approved_on", + "change_reason", + "commercial_review_required", + "withdrawal_reason", + "definition_section", + "customer_facing_technical_summary", + "internal_technical_description", + "material_substrate_summary", + "width", + "width_unit", + "length", + "length_unit", + "thickness_gauge", + "thickness_gauge_unit", + "conversion_finishing_notes", + "packing_requirements", + "technical_notes", + "material_structure", + "process_section", + "printing_required", + "print_process", + "lamination_required", + "slitting_required", + "rewinding_required", + "conversion_bag_making_requirement", + "finishing_requirement", + "process_requirement_notes", + "artwork_section", + "artwork_required", + "artwork_status", + "approved_artwork_file_reference", + "artwork_revision", + "colour_count", + "print_colour_notes", + "customer_approval_basis", + "customer_approval_reference_attachment", + "customer_approval_date", + "tooling_section", + "print_tooling_required", + "tooling_type", + "tooling_set_reference", + "tooling_requirement_notes", + "critical_acceptance_requirements" + ], + "fields": [ + { + "fieldname": "identity_section", + "fieldtype": "Section Break", + "label": "Identity" + }, + { + "default": "PS-.YYYY.-.#####", + "fieldname": "naming_series", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Specification Series", + "options": "PS-.YYYY.-.#####", + "reqd": 1 + }, + { + "fieldname": "specification_series", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Specification Series", + "reqd": 1 + }, + { + "default": "R01", + "fieldname": "revision", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Revision", + "reqd": 1 + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Customer", + "options": "Customer", + "reqd": 1 + }, + { + "fieldname": "item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 1 + }, + { + "fieldname": "product_family", + "fieldtype": "Data", + "label": "Product Family", + "reqd": 1 + }, + { + "default": "Draft", + "fieldname": "specification_status", + "fieldtype": "Select", + "label": "Specification Status", + "options": "Draft\nQuote Eligible\nApproved for Manufacture\nSuperseded\nWithdrawn", + "reqd": 1 + }, + { + "fieldname": "supersedes", + "fieldtype": "Link", + "label": "Supersedes", + "options": "Packaging Specification" + }, + { + "fieldname": "superseded_by", + "fieldtype": "Link", + "label": "Superseded By", + "options": "Packaging Specification" + }, + { + "default": "0", + "fieldname": "current_approved_revision", + "fieldtype": "Check", + "label": "Current Approved Revision" + }, + { + "fieldname": "control_section", + "fieldtype": "Section Break", + "label": "Control" + }, + { + "default": "0", + "fieldname": "quote_eligible", + "fieldtype": "Check", + "label": "Quote Eligible" + }, + { + "fieldname": "quote_eligible_by", + "fieldtype": "Link", + "label": "Quote Eligible By", + "options": "User" + }, + { + "fieldname": "quote_eligible_on", + "fieldtype": "Datetime", + "label": "Quote Eligible On" + }, + { + "default": "0", + "fieldname": "commercially_referenced", + "fieldtype": "Check", + "label": "Commercially Referenced" + }, + { + "fieldname": "approved_by", + "fieldtype": "Link", + "label": "Approved By", + "options": "User" + }, + { + "fieldname": "approved_on", + "fieldtype": "Datetime", + "label": "Approved On" + }, + { + "fieldname": "change_reason", + "fieldtype": "Small Text", + "label": "Change Reason" + }, + { + "default": "0", + "fieldname": "commercial_review_required", + "fieldtype": "Check", + "label": "Commercial Review Required" + }, + { + "fieldname": "withdrawal_reason", + "fieldtype": "Small Text", + "label": "Withdrawal Reason" + }, + { + "fieldname": "definition_section", + "fieldtype": "Section Break", + "label": "Product Definition" + }, + { + "fieldname": "customer_facing_technical_summary", + "fieldtype": "Small Text", + "label": "Customer-facing Technical Summary" + }, + { + "fieldname": "internal_technical_description", + "fieldtype": "Text Editor", + "label": "Internal Technical Description" + }, + { + "fieldname": "material_substrate_summary", + "fieldtype": "Small Text", + "label": "Material / Substrate Summary" + }, + { + "fieldname": "width", + "fieldtype": "Float", + "label": "Width" + }, + { + "fieldname": "width_unit", + "fieldtype": "Data", + "label": "Width Unit" + }, + { + "fieldname": "length", + "fieldtype": "Float", + "label": "Length" + }, + { + "fieldname": "length_unit", + "fieldtype": "Data", + "label": "Length Unit" + }, + { + "fieldname": "thickness_gauge", + "fieldtype": "Float", + "label": "Thickness / Gauge" + }, + { + "fieldname": "thickness_gauge_unit", + "fieldtype": "Data", + "label": "Thickness / Gauge Unit" + }, + { + "fieldname": "conversion_finishing_notes", + "fieldtype": "Small Text", + "label": "Conversion / Finishing Notes" + }, + { + "fieldname": "packing_requirements", + "fieldtype": "Small Text", + "label": "Packing Requirements" + }, + { + "fieldname": "technical_notes", + "fieldtype": "Text Editor", + "label": "Technical Notes" + }, + { + "fieldname": "material_structure", + "fieldtype": "Table", + "label": "Material Structure", + "options": "Material Structure" + }, + { + "fieldname": "process_section", + "fieldtype": "Section Break", + "label": "Process Requirements" + }, + { + "default": "0", + "fieldname": "printing_required", + "fieldtype": "Check", + "label": "Printing Required" + }, + { + "fieldname": "print_process", + "fieldtype": "Data", + "label": "Print Process" + }, + { + "default": "0", + "fieldname": "lamination_required", + "fieldtype": "Check", + "label": "Lamination Required" + }, + { + "default": "0", + "fieldname": "slitting_required", + "fieldtype": "Check", + "label": "Slitting Required" + }, + { + "default": "0", + "fieldname": "rewinding_required", + "fieldtype": "Check", + "label": "Rewinding Required" + }, + { + "fieldname": "conversion_bag_making_requirement", + "fieldtype": "Data", + "label": "Conversion / Bag-Making Requirement" + }, + { + "fieldname": "finishing_requirement", + "fieldtype": "Data", + "label": "Finishing Requirement" + }, + { + "fieldname": "process_requirement_notes", + "fieldtype": "Small Text", + "label": "Process Requirement Notes" + }, + { + "fieldname": "artwork_section", + "fieldtype": "Section Break", + "label": "Artwork / Prepress" + }, + { + "default": "0", + "fieldname": "artwork_required", + "fieldtype": "Check", + "label": "Artwork Required" + }, + { + "fieldname": "artwork_status", + "fieldtype": "Select", + "label": "Artwork Status", + "options": "Not Required\nPending\nReceived\nApproved" + }, + { + "fieldname": "approved_artwork_file_reference", + "fieldtype": "Data", + "label": "Approved Artwork File / Reference" + }, + { + "fieldname": "artwork_revision", + "fieldtype": "Data", + "label": "Artwork Revision" + }, + { + "fieldname": "colour_count", + "fieldtype": "Int", + "label": "Colour Count" + }, + { + "fieldname": "print_colour_notes", + "fieldtype": "Small Text", + "label": "Print / Colour Notes" + }, + { + "fieldname": "customer_approval_basis", + "fieldtype": "Data", + "label": "Customer Approval Basis" + }, + { + "fieldname": "customer_approval_reference_attachment", + "fieldtype": "Data", + "label": "Customer Approval Reference / Attachment" + }, + { + "fieldname": "customer_approval_date", + "fieldtype": "Date", + "label": "Customer Approval Date" + }, + { + "fieldname": "tooling_section", + "fieldtype": "Section Break", + "label": "Tooling" + }, + { + "default": "0", + "fieldname": "print_tooling_required", + "fieldtype": "Check", + "label": "Print Tooling Required" + }, + { + "fieldname": "tooling_type", + "fieldtype": "Data", + "label": "Tooling Type" + }, + { + "fieldname": "tooling_set_reference", + "fieldtype": "Data", + "label": "Tooling Set Reference" + }, + { + "fieldname": "tooling_requirement_notes", + "fieldtype": "Small Text", + "label": "Tooling Requirement Notes" + }, + { + "fieldname": "critical_acceptance_requirements", + "fieldtype": "Table", + "label": "Critical Acceptance Requirements", + "options": "Critical Acceptance Requirement" + } + ], + "links": [], + "modified": "2026-09-02 00:05:48.067955", + "modified_by": "Administrator", + "module": "makap_backend", + "name": "Packaging Specification", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Commercial Reviewer", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Technical Authority", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Production Authority", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Quality Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "specification_series", + "track_changes": 1 +} \ No newline at end of file diff --git a/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.py b/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.py new file mode 100644 index 0000000..b6b7d4d --- /dev/null +++ b/makap_backend/makap_backend/doctype/packaging_specification/packaging_specification.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class PackagingSpecification(Document): + pass diff --git a/makap_backend/makap_backend/doctype/packaging_specification/test_packaging_specification.py b/makap_backend/makap_backend/doctype/packaging_specification/test_packaging_specification.py new file mode 100644 index 0000000..2c20dfb --- /dev/null +++ b/makap_backend/makap_backend/doctype/packaging_specification/test_packaging_specification.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Joseph njuki and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPackagingSpecification(FrappeTestCase): + pass diff --git a/makap_backend/makap_backend/operations.py b/makap_backend/makap_backend/operations.py new file mode 100644 index 0000000..8363011 --- /dev/null +++ b/makap_backend/makap_backend/operations.py @@ -0,0 +1,142 @@ +import frappe + + +def _require_doc(doctype, name): + if not frappe.db.exists(doctype, name): + frappe.throw(f"{doctype} {name} does not exist.") + return frappe.get_doc(doctype, name) + + +@frappe.whitelist() +def evaluate_work_order_readiness(work_order_name, save=True): + """Derive bounded readiness fields from standard Work Order/BOM/specification data.""" + work_order = _require_doc("Work Order", work_order_name) + blockers = [] + + spec_status = None + if work_order.makap_packaging_specification: + spec_status = frappe.db.get_value( + "Packaging Specification", work_order.makap_packaging_specification, "specification_status" + ) + 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 { + "work_order": work_order.name, + "status": work_order.makap_manufacturing_readiness_status, + "blockers": blockers, + "specification_status": spec_status, + } + + +@frappe.whitelist() +def evaluate_material_availability(work_order_name, save=True): + """Compare standard Work Order required quantities with standard Bin actual quantity.""" + work_order = _require_doc("Work Order", work_order_name) + shortages = [] + partially_available = False + + for item in work_order.get("required_items") or []: + required = float(item.required_qty or 0) + if not required or not item.item_code: + continue + filters = {"item_code": item.item_code} + if item.source_warehouse: + filters["warehouse"] = item.source_warehouse + available = sum( + float(row.actual_qty or 0) + for row in frappe.get_all("Bin", filters=filters, fields=["actual_qty"]) + ) + if available <= 0: + shortages.append(f"{item.item_code}: required {required}, available {available}") + elif available < required: + partially_available = True + shortages.append(f"{item.item_code}: required {required}, available {available}") + + if shortages and partially_available: + status = "Partially Available" + elif shortages: + status = "Blocked" + else: + status = "Available" + + work_order.makap_material_availability_status = status + work_order.makap_material_availability_checked_by = frappe.session.user + work_order.makap_material_availability_checked_on = frappe.utils.now_datetime() + work_order.makap_material_availability_blocker_summary = "\n".join(shortages) + if save: + work_order.save(ignore_permissions=True) + frappe.db.commit() + + return {"work_order": work_order.name, "status": status, "shortages": shortages} + + +@frappe.whitelist() +def apply_quality_disposition(quality_inspection_name, disposition, quantity=None, reason=None): + """Record bounded quality disposition evidence on a standard Quality Inspection.""" + inspection = _require_doc("Quality Inspection", quality_inspection_name) + allowed = {"Pending", "Release", "Hold", "Reject", "Rework", "Scrap", "Concession"} + if disposition not in allowed: + frappe.throw("Invalid quality disposition.") + if disposition == "Release" and inspection.makap_quality_eligibility != "Eligible": + frappe.throw("Release requires Quality Eligibility Eligible.") + if disposition == "Concession" and not inspection.makap_concession_reference: + frappe.throw("Concession requires a Concession Reference.") + + inspection.makap_quality_disposition = disposition + inspection.makap_disposition_quantity = quantity + inspection.makap_disposition_reason = reason + inspection.makap_disposition_by = frappe.session.user + inspection.makap_disposition_on = frappe.utils.now_datetime() + if disposition == "Release": + inspection.makap_quality_eligibility = "Eligible" + elif disposition in {"Hold", "Reject", "Rework", "Scrap"}: + inspection.makap_quality_eligibility = "Hold" if disposition == "Hold" else "Ineligible" + inspection.save(ignore_permissions=True) + frappe.db.commit() + return {"quality_inspection": inspection.name, "disposition": disposition} + + +@frappe.whitelist() +def get_supply_assurance_snapshot(item_code, warehouse=None): + """Return a derived supply view from standard stock, material requests, and purchase orders.""" + bins = frappe.get_all( + "Bin", + filters={"item_code": item_code, **({"warehouse": warehouse} if warehouse else {})}, + fields=["warehouse", "actual_qty", "reserved_qty", "ordered_qty", "planned_qty"], + ) + mr_items = frappe.get_all( + "Material Request Item", + filters={"item_code": item_code, "docstatus": 1}, + fields=["parent", "qty", "ordered_qty", "schedule_date", "warehouse"], + limit_page_length=100, + ) + po_items = frappe.get_all( + "Purchase Order Item", + filters={"item_code": item_code, "docstatus": 1}, + fields=["parent", "qty", "received_qty", "schedule_date", "warehouse", "makap_expected_eligible_date"], + limit_page_length=100, + ) + return {"item_code": item_code, "bins": bins, "material_requests": mr_items, "purchase_orders": po_items} diff --git a/makap_backend/makap_backend/schema_sources/critical_acceptance_requirement.json b/makap_backend/makap_backend/schema_sources/critical_acceptance_requirement.json new file mode 100644 index 0000000..276f6c9 --- /dev/null +++ b/makap_backend/makap_backend/schema_sources/critical_acceptance_requirement.json @@ -0,0 +1,62 @@ +{ + "actions": [], + "allow_rename": 0, + "creation": "2026-09-01 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "characteristic", + "requirement_nominal", + "unit", + "minimum_lower_tolerance", + "maximum_upper_tolerance", + "notes" + ], + "fields": [ + { + "fieldname": "characteristic", + "fieldtype": "Data", + "label": "Characteristic", + "reqd": 1 + }, + { + "fieldname": "requirement_nominal", + "fieldtype": "Data", + "label": "Requirement / Nominal", + "reqd": 1 + }, + { + "fieldname": "unit", + "fieldtype": "Data", + "label": "Unit" + }, + { + "fieldname": "minimum_lower_tolerance", + "fieldtype": "Float", + "label": "Minimum / Lower Tolerance" + }, + { + "fieldname": "maximum_upper_tolerance", + "fieldtype": "Float", + "label": "Maximum / Upper Tolerance" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes" + } + ], + "istable": 1, + "links": [], + "module": "makap_backend", + "name": "Critical Acceptance Requirement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "editable_grid": 1, + "quick_entry": 0, + "modified_by": "Administrator" +} diff --git a/makap_backend/makap_backend/schema_sources/material_structure.json b/makap_backend/makap_backend/schema_sources/material_structure.json new file mode 100644 index 0000000..01868fc --- /dev/null +++ b/makap_backend/makap_backend/schema_sources/material_structure.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "allow_rename": 0, + "creation": "2026-09-01 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "sequence", + "material_film_description", + "raw_material_item", + "nominal_thickness_requirement", + "uom", + "layer_role_notes" + ], + "fields": [ + { + "fieldname": "sequence", + "fieldtype": "Int", + "label": "Sequence", + "reqd": 1 + }, + { + "fieldname": "material_film_description", + "fieldtype": "Data", + "label": "Material / Film Description", + "reqd": 1 + }, + { + "fieldname": "raw_material_item", + "fieldtype": "Link", + "label": "Raw Material Item", + "options": "Item" + }, + { + "fieldname": "nominal_thickness_requirement", + "fieldtype": "Float", + "label": "Nominal Thickness / Requirement" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM" + }, + { + "fieldname": "layer_role_notes", + "fieldtype": "Small Text", + "label": "Layer Role / Notes" + } + ], + "istable": 1, + "links": [], + "module": "makap_backend", + "name": "Material Structure", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "editable_grid": 1, + "quick_entry": 0, + "modified_by": "Administrator" +} diff --git a/makap_backend/makap_backend/setup.py b/makap_backend/makap_backend/setup.py new file mode 100644 index 0000000..1ca77e0 --- /dev/null +++ b/makap_backend/makap_backend/setup.py @@ -0,0 +1,178 @@ +import frappe + + +def ensure_makap_roles(): + role_name = "Commercial Reviewer" + if not frappe.db.exists("Role", role_name): + frappe.get_doc( + { + "doctype": "Role", + "role_name": role_name, + "desk_access": 1, + "is_custom": 1, + } + ).insert(ignore_permissions=True) + frappe.db.commit() + return role_name + + +def grant_onboarding_permissions(): + doctype = frappe.get_doc("DocType", "Customer Onboarding Request") + role_name = "Commercial Reviewer" + + existing_roles = {perm.role for perm in doctype.permissions} + if role_name not in existing_roles: + doctype.append( + "permissions", + { + "role": role_name, + "read": 1, + "write": 1, + "create": 1, + "delete": 0, + "submit": 0, + "cancel": 0, + "amend": 0, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1, + }, + ) + doctype.save(ignore_permissions=True) + frappe.db.commit() + + return role_name + + +def smoke_test_onboarding_validation(): + base = { + "doctype": "Customer Onboarding Request", + "organisation_name": "MAKAP Smoke Test Organisation", + "customer_type": "Company", + "contact_name": "Smoke Test Contact", + "email_id": "makap-smoke-test@example.invalid", + "telephone": "+256000000000", + "registration_source": "Website", + "privacy_notice_acknowledged": 1, + "onboarding_status": "Approved", + "customer_match_result": "Not Checked", + } + + blocked = frappe.new_doc("Customer Onboarding Request") + blocked.update(base) + try: + blocked.insert(ignore_permissions=True) + except Exception as exc: + blocked_error = str(exc) + else: + frappe.delete_doc("Customer Onboarding Request", blocked.name, ignore_permissions=True) + frappe.db.commit() + frappe.throw("Smoke test failed: unresolved request was Approved.") + + approved = frappe.new_doc("Customer Onboarding Request") + approved.update(base) + approved.organisation_name = "MAKAP Smoke Test Approved Organisation" + approved.email_id = "makap-smoke-approved@example.invalid" + approved.customer_match_result = "New Customer Confirmed" + approved.insert(ignore_permissions=True) + approved_name = approved.name + frappe.delete_doc("Customer Onboarding Request", approved_name, ignore_permissions=True) + frappe.db.commit() + + return { + "unresolved_request_blocked": "Customer Match Result" in blocked_error, + "approved_confirmed_request_allowed": True, + "temporary_record_deleted": not frappe.db.exists( + "Customer Onboarding Request", approved_name + ), + } + + +def ensure_makap_functional_roles(): + role_names = [ + "Technical Authority", + "Production Authority", + "Materials Controller", + "Supply Assurance Manager", + "Concession Authority", + ] + created = [] + for role_name in role_names: + if not frappe.db.exists("Role", role_name): + frappe.get_doc( + { + "doctype": "Role", + "role_name": role_name, + "desk_access": 1, + "is_custom": 1, + } + ).insert(ignore_permissions=True) + created.append(role_name) + frappe.db.commit() + return created + + +def ensure_makap_object_permissions(): + permissions = { + "Customer Onboarding Request": { + "Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, + }, + "Packaging Specification": { + "Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, + "Technical Authority": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, + "Production Authority": {"read": 1, "print": 1, "report": 1}, + "Quality Manager": {"read": 1, "print": 1, "report": 1}, + }, + } + + for doctype_name, role_map in permissions.items(): + doc = frappe.get_doc("DocType", doctype_name) + existing = {perm.role for perm in doc.permissions} + changed = False + for role_name, flags in role_map.items(): + if role_name in existing: + continue + row = {"role": role_name} + row.update(flags) + doc.append("permissions", row) + changed = True + if changed: + doc.save(ignore_permissions=True) + + frappe.db.commit() + return permissions + + +def ensure_makap_object_permissions(): + permissions = { + "Customer Onboarding Request": { + "Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, + }, + "Packaging Specification": { + "Commercial Reviewer": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, + "Technical Authority": {"read": 1, "write": 1, "create": 1, "print": 1, "report": 1, "export": 1}, + "Production Authority": {"read": 1, "print": 1, "report": 1}, + "Quality Manager": {"read": 1, "print": 1, "report": 1}, + }, + } + + for doctype_name, role_map in permissions.items(): + if not frappe.db.exists("DocType", doctype_name): + continue + doc = frappe.get_doc("DocType", doctype_name) + existing = {perm.role for perm in doc.permissions} + changed = False + for role_name, flags in role_map.items(): + if role_name in existing: + continue + row = {"role": role_name} + row.update(flags) + doc.append("permissions", row) + changed = True + if changed: + doc.save(ignore_permissions=True) + + frappe.db.commit() + return True diff --git a/makap_backend/makap_backend/setup_extensions.py b/makap_backend/makap_backend/setup_extensions.py new file mode 100644 index 0000000..9b6780c --- /dev/null +++ b/makap_backend/makap_backend/setup_extensions.py @@ -0,0 +1,103 @@ +import frappe + + +FIELDS = [ + # Module 2 — commercial/product linkage + ("Item", "makap_requires_packaging_specification", "Requires Packaging Specification", "Check", None, 0), + ("Quotation Item", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 0), + ("Quotation Item", "makap_specification_revision", "Packaging Specification Revision", "Data", None, 1), + ("Quotation Item", "makap_technical_summary", "Technical Summary", "Small Text", None, 1), + ("Sales Order Item", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 0), + ("Sales Order Item", "makap_specification_revision", "Packaging Specification Revision", "Data", None, 1), + ("Sales Order", "makap_customer_acceptance_basis", "Customer Acceptance Basis", "Select", "Customer PO\nSigned Quotation\nEmail/Written Confirmation\nDirect Confirmed Order\nOther", 0), + ("Sales Order", "makap_acceptance_reference", "Acceptance Reference", "Data", None, 0), + # 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), + ("Work Order", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 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_customer", "Customer", "Link", "Customer", 1), + ("Work Order", "makap_manufacturing_readiness_status", "Manufacturing Readiness Status", "Select", "Not Checked\nBlocked\nReady\nReleased", 0), + ("Work Order", "makap_manufacturing_readiness_blockers", "Manufacturing Readiness Blockers", "Small Text", None, 0), + ("Work Order", "makap_readiness_checked_by", "Readiness Checked By", "Link", "User", 1), + ("Work Order", "makap_readiness_checked_on", "Readiness Checked On", "Datetime", None, 1), + ("Work Order", "makap_bom_compatibility_status", "BOM Compatibility Status", "Select", "Not Checked\nCompatible\nIncompatible\nNot Applicable", 0), + ("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_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), + # Module 5 — bounded material accountability controls + ("Item", "makap_material_control_class", "Material Control Class", "Select", "Standard\nControlled\nHigh Traceability", 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_on", "Material Availability Checked On", "Datetime", None, 1), + ("Work Order", "makap_material_availability_blocker_summary", "Material Availability Blocker Summary", "Small Text", None, 0), + ("Work Order", "makap_material_close_status", "Material Close Status", "Select", "Open\nClosed\nException", 0), + ("Work Order", "makap_material_close_checked_by", "Material Close Checked By", "Link", "User", 1), + ("Work Order", "makap_material_close_checked_on", "Material Close Checked On", "Datetime", None, 1), + ("Work Order", "makap_material_close_exception_summary", "Material Close Exception Summary", "Small Text", None, 0), + # Module 6 — quality eligibility and traceability context + ("Quality Inspection", "makap_packaging_specification", "Packaging Specification", "Link", "Packaging Specification", 0), + ("Quality Inspection", "makap_specification_revision", "Specification Revision", "Data", None, 1), + ("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_inspection_trigger", "Inspection Trigger", "Select", "Routine\nFirst-Off\nPeriodic\nPost-Adjustment\nPost-Rework\nComplaint\nRetrospective Hold", 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_population_uom", "Population UOM", "Link", "UOM", 0), + ("Quality Inspection", "makap_sampling_unit", "Sampling Unit", "Data", None, 0), + ("Quality Inspection", "makap_quality_disposition", "Quality Disposition", "Select", "Pending\nRelease\nHold\nReject\nRework\nScrap\nConcession", 0), + ("Quality Inspection", "makap_disposition_quantity", "Disposition Quantity", "Float", None, 0), + ("Quality Inspection", "makap_disposition_uom", "Disposition UOM", "Link", "UOM", 0), + ("Quality Inspection", "makap_disposition_by", "Disposition By", "Link", "User", 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_defect_failure_category", "Defect / Failure Category", "Data", None, 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_type", "Reinspection Type", "Select", "Full\nPartial\nTargeted", 0), + ("Quality Inspection", "makap_concession_scope", "Concession Scope", "Small Text", None, 0), + ("Quality Inspection", "makap_concession_quantity", "Concession Quantity", "Float", None, 0), + ("Quality Inspection", "makap_concession_uom", "Concession UOM", "Link", "UOM", 0), + ("Quality Inspection", "makap_concession_reference", "Concession Reference", "Data", None, 0), + ("Quality Inspection", "makap_customer_approval_required", "Customer Approval Required", "Check", None, 0), + ("Quality Inspection", "makap_customer_approval_reference", "Customer Approval Reference", "Data", None, 0), + ("Quality Inspection", "makap_inspection_equipment_reference", "Inspection Equipment Reference", "Data", None, 0), + ("Quality Inspection", "makap_released_not_before", "Released Not Before", "Datetime", None, 0), + # Module 7 — planning, supply assurance and procurement evidence + ("Production Plan Item", "makap_material_need_date", "Material Need Date", "Date", None, 0), + ("Production Plan Item", "makap_coverage_mode", "Coverage Mode", "Select", "Pegged\nPooled", 0), + ("Material Request Item", "makap_material_need_date", "Material Need Date", "Date", None, 0), + ("Material Request Item", "makap_coverage_mode", "Coverage Mode", "Select", "Pegged\nPooled", 0), + ("Purchase Order Item", "makap_sourcing_basis", "Sourcing Basis", "Select", "Approved Supplier\nCompetitive RFQ\nSingle Source\nEmergency\nOther", 0), + ("Purchase Order Item", "makap_supplier_selection_reason", "Supplier Selection Reason", "Small Text", None, 0), + ("Purchase Order Item", "makap_supplier_confirmation_status", "Supplier Confirmation Status", "Select", "Not Requested\nRequested\nConfirmed\nAt Risk\nDeclined", 0), + ("Purchase Order Item", "makap_supplier_confirmation_reference", "Supplier Confirmation Reference", "Data", None, 0), + ("Purchase Order Item", "makap_expected_eligible_date", "Expected Eligible Date", "Date", None, 0), + ("Purchase Receipt Item", "makap_receipt_exception_type", "Receipt Exception Type", "Select", "None\nShort Receipt\nOver Receipt\nDamaged\nQuality Hold\nRejected\nDocumentation Exception", 0), +] + + +def ensure_custom_fields(): + created = [] + for dt, fieldname, label, fieldtype, options, read_only in FIELDS: + if not frappe.db.exists("DocType", dt): + continue + if frappe.db.exists("Custom Field", {"dt": dt, "fieldname": fieldname}): + continue + doc = frappe.get_doc( + { + "doctype": "Custom Field", + "dt": dt, + "fieldname": fieldname, + "label": label, + "fieldtype": fieldtype, + "options": options, + "read_only": read_only, + "insert_after": "amended_from" if dt in {"Sales Order", "Work Order", "Quality Inspection"} else None, + } + ) + doc.insert(ignore_permissions=True) + created.append(f"{dt}.{fieldname}") + frappe.db.commit() + return created diff --git a/makap_backend/makap_backend/setup_module3.py b/makap_backend/makap_backend/setup_module3.py new file mode 100644 index 0000000..a60b7d6 --- /dev/null +++ b/makap_backend/makap_backend/setup_module3.py @@ -0,0 +1,56 @@ +import json +from pathlib import Path + +import frappe + + +ROOT = Path(__file__).parent +SOURCE_DIR = ROOT / "schema_sources" + + +def ensure_module3_child_tables(): + for filename in ("material_structure.json", "critical_acceptance_requirement.json"): + data = json.loads((SOURCE_DIR / filename).read_text(encoding="utf-8")) + name = data["name"] + + if frappe.db.exists("DocType", name): + existing = frappe.get_doc("DocType", name) + existing.update(data) + existing.save(ignore_permissions=True) + else: + frappe.get_doc(data).insert(ignore_permissions=True) + + frappe.db.commit() + return ["Material Structure", "Critical Acceptance Requirement"] + + +def ensure_makap_backend_module(): + if not frappe.db.exists("Module Def", "makap_backend"): + frappe.get_doc( + { + "doctype": "Module Def", + "module_name": "makap_backend", + "app_name": "makap_backend", + "custom": 1, + } + ).insert(ignore_permissions=True) + frappe.db.commit() + return "makap_backend" + + +def ensure_module3_child_tables(): + for filename in ("material_structure.json", "critical_acceptance_requirement.json"): + data = json.loads((SOURCE_DIR / filename).read_text(encoding="utf-8")) + data["module"] = "makap_backend" + name = data["name"] + + if frappe.db.exists("DocType", name): + existing = frappe.get_doc("DocType", name) + existing.update(data) + existing.module = "makap_backend" + existing.save(ignore_permissions=True) + else: + frappe.get_doc(data).insert(ignore_permissions=True) + + frappe.db.commit() + return ["Material Structure", "Critical Acceptance Requirement"] diff --git a/makap_backend/makap_backend/workspace_setup.py b/makap_backend/makap_backend/workspace_setup.py new file mode 100644 index 0000000..9fefa72 --- /dev/null +++ b/makap_backend/makap_backend/workspace_setup.py @@ -0,0 +1,349 @@ +import json +import frappe + + +SHORTCUTS = [ + ("Customer Onboarding Request", "Customer Onboarding Request"), + ("Packaging Specification", "Packaging Specification"), + ("Material Structure", "Material Structure"), + ("Critical Acceptance Requirement", "Critical Acceptance Requirement"), +] + + +def ensure_makap_workspace(): + content = [ + { + "id": "makap_backend_header", + "type": "header", + "data": {"text": "MAKAP Backend", "col": 12}, + } + ] + for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1): + content.append( + { + "id": f"makap_shortcut_{index}", + "type": "shortcut", + "data": { + "shortcut_name": label, + "col": 3, + "link_to": doctype_name, + "type": "DocType", + }, + } + ) + + if frappe.db.exists("Workspace", "MAKAP Backend"): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.title = "MAKAP Backend" + workspace.label = "MAKAP Backend" + workspace.is_hidden = 0 + workspace.public = 1 + workspace.content = json.dumps(content) + workspace.set("links", []) + else: + workspace = frappe.get_doc( + { + "doctype": "Workspace", + "name": "MAKAP Backend", + "title": "MAKAP Backend", + "label": "MAKAP Backend", + "is_hidden": 0, + "public": 1, + "content": json.dumps(content), + "links": [], + } + ) + + for label, doctype_name in SHORTCUTS: + workspace.append( + "links", + { + "label": label, + "link_type": "DocType", + "link_to": doctype_name, + "type": "Card Break", + }, + ) + + workspace.save(ignore_permissions=True) + frappe.db.commit() + return "MAKAP Backend" + + +def ensure_makap_workspace(): + content = [ + {"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend", "col": 12}} + ] + for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1): + content.append( + { + "id": f"makap_shortcut_{index}", + "type": "shortcut", + "data": {"shortcut_name": label, "col": 3, "link_to": doctype_name, "type": "DocType"}, + } + ) + + values = { + "title": "MAKAP Backend", + "label": "MAKAP Backend", + "is_hidden": 0, + "public": 1, + "content": json.dumps(content), + } + + if frappe.db.exists("Workspace", "MAKAP Backend"): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.update(values) + workspace.set("links", []) + workspace.save(ignore_permissions=True) + else: + values.update({"doctype": "Workspace", "name": "MAKAP Backend", "links": []}) + workspace = frappe.get_doc(values) + workspace.insert(ignore_permissions=True) + + frappe.db.commit() + return "MAKAP Backend" + + +def ensure_makap_workspace(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.set("shortcuts", []) + + for label, doctype_name in SHORTCUTS: + workspace.append( + "shortcuts", + { + "shortcut_name": label, + "type": "DocType", + "link_to": doctype_name, + "icon": "list", + "color": "Blue", + }, + ) + + workspace.set("links", []) + workspace.save(ignore_permissions=True) + frappe.db.commit() + return "MAKAP Backend" + + +def ensure_makap_workspace(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.set("shortcuts", []) + + for label, doctype_name in SHORTCUTS: + workspace.append( + "shortcuts", + { + "label": label, + "type": "DocType", + "link_to": doctype_name, + "icon": "list", + "color": "Blue", + }, + ) + + workspace.set("links", []) + workspace.save(ignore_permissions=True) + frappe.db.commit() + return "MAKAP Backend" + +SHORTCUTS = [ + ("Customer Onboarding Request", "Customer Onboarding Request"), + ("Packaging Specification", "Packaging Specification"), + ("Customer", "Customer"), + ("Contact", "Contact"), + ("Opportunity", "Opportunity"), + ("Quotation", "Quotation"), + ("Sales Order", "Sales Order"), + ("Item", "Item"), + ("Operation", "Operation"), + ("Work Order", "Work Order"), + ("Quality Inspection", "Quality Inspection"), + ("Production Plan", "Production Plan"), + ("Material Request", "Material Request"), + ("Purchase Order", "Purchase Order"), + ("Purchase Receipt", "Purchase Receipt"), +] + + +def ensure_makap_workspace(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.set("shortcuts", []) + + for label, doctype_name in SHORTCUTS: + workspace.append( + "shortcuts", + { + "label": label, + "type": "DocType", + "link_to": doctype_name, + "icon": "list", + "color": "Blue", + }, + ) + + workspace.set("links", []) + workspace.save(ignore_permissions=True) + frappe.db.commit() + return len(SHORTCUTS) + + +def get_makap_workspace_shortcuts(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + return [ + {"label": row.label, "type": row.type, "link_to": row.link_to} + for row in workspace.shortcuts + ] + + +def ensure_makap_workspace(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.content = "[]" + workspace.set("links", []) + + for label, doctype_name in SHORTCUTS: + workspace.append( + "links", + { + "label": label, + "type": "Link", + "link_type": "DocType", + "link_to": doctype_name, + "onboard": 0, + }, + ) + + workspace.save(ignore_permissions=True) + frappe.db.commit() + return len(workspace.links) + + +def get_makap_workspace_links(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + return [ + {"idx": row.idx, "label": row.label, "type": row.type, "link_type": row.link_type, "link_to": row.link_to} + for row in workspace.links + ] + + +def ensure_makap_workspace(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.set("links", []) + workspace.set("shortcuts", []) + + blocks = [ + {"id": "makap_backend_header", "type": "header", "data": {"text": "MAKAP Backend Operations", "col": 12}} + ] + + for index, (label, doctype_name) in enumerate(SHORTCUTS, start=1): + workspace.append( + "shortcuts", + { + "label": label, + "type": "DocType", + "link_to": doctype_name, + "doc_view": "List", + "icon": "list", + "color": "Blue", + }, + ) + blocks.append( + { + "id": f"makap_shortcut_{index}", + "type": "shortcut", + "data": { + "shortcut_name": label, + "col": 3, + "link_to": doctype_name, + "type": "DocType", + "doc_view": "List", + "icon": "list", + "color": "Blue", + }, + } + ) + + workspace.content = json.dumps(blocks) + workspace.save(ignore_permissions=True) + frappe.db.commit() + return {"shortcuts": len(workspace.shortcuts), "links": len(workspace.links)} + +CUSTOMER_COMMERCIAL_SHORTCUTS = [ + ("Customer Onboarding Request", "Customer Onboarding Request"), + ("Customer", "Customer"), + ("Contact", "Contact"), + ("Opportunity", "Opportunity"), + ("Quotation", "Quotation"), + ("Sales Order", "Sales Order"), + ("Packaging Specification", "Packaging Specification"), + ("Item", "Item"), + ("Operation", "Operation"), + ("Work Order", "Work Order"), +] + +INTERNAL_OPERATIONS_SHORTCUTS = [ + ("Quality Inspection", "Quality Inspection"), + ("Production Plan", "Production Plan"), + ("Material Request", "Material Request"), + ("Purchase Order", "Purchase Order"), + ("Purchase Receipt", "Purchase Receipt"), +] + + +def ensure_makap_workspace(): + workspace = frappe.get_doc("Workspace", "MAKAP Backend") + workspace.set("links", []) + workspace.set("shortcuts", []) + + blocks = [] + shortcut_index = 1 + + for section_title, section_shortcuts in [ + ("Customer & Commercial Operations — Modules 1–4", CUSTOMER_COMMERCIAL_SHORTCUTS), + ("Internal Operations — Modules 5–7", INTERNAL_OPERATIONS_SHORTCUTS), + ]: + blocks.append( + { + "id": f"makap_section_{shortcut_index}", + "type": "header", + "data": {"text": section_title, "col": 12}, + } + ) + for label, doctype_name in section_shortcuts: + workspace.append( + "shortcuts", + { + "label": label, + "type": "DocType", + "link_to": doctype_name, + "doc_view": "List", + "icon": "list", + "color": "Blue", + }, + ) + blocks.append( + { + "id": f"makap_shortcut_{shortcut_index}", + "type": "shortcut", + "data": { + "shortcut_name": label, + "col": 3, + "link_to": doctype_name, + "type": "DocType", + "doc_view": "List", + "icon": "list", + "color": "Blue", + }, + } + ) + shortcut_index += 1 + + workspace.content = json.dumps(blocks) + workspace.save(ignore_permissions=True) + frappe.db.commit() + return { + "customer_commercial": len(CUSTOMER_COMMERCIAL_SHORTCUTS), + "internal_operations": len(INTERNAL_OPERATIONS_SHORTCUTS), + "total": len(workspace.shortcuts), + } diff --git a/makap_backend/modules.txt b/makap_backend/modules.txt index 539795f..74f7ca1 100644 --- a/makap_backend/modules.txt +++ b/makap_backend/modules.txt @@ -1 +1 @@ -Makap Backend \ No newline at end of file +makap_backend