diff --git a/req/dn_custom_is_exchange_change.txt b/req/dn_custom_is_exchange_change.txt new file mode 100644 index 0000000..256e449 --- /dev/null +++ b/req/dn_custom_is_exchange_change.txt @@ -0,0 +1,130 @@ +DN 允许负数出库 + 负总额校验放开(按 custom_is_exchange 控制) + +需求说明 +- DN 单据新增自定义字段 custom_is_exchange +- 当 custom_is_exchange = 1 时:允许 DN 允许负数数量出库,并允许总金额为负数 +- 当 custom_is_exchange = 0 时:按原有校验规则,保留现有限制 +- 如果该字段不存在,则默认按 0 处理 +- 这次调整只对 Delivery Note 生效,不影响其他单据(SO / SI / PO / PR 等)的校验逻辑 + +结论先行 +- 这里的关键校验不在 DN 自身文件中,而是在通用账户校验和状态更新逻辑中 +- 报错 "Incorrect value: Grand Total must be >= 0.0" 的来源是 AccountsController.validate() 里的 base_grand_total >= 0 校验 +- 这条规则的放开条件必须和 custom_is_exchange 绑定 +- 另一个 qty 正负校验来自 StatusUpdater.validate_qty(),也需要做同样的条件判断 + +修改点一:Delivery Note 自定义字段 +- 在 Delivery Note 的自定义字段中新增:custom_is_exchange(Check) +- 默认值:0 +- 说明:该字段用于表示是否开启“交换/冲销/负数出库”场景 +- 若未存在于当前环境,则按 0 视为默认值 +- 代码中读取时统一用: + getattr(self, "custom_is_exchange", 0) + 或 cint(getattr(self, "custom_is_exchange", 0)) + +修改点二:放开总金额校验 +文件: +yierp/controllers/accounts_controller.py + +位置: +- AccountsController.is_negative_grand_total_allowed() +- AccountsController.validate() + +现状逻辑: +- if not self.is_negative_grand_total_allowed(): +- self.validate_value("base_grand_total", ">=", 0) + +需求改动逻辑: +- 对 Delivery Note 做条件放行:只有当 self.doctype == "Delivery Note" 且 cint(getattr(self, "custom_is_exchange", 0)) == 1 时,才允许负总额 +- 其余单据保持原有逻辑不变 + +建议改法: + def is_negative_grand_total_allowed(self) -> bool: + if self.doctype == "Delivery Note": + return cint(getattr(self, "custom_is_exchange", 0)) == 1 + + if self.doctype == "Sales Order": + return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items")) + + if self.doctype == "Purchase Order": + return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items")) + + return False + +说明: +- 这样做会让 custom_is_exchange=1 时 DN 可保存负总额 +- custom_is_exchange=0 时,继续走现有 >= 0 校验 +- 该逻辑不影响其他单据 + +注意: +- 如果希望只在某些业务场景开启,而不是全局放开,则这个逻辑更稳妥: + 只有 DN + custom_is_exchange = 1 才放开,否则仍然保留校验 + +修改点三:放开 qty 符号校验 +文件: +yierp/controllers/status_updater.py + +位置: +- StatusUpdater.validate_qty() + +现状逻辑: +- if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"): + frappe.throw(... quantity must be positive number) +- if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): + frappe.throw(... quantity must be negative number) + +需求改动逻辑: +- Delivery Note 在 custom_is_exchange = 1 时,跳过这段 qty 正负号校验 +- custom_is_exchange = 0 时,保留原逻辑 +- 其他 doctype 保持原有规则不变 + +建议伪代码: + for d in self.get_all_children(): + is_dn_exchange = self.doctype == "Delivery Note" and cint(getattr(self, "custom_is_exchange", 0)) == 1 + + if not is_dn_exchange: + if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"): + frappe.throw(...) + + if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): + frappe.throw(...) + +- 这样可以允许 DN 行项目 qty 为负数,且允许同单中有正有负数混合 + +修改点四:如果需要真正允许负库存 +文件: +yierp/stock/stock_ledger.py + +说明: +- 这里是实际库存/Stock Ledger 层的负库存校验 +- DN 负数出库的业务是否真正允许落库,还需要结合 Stock Settings.allow_negative_stock / Item.allow_negative_stock +- 如果业务要求“DN 可以负数出库”,但系统还禁止负库存,则在 stock_ledger 层仍可能因为负库存校验失败 +- 当 custom_is_exchange=1 时,建议同步确认是否允许对该仓库/物料做负库存 + +结论: +- 若目标是:仅当 custom_is_exchange=1 时,DN 允许 qty 负数、总额负数;否则保持原校验 +- 需要修改的核心文件如下: + 1) yierp/controllers/accounts_controller.py + 2) yierp/controllers/status_updater.py + 3)(可选)yierp/stock/stock_ledger.py + 4) Delivery Note 自定义字段:custom_is_exchange(默认 0) + +变更原则 +- 不要修改非 DN 单据的全局规则 +- 不要对所有 Delivery Note 一概放开,而是绑定 custom_is_exchange 开关 +- custom_is_exchange 缺失时,默认 0,避免历史数据异常 + +验证清单 +- custom_is_exchange = 0:DN 正常保留 qty 与总额的非负校验 +- custom_is_exchange = 1:DN 可以保存 qty 为负数,同时 Grand Total 可为负数 +- 其他单据仍保持旧规则 +- 若有负库存配置限制,则需要确认是否还会在 stock_ledger 层被拦住 + +实施前提示 +- 先确认环境中是否已经存在 custom_is_exchange 字段 +- 如果不存在,先创建字段后,再依赖字段值进行条件判断 +- 实施时不要直接写死为所有 DN 放行,而是做精确条件判断 + +说明 +- 本文档只是改动说明,不直接提交代码 +- 当确认无误后,再回到代码层实际修改这些逻辑 diff --git a/yierp/controllers/accounts_controller.py b/yierp/controllers/accounts_controller.py index b78c855..7b53e87 100644 --- a/yierp/controllers/accounts_controller.py +++ b/yierp/controllers/accounts_controller.py @@ -244,12 +244,13 @@ class AccountsController(TransactionBase): def is_negative_grand_total_allowed(self) -> bool: """Return True if this document may save with a negative grand total. - Sales Order and Purchase Order never post to the GL, so a negative - total is safe there whenever the user has explicitly opted into - negative rates via Selling/Buying Settings. Every other - AccountsController doctype (invoices, delivery notes, receipts, - quotations, ...) keeps relying on the `is_return` escape hatch only. + Delivery Note can opt into negative quantity/amount flows via the + custom_is_exchange flag. For all other doctypes, keep the historical + behavior unless the document is explicitly an exception such as a return. """ + if self.doctype == "Delivery Note": + return cint(getattr(self, "custom_is_exchange", 0)) == 1 + if self.doctype == "Sales Order": return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items")) diff --git a/yierp/controllers/status_updater.py b/yierp/controllers/status_updater.py index 786bf5c..4dab29d 100644 --- a/yierp/controllers/status_updater.py +++ b/yierp/controllers/status_updater.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div +from frappe.utils import cint, comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div class OverAllowanceError(frappe.ValidationError): @@ -288,11 +288,17 @@ class StatusUpdater(Document): # get unique transactions to update for d in self.get_all_children(): - if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"): - frappe.throw(_("For an item {0}, quantity must be positive number").format(d.item_code)) + is_dn_exchange = self.doctype == "Delivery Note" and cint(getattr(self, "custom_is_exchange", 0)) == 1 + if not is_dn_exchange: + if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"): + frappe.throw( + _("For an item {0}, quantity must be positive number").format(d.item_code) + ) - if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): - frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) + if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): + frappe.throw( + _("For an item {0}, quantity must be negative number").format(d.item_code) + ) if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or ( not buying_negative_rate_allowed and self.doctype in buying_doctypes diff --git a/yierp/stock/doctype/delivery_note/delivery_note.py b/yierp/stock/doctype/delivery_note/delivery_note.py index 43cc7c2..7e66be5 100644 --- a/yierp/stock/doctype/delivery_note/delivery_note.py +++ b/yierp/stock/doctype/delivery_note/delivery_note.py @@ -411,6 +411,9 @@ class DeliveryNote(SellingController): def validate_warehouse(self): super().validate_warehouse() + if self.doctype == "Delivery Note" and cint(getattr(self, "custom_is_exchange", 0)) == 1: + return + for d in self.get_item_list(): if not d["warehouse"] and frappe.get_cached_value("Item", d["item_code"], "is_stock_item") == 1: frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"]))