直接customer_is_exchange 选项, 允许负数提交

This commit is contained in:
jerron
2026-10-17 22:26:02 +08:00
parent 000903755f
commit 57f2bd88f6
4 changed files with 150 additions and 10 deletions
+130
View File
@@ -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 放行,而是做精确条件判断
说明
- 本文档只是改动说明,不直接提交代码
- 当确认无误后,再回到代码层实际修改这些逻辑
+6 -5
View File
@@ -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"))
+11 -5
View File
@@ -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
@@ -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"]))