Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ repos:
additional_dependencies: ['flake8-bugbear']

- repo: https://github.com/agritheory/test_utils
rev: v1.20.2
rev: v1.22.0
hooks:
- id: update_pre_commit_config
- id: validate_frappe_project
Expand Down
50 changes: 50 additions & 0 deletions beam/beam/custom/inventory_dimension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"custom_fields": [
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": null,
"dt": "Inventory Dimension",
"fetch_if_empty": 0,
"fieldname": "custom_carry_forward",
"fieldtype": "Check",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"idx": 11,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "apply_to_all_doctypes",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Carry Forward",
"length": 0,
"module": "BEAM",
"name": "Inventory Dimension-custom_carry_forward",
"no_copy": 0,
"non_negative": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0
}
],
"doctype": "Inventory Dimension",
"property_setters": [],
"sync_on_migrate": 1
}
14 changes: 11 additions & 3 deletions beam/beam/handling_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import frappe
from erpnext.stock.stock_ledger import NegativeStockError
from frappe.utils import flt

from beam.beam.doctype.beam_settings.beam_settings import create_beam_settings
from beam.beam.scan import get_handling_unit
Expand Down Expand Up @@ -44,9 +45,16 @@ def generate_handling_units(doc, method=None):
in ("Material Transfer", "Send to Subcontractor", "Material Transfer for Manufacture")
and row.handling_unit
):
handling_unit = frappe.new_doc("Handling Unit")
handling_unit.save()
row.to_handling_unit = handling_unit.name
if not row.to_handling_unit:
handling_unit = frappe.new_doc("Handling Unit")
handling_unit.save()
row.to_handling_unit = handling_unit.name
elif row.to_handling_unit == row.handling_unit:
hu = get_handling_unit(row.handling_unit)
if hu and flt(hu.stock_qty) != flt(row.transfer_qty):
handling_unit = frappe.new_doc("Handling Unit")
handling_unit.save()
row.to_handling_unit = handling_unit.name
continue

if doc.doctype == "Subcontracting Receipt" and not row.handling_unit:
Expand Down
99 changes: 99 additions & 0 deletions beam/beam/inventory_dimension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Copyright (c) 2025, AgriTheory and contributors
# For license information, please see license.txt

import frappe

from beam.beam.scan.config import get_scan_doctypes


def setup_inventory_dimensions(inv_dim_dict_list: list[dict]) -> None:
"""Create Inventory Dimensions and perform supporting setup.

Each dictionary in the list should contain the following keys:
dimension_name - required
reference_document - required
target_fieldname - optional
apply_to_all_doctypes - optional, defaults to 1
custom_carry_forward - optional, defaults to 0

Side-effects per dimension:
- Relabels ``Source {Name}`` custom fields to ``{Name}``.
- Hides / marks read-only ``Target {Name}`` custom fields (keeps Purchase Invoice Item
variant visible and relabeled).
- Sets ``no_copy`` on all generated custom fields and marks them read-only on doctypes that
aren't scannable form targets (based on BEAM's ``get_scan_doctypes``).
"""
frappe.flags.in_test = True
for inv_dim_dict in inv_dim_dict_list:
name = inv_dim_dict["dimension_name"]
print(f"Setting up {name} Inventory Dimension")
if frappe.db.exists("Inventory Dimension", name):
continue

inv_dim_dict.setdefault("apply_to_all_doctypes", 1)
inv_dim = frappe.new_doc("Inventory Dimension")
inv_dim.update(inv_dim_dict)
inv_dim.save()

for custom_field in frappe.get_all("Custom Field", {"label": f"Source {name}"}):
frappe.set_value("Custom Field", custom_field, "label", name)

for custom_field in frappe.get_all("Custom Field", {"label": f"Target {name}"}, ["name", "dt"]):
if custom_field.dt == "Purchase Invoice Item":
frappe.set_value("Custom Field", custom_field, "label", name)
else:
frappe.set_value("Custom Field", custom_field, "read_only", 1)
frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)

frm_doctypes = get_scan_doctypes()["frm"]

for custom_field in frappe.get_all("Custom Field", {"label": name}, ["name", "dt"]):
frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)

if (
custom_field["dt"] not in frm_doctypes
and custom_field["dt"].replace(" Item", "").replace(" Detail", "") not in frm_doctypes
):
frappe.set_value("Custom Field", custom_field["name"], "read_only", 1)
frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)


_CARRY_FORWARD_CACHE_KEY = "beam:inv_dim_carry_forward"


def get_carry_forward_dims() -> list[dict]:
"""Cached list of Inventory Dimensions with carry_forward enabled. Cleared alongside
the scan cache when an Inventory Dimension is updated or deleted."""

def _fetch():
return [
d
for d in frappe.get_all(
"Inventory Dimension",
filters={"custom_carry_forward": 1},
fields=["source_fieldname", "target_fieldname"],
)
if d.source_fieldname and d.target_fieldname
]

return frappe.cache().get_value(_CARRY_FORWARD_CACHE_KEY, generator=_fetch)


def propagate_inventory_dimensions(doc, method=None):
"""before_submit hook for Stock Entry: copy each Inventory Dimension's source field to its
target field on rows where target is empty. Only fires for dimensions flagged with
`custom_carry_forward`, and only on rows that represent a real transfer (both s_warehouse
and t_warehouse set, row has a stock item code).

App-specific flows that explicitly set target (including explicit `None`) are unaffected
as long as their dimension is not flagged to carry forward."""
dims = get_carry_forward_dims()
if not dims:
return

for row in doc.items or []:
if not row.item_code or not row.s_warehouse or not row.t_warehouse:
continue
for dim in dims:
if row.get(dim.source_fieldname) and not row.get(dim.target_fieldname):
row.set(dim.target_fieldname, row.get(dim.source_fieldname))
39 changes: 34 additions & 5 deletions beam/beam/scan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@
from frappe.query_builder.functions import Coalesce


_INV_DIM_CACHE_KEY = "beam:inv_dim_source_fieldnames"


def get_inv_dim_source_fieldnames() -> list[str]:
"""Cached list of Inventory Dimension source_fieldnames (excluding Handling Unit). Cleared
whenever an Inventory Dimension is updated or deleted via `clear_inv_dim_cache` hook."""

def _fetch():
return frappe.get_all(
"Inventory Dimension",
filters={"name": ["!=", "Handling Unit"]},
pluck="source_fieldname",
)

return frappe.cache().get_value(_INV_DIM_CACHE_KEY, generator=_fetch)


def clear_inv_dim_cache(doc=None, method=None) -> None:
from beam.beam.inventory_dimension import _CARRY_FORWARD_CACHE_KEY

frappe.cache().delete_value(_INV_DIM_CACHE_KEY)
frappe.cache().delete_value(_CARRY_FORWARD_CACHE_KEY)


@frappe.whitelist()
def scan(
barcode: str,
Expand Down Expand Up @@ -93,13 +117,15 @@ def get_barcode_context(barcode: str) -> frappe._dict | None:
return None


def get_handling_unit(handling_unit: str, parent_doctype: str | None = None) -> frappe._dict:
def get_handling_unit(
handling_unit: str, parent_doctype: str | None = None, inv_dims: list | None = None
) -> frappe._dict:
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"handling_unit": handling_unit, "is_cancelled": 0},
fields=[
"item_code",
"SUM(actual_qty) AS stock_qty",
{"SUM": "actual_qty", "as": "stock_qty"},
"company",
"handling_unit",
"voucher_no",
Expand All @@ -109,7 +135,8 @@ def get_handling_unit(handling_unit: str, parent_doctype: str | None = None) ->
"voucher_type",
"voucher_detail_no",
"warehouse",
],
]
+ (inv_dims if inv_dims else []),
group_by="handling_unit",
order_by="posting_date DESC",
limit=1,
Expand Down Expand Up @@ -166,7 +193,7 @@ def get_stock_entry_item_details(doc: dict, item_code: str) -> frappe._dict:
if not stock_entry.stock_entry_type:
stock_entry.purpose = "Material Transfer"
stock_entry.set_stock_entry_type()
target = stock_entry.get_item_details({"item_code": item_code})
target = stock_entry.get_item_details(frappe._dict(item_code=item_code))
target.item_code = item_code
target.qty = 1 # only required for first scan, since quantity by default is zero
return target
Expand Down Expand Up @@ -225,7 +252,8 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di
)

if barcode_doc.doc.doctype == "Handling Unit":
hu_details = get_handling_unit(barcode_doc.doc.name, context.frm)
inv_dims = get_inv_dim_source_fieldnames()
hu_details = get_handling_unit(barcode_doc.doc.name, context.frm, inv_dims)
if context.frm == "Stock Entry":
target = get_stock_entry_item_details(context.doc, hu_details.item_code)
target.warehouse = hu_details.warehouse
Expand Down Expand Up @@ -265,6 +293,7 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di
"posting_datetime": hu_details.posting_datetime,
"dn_detail": hu_details.dn_detail,
}
| {i: hu_details[i] for i in inv_dims}
)
elif barcode_doc.doc.doctype == "Item":
if context.frm == "Stock Entry":
Expand Down
5 changes: 5 additions & 0 deletions beam/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@
# Hook on document methods and events

doc_events = {
"Inventory Dimension": {
"on_update": "beam.beam.scan.clear_inv_dim_cache",
"on_trash": "beam.beam.scan.clear_inv_dim_cache",
},
"Item": {
"validate": [
"beam.beam.barcodes.create_beam_barcode",
Expand Down Expand Up @@ -155,6 +159,7 @@
# "beam.beam.handling_unit.validate_handling_unit_overconsumption",
],
"before_submit": [
"beam.beam.inventory_dimension.propagate_inventory_dimensions",
"beam.beam.handling_unit.generate_handling_units",
"beam.beam.overrides.stock_entry.validate_items_with_handling_unit",
],
Expand Down
41 changes: 4 additions & 37 deletions beam/install.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,10 @@
# Copyright (c) 2025, AgriTheory and contributors
# For license information, please see license.txt

import frappe

from beam.beam.scan.config import get_scan_doctypes
from beam.beam.inventory_dimension import setup_inventory_dimensions


def after_install():
print("Setting up Handling Unit Inventory Dimension")
if frappe.db.exists("Inventory Dimension", "Handling Unit"):
return
huid = frappe.new_doc("Inventory Dimension")
huid.dimension_name = "Handling Unit"
huid.reference_document = "Handling Unit"
huid.apply_to_all_doctypes = 1
huid.save()

# re-label
for custom_field in frappe.get_all("Custom Field", {"label": "Source Handling Unit"}):
frappe.set_value("Custom Field", custom_field, "label", "Handling Unit")

# hide target fields
for custom_field in frappe.get_all(
"Custom Field", {"label": "Target Handling Unit"}, ["name", "dt"]
):
if custom_field.dt == "Purchase Invoice Item":
frappe.set_value("Custom Field", custom_field, "label", "Handling Unit")
else:
frappe.set_value("Custom Field", custom_field, "read_only", 1)
frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)

frm_doctypes = get_scan_doctypes()["frm"]

for custom_field in frappe.get_all("Custom Field", {"label": "Handling Unit"}, ["name", "dt"]):
frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)

if (
custom_field["dt"] not in frm_doctypes
and custom_field["dt"].replace(" Item", "").replace(" Detail", "") not in frm_doctypes
):
frappe.set_value("Custom Field", custom_field["name"], "read_only", 1)
frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)
setup_inventory_dimensions(
[{"dimension_name": "Handling Unit", "reference_document": "Handling Unit"}]
)
8 changes: 7 additions & 1 deletion beam/tests/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,10 +477,11 @@ def create_production_plan(settings, prod_plan_from_doc):
pp.get_mr_items()
for item in pp.po_items:
item.planned_start_date = settings.day
pp.for_warehouse = "Storeroom - APC"
pp.sub_assembly_warehouse = "Kitchen - APC"
pp.get_sub_assembly_items()
for item in pp.sub_assembly_items:
item.schedule_date = settings.day
pp.for_warehouse = "Storeroom - APC"
raw_materials = get_items_for_material_requests(
pp.as_dict(), warehouses=None, get_parent_warehouse_data=None
)
Expand Down Expand Up @@ -551,6 +552,11 @@ def create_production_plan(settings, prod_plan_from_doc):
wo.required_items = sorted(wo.required_items, key=lambda x: x.get("item_code"))
for idx, w in enumerate(wo.required_items, start=1):
w.idx = idx
default_warehouse = frappe.db.get_value(
"Item Default", {"parent": w.item_code}, "default_warehouse"
)
if default_warehouse:
w.source_warehouse = default_warehouse
wo.save()
wo.submit()
frappe.db.set_value("Work Order", wo.name, "creation", start_time)
Expand Down
Loading
Loading