Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions icd_tz/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@
"on_cancel": "icd_tz.icd_tz.api.purchase_order.on_cancel",
},
"Purchase Invoice": {
"before_validate": "icd_tz.icd_tz.api.purchase_invoice.set_wip_account",
"on_submit": "icd_tz.icd_tz.api.purchase_invoice.on_submit",
"on_cancel": "icd_tz.icd_tz.api.purchase_invoice.on_cancel",
},
Expand Down
30 changes: 29 additions & 1 deletion icd_tz/icd_tz/api/purchase_invoice.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import frappe

from icd_tz.icd_tz.api.purchase_order import get_expense_coverage, set_rows
from icd_tz.icd_tz.api.purchase_order import (
get_expense_coverage,
get_expense_items_by_type,
get_wip_account,
set_rows,
)


def set_wip_account(doc, method=None):
"""Hold port expense lines on the work in progress account

The mapper copies the account from the order the invoice was made from, but an
invoice raised on its own has none, and an invoice can be edited after it is made.
The expense is released to cost of goods sold when the container is invoiced, so a
line that slipped onto another account would never be released.

Only a configured port expense item on a container line is held: a container can be
tagged on any purchase, and the rest of the invoice is ERPNext's to account for.
Runs before validate so ERPNext still has its say on every other line.
"""

wip_account = get_wip_account(doc.company)
if not wip_account:
return

expense_items = get_expense_items_by_type()
for item in doc.items:
if item.get("icd_container") and item.item_code in expense_items:
item.expense_account = wip_account


def on_submit(doc, method):
Expand Down
49 changes: 47 additions & 2 deletions icd_tz/icd_tz/api/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,57 @@ def validate_no_draft_purchase_order(manifest: str, rows: list):
)


def get_wip_account(company: str) -> str | None:
"""Work in progress account port expenses of this company are booked to, or None

None means leave the expense account alone, so ERPNext keeps choosing it from the
item and company defaults as it always has. An account belongs to one company, and
the settings hold one account, so another company's expenses are left alone too
rather than made unsavable.
"""

settings_doc = frappe.get_cached_doc("ICD TZ Settings")
if not settings_doc.enable_wip_for_expenses or not settings_doc.wip_account:
return None

if frappe.get_cached_value("Account", settings_doc.wip_account, "company") != company:
return None

return settings_doc.wip_account


def get_required_wip_account(company: str) -> str | None:
"""The WIP account for a port expense order, or None while the ICD does not use one

Where the ICD is deliberately creating a port expense order, a configured account
that cannot serve the company is said out loud rather than passed over, which is
what would otherwise send the expense quietly back to the ERPNext default.
"""

settings_doc = frappe.get_cached_doc("ICD TZ Settings")
if not settings_doc.enable_wip_for_expenses:
return None

wip_account = get_wip_account(company)
if not wip_account:
frappe.throw(
_("Port expenses are booked to work in progress, but {0} cannot be used for {1}").format(
frappe.bold(settings_doc.wip_account or _("no WIP Account")), frappe.bold(company)
),
title=_("WIP Account Not Usable"),
)

return wip_account


def build_purchase_order(manifest: str, buying_price_list: str, supplier: str, rows: list):
"""Draft order carrying one line per container, so cost lands on the right dimension"""

if not supplier:
frappe.throw(_("Select a Supplier before creating the Purchase Order"))

header = get_manifest_header(manifest)
wip_account = get_required_wip_account(header.company)

purchase_order = frappe.new_doc("Purchase Order")
purchase_order.update(
Expand All @@ -110,20 +154,21 @@ def build_purchase_order(manifest: str, buying_price_list: str, supplier: str, r

for row in rows:
for container in row["containers"]:
add_container_line(purchase_order, manifest, row, container)
add_container_line(purchase_order, manifest, row, container, wip_account)

purchase_order.flags.ignore_permissions = True
purchase_order.insert()

return purchase_order


def add_container_line(purchase_order, manifest: str, row: dict, container: dict):
def add_container_line(purchase_order, manifest: str, row: dict, container: dict, wip_account: str | None):
"""One order line for one container, stamped with its accounting dimensions"""

purchase_order.append(
"items",
{
"expense_account": wip_account,
"item_code": row["item_code"],
"qty": container["qty"],
"rate": row["rate"],
Expand Down
11 changes: 11 additions & 0 deletions icd_tz/icd_tz/doctype/icd_tz_settings/icd_tz_settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ frappe.ui.form.on("ICD TZ Settings", {
};
});

for (const field of ["wip_account", "cogs_account"]) {
frm.set_query(field, () => {
return {
filters: {
is_group: 0,
company: frappe.defaults.get_user_default("Company"),
},
};
});
}

frm.set_query("expense_item", "expense_types", () => {
return {
filters: {
Expand Down
40 changes: 38 additions & 2 deletions icd_tz/icd_tz/doctype/icd_tz_settings/icd_tz_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"section_break_qvvx",
"icd_code",
"default_price_list",
"column_break_8yjq8",
Expand All @@ -28,6 +29,10 @@
"expenses_tab",
"expense_defaults_section",
"default_buying_price_list",
"wip_account",
"column_break_wip",
"enable_wip_for_expenses",
"cogs_account",
"expense_pricing_criteria_section",
"expense_types",
"port_storage_section",
Expand Down Expand Up @@ -181,6 +186,33 @@
"fieldtype": "Tab Break",
"label": "Expenses"
},
{
"default": "0",
"description": "Book port expenses to a work in progress account instead of letting ERPNext choose the expense account. The COGS Account is held for releasing them when the container is invoiced.",
"fieldname": "enable_wip_for_expenses",
"fieldtype": "Check",
"label": "Enable WIP for Port Expenses"
},
{
"depends_on": "enable_wip_for_expenses",
"fieldname": "wip_account",
"fieldtype": "Link",
"label": "WIP Account",
"mandatory_depends_on": "enable_wip_for_expenses",
"options": "Account"
},
{
"fieldname": "column_break_wip",
"fieldtype": "Column Break"
},
{
"depends_on": "enable_wip_for_expenses",
"fieldname": "cogs_account",
"fieldtype": "Link",
"label": "COGS Account",
"mandatory_depends_on": "enable_wip_for_expenses",
"options": "Account"
},
{
"fieldname": "expense_defaults_section",
"fieldtype": "Section Break",
Expand Down Expand Up @@ -220,12 +252,16 @@
"fieldname": "port_storage_example",
"fieldtype": "HTML",
"options": "<h2>Example</h2>\n<style>\ntable {\n font-family: arial, sans-serif;\n border-collapse: collapse;\n width: 100%;\n}\n\ntd, th {\n border: 1px solid #dddddd;\n text-align: left;\n padding: 8px;\n}\n\n</style>\n\n<table>\n <tbody>\n <tr style=\"background-color: #dddddd;\">\n <th>Charge</th>\n <th>From</th>\n <th>To</th>\n </tr>\n <tr>\n <td>Single</td>\n <td>1</td>\n <td>7</td>\n </tr>\n <tr>\n <td>Double</td>\n <td>8</td>\n <td>9999999</td>\n </tr>\n</tbody></table>\n"
},
{
"fieldname": "section_break_qvvx",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-09-22 12:00:00.000000",
"modified": "2026-09-23 23:35:21.302602",
"modified_by": "Administrator",
"module": "Icd Tz",
"name": "ICD TZ Settings",
Expand All @@ -247,4 +283,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}
52 changes: 52 additions & 0 deletions icd_tz/icd_tz/doctype/icd_tz_settings/icd_tz_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,58 @@ def before_save(self):
self.validate_storage_days()
self.validate_expense_types()
self.validate_port_storage_days()
self.validate_wip_accounts()

def validate_wip_accounts(self):
"""What the two accounts must be before expenses can be booked to work in progress

The form makes them mandatory and filters out group accounts, but neither
reaches an import, a patch or the API, and a group account fails only later at
posting time.
"""

if not self.enable_wip_for_expenses:
return

missing = [
self.meta.get_label(field) for field in ("wip_account", "cogs_account") if not self.get(field)
]
if missing:
frappe.throw(
_("{0} is required while port expenses are booked to work in progress").format(
frappe.bold(" and ".join(missing))
),
title=_("WIP Accounts Not Set"),
)

if self.wip_account == self.cogs_account:
frappe.throw(
_("WIP Account and COGS Account must differ, or releasing an expense would post nothing"),
title=_("WIP Accounts Not Set"),
)

companies = set()
for field in ("wip_account", "cogs_account"):
account = frappe.get_cached_value(
"Account", self.get(field), ["is_group", "company"], as_dict=True
)
if account.is_group:
frappe.throw(
_("{0} is a group account, which cannot be posted to").format(
frappe.bold(self.get(field))
),
title=_("WIP Accounts Not Set"),
)

companies.add(account.company)

if len(companies) > 1:
frappe.throw(
_("WIP Account and COGS Account belong to different companies: {0}").format(
frappe.bold(", ".join(sorted(companies)))
),
title=_("WIP Accounts Not Set"),
)

def validate_storage_days(self):
storage_days = []
Expand Down
Loading
Loading