diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4b96c5a..b9e52c5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.20.3 + rev: v1.25.1 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/inventory_tools/docs/uom_conversion_semantics.md b/inventory_tools/docs/uom_conversion_semantics.md new file mode 100644 index 00000000..138f3996 --- /dev/null +++ b/inventory_tools/docs/uom_conversion_semantics.md @@ -0,0 +1,31 @@ + + +# UOM conversion semantics (Inventory Tools) + +## Purpose + +ERPNext **[UOM Conversion Factor](https://docs.erpnext.com/)** requires a numeric **Value**. Some real-world ratios (for example pallet vs weight) are intentionally **not** a single global constant. Inventory Tools adds **Conversion basis (Inventory Tools)** (`it_conversion_basis`) on **UOM Conversion Factor**: + +| Value stored | Meaning for strict tooling | +| --- | --- | +| unset / empty | Same as **fixed** (backward compatible default). | +| **fixed** | Row participates in naive fixed-ratio / ambiguity suspicion. | +| **contextual** | Row is intentionally non-universal; suspicion reports should **skip** naive 1:1 / fixed-ratio checks for this row or show an informational “marked variable” state. | + +## What this does **not** change + +- **`get_uom_conv_factor`** and core stock qty resolution continue to use the numeric **Value** when a row matches. **`contextual` is metadata for Inventory Tools tooling**, not a change to ERPNext resolver behavior. +- For item-specific ratios, rely on **[Item UOM Conversion Detail](https://docs.erpnext.com/)** and transaction-level UOMs as ERPNext intends. + +## Python helpers + +Module `inventory_tools.inventory_tools.uom_conversion_semantics` (`inventory_tools/inventory_tools/uom_conversion_semantics.py`) exposes: + +- `is_contextual_conversion_factor(doc)` — carve-out predicate. +- `participates_in_strict_conversion_checks(doc)` — inverse for validators. +- `qb_uom_conversion_factor_strict_rows(ucf)` / `qb_uom_conversion_factor_contextual_rows(ucf)` — Query Builder filters for reports. + +## UX copy (future reports) + +When listing excluded contextual rows, prefer short notes such as: **“Marked contextual — global factor not validated”** or **“Intentionally variable (per item/shipment); not checked for fixed ratio.”** diff --git a/inventory_tools/inventory_tools/custom/uom_conversion_factor.json b/inventory_tools/inventory_tools/custom/uom_conversion_factor.json new file mode 100644 index 00000000..b9285858 --- /dev/null +++ b/inventory_tools/inventory_tools/custom/uom_conversion_factor.json @@ -0,0 +1,47 @@ +{ + "custom_fields": [ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "fixed", + "description": "Fixed: global scalar is meaningful for strict ambiguity checks. Contextual: relationship is SKU- or shipment-dependent; strict/suspicion tooling should skip naive fixed-ratio assumptions (ERPNext qty resolution unchanged—use Item UOM Conversion Detail where needed).", + "dt": "UOM Conversion Factor", + "fetch_if_empty": 0, + "fieldname": "it_conversion_basis", + "fieldtype": "Select", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 1, + "insert_after": "value", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Conversion basis (Inventory Tools)", + "length": 0, + "module": "Inventory Tools", + "name": "UOM Conversion Factor-it_conversion_basis", + "no_copy": 0, + "non_negative": 0, + "options": "\nfixed\ncontextual", + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + } + ] +} diff --git a/inventory_tools/inventory_tools/overrides/stock_entry.py b/inventory_tools/inventory_tools/overrides/stock_entry.py index 1ce6cb8f..5d305c0f 100644 --- a/inventory_tools/inventory_tools/overrides/stock_entry.py +++ b/inventory_tools/inventory_tools/overrides/stock_entry.py @@ -183,7 +183,7 @@ def get_pending_raw_materials(self, backflush_based_on=None): @frappe.whitelist() def get_items(self): """ - HASH: 279cf6fe007528f624f8f1e5ef7d169124584756 + HASH: 4dd9f0b25545a034ae3cc2012dc5a1049449c5b7 REPO: https://github.com/frappe/erpnext/ PATH: erpnext/stock/doctype/stock_entry/stock_entry.py METHOD: get_items diff --git a/inventory_tools/inventory_tools/overrides/work_order.py b/inventory_tools/inventory_tools/overrides/work_order.py index fc89a57b..799d435f 100644 --- a/inventory_tools/inventory_tools/overrides/work_order.py +++ b/inventory_tools/inventory_tools/overrides/work_order.py @@ -17,7 +17,7 @@ class InventoryToolsWorkOrder(WorkOrder): def onload(self): """ - HASH: 46a2b7a07e5326ad5fde89d030460a5e9f2b67b0 + HASH: 4dd9f0b25545a034ae3cc2012dc5a1049449c5b7 REPO: https://github.com/frappe/erpnext/ PATH: erpnext/manufacturing/doctype/work_order/work_order.py METHOD: onload diff --git a/inventory_tools/inventory_tools/report/uom_conversion_exceptions/__init__.py b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/__init__.py new file mode 100644 index 00000000..ea2ab3f3 --- /dev/null +++ b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt diff --git a/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.js b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.js new file mode 100644 index 00000000..f0d6bc83 --- /dev/null +++ b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.js @@ -0,0 +1,48 @@ +// Copyright (c) 2026, AgriTheory and contributors +// For license information, please see license.txt + +frappe.query_reports['UOM Conversion Exceptions'] = { + filters: [ + { + fieldname: 'company', + label: __('Company'), + fieldtype: 'Link', + options: 'Company', + default: frappe.defaults.get_default('company'), + }, + { + fieldname: 'from_date', + label: __('From Date'), + fieldtype: 'Date', + }, + { + fieldname: 'to_date', + label: __('To Date'), + fieldtype: 'Date', + }, + { + fieldname: 'uom', + label: __('UOM'), + fieldtype: 'MultiSelectList', + options: 'UOM', + get_data: function (txt) { + return frappe.db.get_link_options('UOM', txt) + }, + }, + { + fieldname: 'uom_category', + label: __('UOM Category'), + fieldtype: 'MultiSelectList', + options: 'UOM Category', + get_data: function (txt) { + return frappe + .call({ + method: 'inventory_tools.inventory_tools.uom_conversion_exceptions.category_filter_multiselect_data', + args: { txt: txt || '' }, + type: 'POST', + }) + .then(r => r.message || []) + }, + }, + ], +} diff --git a/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.json b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.json new file mode 100644 index 00000000..5dc0d99d --- /dev/null +++ b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "add_total_row": 0, + "add_translate_data": 1, + "creation": "2026-05-01 12:00:00", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-05-01 12:00:00", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "UOM Conversion Exceptions", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Item", + "report_name": "UOM Conversion Exceptions", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock Manager" + }, + { + "role": "Stock User" + }, + { + "role": "Item Manager" + }, + { + "role": "Purchase Manager" + }, + { + "role": "Purchase User" + }, + { + "role": "Sales User" + }, + { + "role": "System Manager" + } + ], + "timeout": 0 +} diff --git a/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.py b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.py new file mode 100644 index 00000000..53c13c5b --- /dev/null +++ b/inventory_tools/inventory_tools/report/uom_conversion_exceptions/uom_conversion_exceptions.py @@ -0,0 +1,195 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +from __future__ import annotations + +import frappe +from frappe import _ +from frappe.query_builder import DocType + +from inventory_tools.inventory_tools.uom_conversion_exceptions import ( + DEFAULT_SCAN_SPECS, + ScanSpec, + factor_categories_for_uoms, + is_undocumented_line_uom, + load_item_meta, + load_uom_conversion_detail_pairs, + normalize_multiselect_filter, + row_matches_category_filter, + row_matches_uom_filter, +) + + +def execute(filters: dict | None = None): + filters = filters or {} + selected_uoms = normalize_multiselect_filter(filters.get("uom")) + selected_categories = normalize_multiselect_filter(filters.get("uom_category")) + company = filters.get("company") + from_date = filters.get("from_date") + to_date = filters.get("to_date") + + columns = get_columns() + raw_rows: list[dict] = [] + for spec in DEFAULT_SCAN_SPECS: + raw_rows.extend(_query_spec_rows(spec, company, from_date, to_date)) + + if not raw_rows: + return columns, [] + + item_codes = {r["item_code"] for r in raw_rows if r.get("item_code")} + item_meta = load_item_meta(item_codes) + detail_pairs = load_uom_conversion_detail_pairs(item_codes, item_meta) + + candidates: list[dict] = [] + for r in raw_rows: + code = r.get("item_code") + if not code or code not in item_meta: + continue + line_uom = r.get("line_uom") + if not line_uom: + continue + row_stock = r.get("row_stock_uom") or "" + stock_uom = row_stock or (item_meta[code].get("stock_uom") or "") + if not stock_uom or line_uom == stock_uom: + continue + r["_norm_stock_uom"] = stock_uom + candidates.append(r) + + if not candidates: + return columns, [] + + uoms_for_cats = {c["_norm_stock_uom"] for c in candidates} | {c["line_uom"] for c in candidates} + uom_to_cats = factor_categories_for_uoms(uoms_for_cats) + + out: list[dict] = [] + for r in candidates: + stock_uom = r["_norm_stock_uom"] + line_uom = r["line_uom"] + if not row_matches_uom_filter(selected_uoms, line_uom, stock_uom): + continue + if not row_matches_category_filter(selected_categories, line_uom, stock_uom, uom_to_cats): + continue + if not is_undocumented_line_uom(r["item_code"], line_uom, stock_uom, item_meta, detail_pairs): + continue + + meta_row = item_meta[r["item_code"]] + out.append( + { + "parenttype": r["parenttype"], + "parent": r["parent"], + "transaction_date": r.get("transaction_date"), + "child_row_name": r.get("child_row_name"), + "child_idx": r.get("child_idx"), + "item_code": r["item_code"], + "item_name": meta_row.get("item_name") or "", + "line_uom": line_uom, + "stock_uom": stock_uom, + "row_conversion_factor": r.get("row_conversion_factor"), + "reason": _("No Item UOM Conversion Detail and no global UOM Conversion Factor"), + } + ) + + return columns, out + + +def get_columns(): + return [ + { + "label": _("Parent DocType"), + "fieldname": "parenttype", + "fieldtype": "Data", + "width": 140, + }, + { + "label": _("Document"), + "fieldname": "parent", + "fieldtype": "Dynamic Link", + "options": "parenttype", + "width": 160, + }, + {"label": _("Date"), "fieldname": "transaction_date", "fieldtype": "Date", "width": 110}, + { + "label": _("Row name"), + "fieldname": "child_row_name", + "fieldtype": "Data", + "width": 130, + }, + {"label": _("Row idx"), "fieldname": "child_idx", "fieldtype": "Int", "width": 70}, + { + "label": _("Item"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 140, + }, + {"label": _("Item name"), "fieldname": "item_name", "fieldtype": "Data", "width": 180}, + { + "label": _("Line UOM"), + "fieldname": "line_uom", + "fieldtype": "Link", + "options": "UOM", + "width": 100, + }, + { + "label": _("Stock UOM"), + "fieldname": "stock_uom", + "fieldtype": "Link", + "options": "UOM", + "width": 100, + }, + { + "label": _("Stored conv. factor"), + "fieldname": "row_conversion_factor", + "fieldtype": "Float", + "width": 120, + }, + {"label": _("Reason"), "fieldname": "reason", "fieldtype": "Data", "width": 360}, + ] + + +def _query_spec_rows( + spec: ScanSpec, company: str | None, from_date, to_date +) -> list[dict[str, object]]: + child_meta = frappe.get_meta(spec.child_doctype) + child = DocType(spec.child_doctype) + parent = DocType(spec.parent_doctype) + + date_col = getattr(parent, spec.date_field) + select_fields: list = [ + child.name.as_("child_row_name"), + child.idx.as_("child_idx"), + child.parent, + child.item_code, + child.uom.as_("line_uom"), + child.stock_uom.as_("row_stock_uom"), + date_col.as_("transaction_date"), + ] + if child_meta.get_field("conversion_factor"): + select_fields.append(child.conversion_factor.as_("row_conversion_factor")) + + q = ( + frappe.qb.from_(child).inner_join(parent).on(child.parent == parent.name).select(*select_fields) + ) + + conds = ( + (parent.docstatus == 1) + & (child.parenttype == spec.parent_doctype) + & (child.item_code.isnotnull()) + & (child.item_code != "") + & (child.uom.isnotnull()) + & (child.uom != "") + & ((child.stock_uom.isnull()) | (child.uom != child.stock_uom)) + ) + if spec.company_field and company: + conds &= getattr(parent, spec.company_field) == company + if from_date: + conds &= date_col >= from_date + if to_date: + conds &= date_col <= to_date + + rows = q.where(conds).run(as_dict=True) + for r in rows: + r["parenttype"] = spec.parent_doctype + if "row_conversion_factor" not in r: + r["row_conversion_factor"] = None + return rows diff --git a/inventory_tools/inventory_tools/uom_conversion_exceptions.py b/inventory_tools/inventory_tools/uom_conversion_exceptions.py new file mode 100644 index 00000000..b0912c83 --- /dev/null +++ b/inventory_tools/inventory_tools/uom_conversion_exceptions.py @@ -0,0 +1,197 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any +from collections.abc import Callable, Iterable + +import frappe +from frappe import _ +from frappe.query_builder import DocType + +from erpnext.stock.doctype.item.item import get_uom_conv_factor + +NOT_CATEGORIZED_SENTINEL = "__IT_NOT_CATEGORIZED__" + + +@dataclass(frozen=True, slots=True) +class ScanSpec: + child_doctype: str + parent_doctype: str + """Date field on parent for from/to filters.""" + date_field: str + """Field on parent for company filter; None if parent has no company.""" + company_field: str | None = "company" + + +# High-volume transactional child tables with item_code + uom + stock_uom (ERPNext stock flow). +DEFAULT_SCAN_SPECS: tuple[ScanSpec, ...] = ( + ScanSpec("Sales Order Item", "Sales Order", "transaction_date", "company"), + ScanSpec("Delivery Note Item", "Delivery Note", "posting_date", "company"), + ScanSpec("Purchase Order Item", "Purchase Order", "transaction_date", "company"), + ScanSpec("Purchase Receipt Item", "Purchase Receipt", "posting_date", "company"), + ScanSpec("Material Request Item", "Material Request", "transaction_date", "company"), + ScanSpec("Stock Entry Detail", "Stock Entry", "posting_date", "company"), + ScanSpec("Quotation Item", "Quotation", "transaction_date", "company"), + ScanSpec("Sales Invoice Item", "Sales Invoice", "posting_date", "company"), + ScanSpec("Purchase Invoice Item", "Purchase Invoice", "posting_date", "company"), +) + + +def normalize_multiselect_filter(value: Any) -> list[str]: + if value in (None, "", []): + return [] + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [value] if value.strip() else [] + value = parsed + if not isinstance(value, (list, tuple)): + return [] + out: list[str] = [] + for x in value: + if x is None: + continue + s = str(x).strip() + if s: + out.append(s) + return out + + +def factor_categories_for_uoms(uoms: Iterable[str]) -> dict[str, set[str]]: + """Map each UOM name to distinct UOM Conversion Factor category names it appears in.""" + names = {u for u in uoms if u} + if not names: + return {} + ucf = DocType("UOM Conversion Factor") + nlist = list(names) + fr = getattr(ucf, "from_uom") + to = getattr(ucf, "to_uom") + cat = getattr(ucf, "category") + q1 = frappe.qb.from_(ucf).select(fr.as_("uom_name"), cat).where(fr.isin(nlist)) + q2 = frappe.qb.from_(ucf).select(to.as_("uom_name"), cat).where(to.isin(nlist)) + rows = q1.union(q2).run() + result: dict[str, set[str]] = {u: set() for u in names} + for uom_name, category in rows: + if uom_name in result and category: + result[uom_name].add(category) + return result + + +def load_item_meta(item_codes: Iterable[str]) -> dict[str, dict[str, Any]]: + codes = [c for c in {x for x in item_codes if x} if frappe.db.exists("Item", c)] + if not codes: + return {} + rows = frappe.get_all( + "Item", + filters={"name": ("in", codes)}, + fields=["name", "item_name", "stock_uom", "variant_of", "is_stock_item"], + as_list=False, + ) + return {r.name: r for r in rows} + + +def load_uom_conversion_detail_pairs( + item_codes: Iterable[str], item_meta: dict[str, dict[str, Any]] +) -> set[tuple[str, str]]: + parents: set[str] = set() + for code in item_codes: + if code not in item_meta: + continue + parents.add(code) + v = item_meta[code].get("variant_of") + if v: + parents.add(v) + if not parents: + return set() + rows = frappe.get_all( + "UOM Conversion Detail", + filters={"parent": ("in", list(parents))}, + fields=["parent", "uom"], + ) + return {(r.parent, r.uom) for r in rows if r.uom} + + +def item_has_conversion_detail( + item_code: str, + line_uom: str, + item_meta: dict[str, dict[str, Any]], + detail_pairs: set[tuple[str, str]], +) -> bool: + meta = item_meta.get(item_code) + if not meta: + return False + for parent_key in (item_code, meta.get("variant_of")): + if parent_key and (parent_key, line_uom) in detail_pairs: + return True + return False + + +def global_uom_conversion_resolves(line_uom: str, stock_uom: str) -> bool: + if line_uom == stock_uom: + return True + f = get_uom_conv_factor(line_uom, stock_uom) + return f is not None + + +def is_undocumented_line_uom( + item_code: str, + line_uom: str, + stock_uom: str, + item_meta: dict[str, dict[str, Any]], + detail_pairs: set[tuple[str, str]], + _global_check: Callable[[str, str], bool] | None = None, +) -> bool: + """ + True when ERPNext would fall back to conversion_factor 1.0 with no backing detail/global factor. + """ + if not item_code or not line_uom: + return False + meta = item_meta.get(item_code) + if not meta or not meta.get("is_stock_item"): + return False + if line_uom == stock_uom: + return False + if item_has_conversion_detail(item_code, line_uom, item_meta, detail_pairs): + return False + check = _global_check or global_uom_conversion_resolves + return not check(line_uom, stock_uom) + + +def row_matches_uom_filter(selected_uoms: list[str], line_uom: str, stock_uom: str) -> bool: + if not selected_uoms: + return True + sel = set(selected_uoms) + return line_uom in sel or stock_uom in sel + + +def row_matches_category_filter( + selected: list[str], line_uom: str, stock_uom: str, uom_to_cats: dict[str, set[str]] +) -> bool: + if not selected: + return True + line_cats = uom_to_cats.get(line_uom, set()) + stock_cats = uom_to_cats.get(stock_uom, set()) + for token in selected: + if token == NOT_CATEGORIZED_SENTINEL: + if not line_cats or not stock_cats: + return True + elif token in line_cats or token in stock_cats: + return True + return False + + +@frappe.whitelist() +def category_filter_multiselect_data(txt: str | None = None) -> list[dict[str, str]]: + """Link-style options for UOM Category multiselect plus synthetic not-in-table row.""" + frappe.has_permission("UOM Category", "read", throw=True) + sentinel = { + "value": NOT_CATEGORIZED_SENTINEL, + "description": _("Not in any conversion factor"), + } + opts = frappe.db.get_link_options("UOM Category", (txt or "").strip(), None) or [] + return [sentinel, *opts] diff --git a/inventory_tools/inventory_tools/uom_conversion_semantics.py b/inventory_tools/inventory_tools/uom_conversion_semantics.py new file mode 100644 index 00000000..257f8ada --- /dev/null +++ b/inventory_tools/inventory_tools/uom_conversion_semantics.py @@ -0,0 +1,82 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + +""" +Inventory Tools convention for UOM Conversion Factor rows whose numeric ``value`` is +not meant as a universal constant (e.g. pallet vs mass). + +This module is for **reports, validators, and suspicion tooling** only. Core ERPNext +``get_uom_conv_factor`` and related resolution still treat matched rows as scalars; +``contextual`` does not change that unless a future core hook exists. +""" + +from __future__ import annotations + +from typing import Any, Union +from collections.abc import Mapping + +from frappe.query_builder import DocType + +from pypika.terms import Criterion as PypikaCriterion + +FIELD_CONVERSION_BASIS = "it_conversion_basis" +BASIS_FIXED = "fixed" +BASIS_CONTEXTUAL = "contextual" + +ConversionFactorLike = Union[Mapping[str, Any], Any] + + +def get_conversion_basis(doc: ConversionFactorLike) -> str | None: + """Return raw ``it_conversion_basis`` or None if absent / blank.""" + if doc is None: + return None + raw = conversion_factor_field(doc, FIELD_CONVERSION_BASIS) + if raw is None: + return None + s = str(raw).strip() + return s or None + + +def is_contextual_conversion_factor(doc: ConversionFactorLike) -> bool: + """True when this row is explicitly marked contextual (strict tooling carve-out).""" + return get_conversion_basis(doc) == BASIS_CONTEXTUAL + + +def participates_in_strict_conversion_checks(doc: ConversionFactorLike) -> bool: + """ + Whether naive fixed-ratio / ambiguity suspicion should apply. + + Unset and ``fixed`` participate; ``contextual`` does not. + Any unexpected stored value participates (fail-safe). + """ + basis = get_conversion_basis(doc) + if basis is None: + return True + if basis == BASIS_CONTEXTUAL: + return False + return True + + +def qb_uom_conversion_factor_strict_rows(ucf_alias: DocType) -> PypikaCriterion: + """ + Filter on a ``frappe.qb.DocType("UOM Conversion Factor")`` alias. + + Matches rows that **should** be evaluated by strict / suspicion tooling + (i.e. not explicitly contextual). SQL-safe for NULL ``it_conversion_basis``. + """ + column = getattr(ucf_alias, FIELD_CONVERSION_BASIS) + return column.isnull() | (column != BASIS_CONTEXTUAL) + + +def qb_uom_conversion_factor_contextual_rows(ucf_alias: DocType) -> PypikaCriterion: + """Matches rows explicitly marked contextual (informational carve-out / exclusions).""" + return getattr(ucf_alias, FIELD_CONVERSION_BASIS) == BASIS_CONTEXTUAL + + +def conversion_factor_field(doc: ConversionFactorLike, fieldname: str): + if isinstance(doc, Mapping): + return doc.get(fieldname) + getter = getattr(doc, "get", None) + if callable(getter): + return getter(fieldname) + return getattr(doc, fieldname, None) diff --git a/inventory_tools/tests/setup.py b/inventory_tools/tests/setup.py index 6ccc2b94..b2c5d558 100644 --- a/inventory_tools/tests/setup.py +++ b/inventory_tools/tests/setup.py @@ -108,6 +108,7 @@ def create_test_data(): copy_image_fixtures() frappe.db.set_single_value("Stock Settings", "valuation_method", "Moving Average") frappe.db.set_single_value("Stock Settings", "default_warehouse", "") + frappe.db.set_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item", 1) create_warehouse_plan(cfc) create_warehouses(settings) create_warehouse_locations() diff --git a/inventory_tools/tests/test_uom_conversion_exceptions.py b/inventory_tools/tests/test_uom_conversion_exceptions.py new file mode 100644 index 00000000..37ef3025 --- /dev/null +++ b/inventory_tools/tests/test_uom_conversion_exceptions.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import pytest + +import inventory_tools.inventory_tools.uom_conversion_exceptions as uce + + +@pytest.mark.order(75) +def test_normalize_multiselect_filter_accepts_lists_and_json(): + assert uce.normalize_multiselect_filter(None) == [] + assert uce.normalize_multiselect_filter("") == [] + assert uce.normalize_multiselect_filter([" Nos ", "Box"]) == ["Nos", "Box"] + assert uce.normalize_multiselect_filter('["Nos","Box"]') == ["Nos", "Box"] + + +@pytest.mark.order(76) +def test_row_matches_uom_filter_empty_means_all(): + assert uce.row_matches_uom_filter([], "A", "B") is True + assert uce.row_matches_uom_filter(["X"], "A", "B") is False + assert uce.row_matches_uom_filter(["A"], "A", "B") is True + assert uce.row_matches_uom_filter(["B"], "A", "B") is True + + +@pytest.mark.order(77) +def test_row_matches_category_filter_sentinel_or_categories(): + uom_map = { + "Kg": {"Mass"}, + "Lb": set(), + "Box": {"Count"}, + } + assert uce.row_matches_category_filter([], "Kg", "Lb", uom_map) is True + assert ( + uce.row_matches_category_filter([uce.NOT_CATEGORIZED_SENTINEL], "Kg", "Lb", uom_map) is True + ) + assert ( + uce.row_matches_category_filter([uce.NOT_CATEGORIZED_SENTINEL], "Kg", "Box", uom_map) is False + ) + assert ( + uce.row_matches_category_filter([uce.NOT_CATEGORIZED_SENTINEL], "Kg", "Nos", uom_map) is True + ) + assert uce.row_matches_category_filter(["Mass"], "Kg", "Lb", uom_map) is True + assert uce.row_matches_category_filter(["Mass"], "Box", "Lb", uom_map) is False + + +@pytest.mark.order(78) +def test_is_undocumented_line_uom_with_detail_and_global_stub(): + meta = { + "I1": { + "name": "I1", + "stock_uom": "Nos", + "variant_of": None, + "is_stock_item": 1, + }, + } + detail_pairs = {("I1", "Box")} + + assert ( + uce.is_undocumented_line_uom( + "I1", "Box", "Nos", meta, detail_pairs, _global_check=lambda a, b: False + ) + is False + ) + + assert ( + uce.is_undocumented_line_uom("I1", "Box", "Nos", meta, set(), _global_check=lambda a, b: False) + is True + ) + + assert ( + uce.is_undocumented_line_uom("I1", "Box", "Nos", meta, set(), _global_check=lambda a, b: True) + is False + ) + + assert uce.is_undocumented_line_uom("I1", "Nos", "Nos", meta, set()) is False + + +@pytest.mark.order(79) +def test_is_undocumented_non_stock_excluded(): + meta = { + "SVC": { + "name": "SVC", + "stock_uom": "Nos", + "variant_of": None, + "is_stock_item": 0, + }, + } + assert uce.is_undocumented_line_uom("SVC", "Box", "Nos", meta, set()) is False diff --git a/inventory_tools/tests/test_uom_conversion_semantics.py b/inventory_tools/tests/test_uom_conversion_semantics.py new file mode 100644 index 00000000..32ceb142 --- /dev/null +++ b/inventory_tools/tests/test_uom_conversion_semantics.py @@ -0,0 +1,48 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + +import pytest +from frappe.query_builder import DocType + +import inventory_tools.inventory_tools.uom_conversion_semantics as ucs + + +@pytest.mark.order(70) +def test_contextual_basis_only_when_explicit(): + assert not ucs.is_contextual_conversion_factor({}) + assert not ucs.is_contextual_conversion_factor({"it_conversion_basis": None}) + assert not ucs.is_contextual_conversion_factor({"it_conversion_basis": ""}) + assert not ucs.is_contextual_conversion_factor({"it_conversion_basis": "fixed"}) + assert ucs.is_contextual_conversion_factor({"it_conversion_basis": "contextual"}) + assert ucs.is_contextual_conversion_factor({"it_conversion_basis": " contextual "}) + + +@pytest.mark.order(71) +def test_strict_participation_matches_contract(): + row_ctx = {"it_conversion_basis": "contextual"} + row_fixed = {"it_conversion_basis": "fixed"} + row_empty = {} + + assert ucs.participates_in_strict_conversion_checks(row_ctx) is False + assert ucs.participates_in_strict_conversion_checks(row_fixed) is True + assert ucs.participates_in_strict_conversion_checks(row_empty) is True + assert ( + ucs.participates_in_strict_conversion_checks({"it_conversion_basis": "typo-or-future"}) is True + ) + + +@pytest.mark.order(72) +def test_get_conversion_basis_normalizes_blank(): + assert ucs.get_conversion_basis({"it_conversion_basis": "fixed"}) == "fixed" + assert ucs.get_conversion_basis({"it_conversion_basis": " "}) is None + + +@pytest.mark.order(73) +def test_qb_where_clauses_include_expected_columns(): + ucf = DocType("UOM Conversion Factor") + sql_strict = ucs.qb_uom_conversion_factor_strict_rows(ucf).get_sql() + sql_ctx = ucs.qb_uom_conversion_factor_contextual_rows(ucf).get_sql() + assert ucs.FIELD_CONVERSION_BASIS in sql_strict + assert ucs.BASIS_CONTEXTUAL.casefold() in sql_strict.casefold() + assert ucs.FIELD_CONVERSION_BASIS in sql_ctx + assert ucs.BASIS_CONTEXTUAL.casefold() in sql_ctx.casefold()