feat(dn): 添加 is_exchange 字段支持换货场景库存更新

问题描述:
- DN 收退货时,明细行有正负数量导致总数量=0时,提交后库存未更新
- 原因:代码中多处过滤条件会跳过 actual_qty=0 的记录

解决方案:
1. 在 Delivery Note DocType 中添加内置字段 is_exchange (Check)
2. 替换所有 custom_is_exchange 自定义字段引用为 is_exchange
3. 修改库存更新逻辑,在 is_exchange=1 时允许数量为0的明细行更新库存
4. 在 SLE 生成时传递 is_exchange 字段到 Stock Ledger Entry

修改文件:
- delivery_note.json: 添加 is_exchange 字段定义
- delivery_note.py: 替换字段引用
- selling_controller.py: 修改库存更新条件并传递 is_exchange 到 SLE
- stock_ledger.py: 修改 SLE 创建和过账条件
- accounts_controller.py: 替换字段引用
- status_updater.py: 替换字段引用

影响范围:
- 仅当 DN 的 is_exchange=1 时生效
- 不影响其他单据类型的校验逻辑
- 正常 DN (is_exchange=0) 保持原有行为
This commit is contained in:
jerron
2026-10-18 09:29:33 +08:00
parent 855cec25f9
commit 9c6812eb01
6 changed files with 42 additions and 7 deletions
+1 -1
View File
@@ -249,7 +249,7 @@ class AccountsController(TransactionBase):
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
return cint(self.is_exchange) == 1
if self.doctype == "Sales Order":
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
+17 -2
View File
@@ -656,7 +656,16 @@ class SellingController(StockController):
sl_entries = []
# Loop over items and packed items table
for d in self.get_item_list():
if frappe.get_cached_value("Item", d.item_code, "is_stock_item") == 1 and flt(d.qty):
item_is_stock = frappe.get_cached_value("Item", d.item_code, "is_stock_item") == 1
qty_value = flt(d.qty)
# 对于换货(is_exchange=1)或退货(is_return=1)场景,即使单行数量为0也要处理
is_exchange_or_return = (
(self.doctype == "Delivery Note" and cint(self.is_exchange) == 1)
or self.is_return
)
if item_is_stock and (qty_value != 0 or is_exchange_or_return):
if flt(d.conversion_factor) == 0.0:
d.conversion_factor = (
get_conversion_factor(d.item_code, d.uom).get("conversion_factor") or 1.0
@@ -708,6 +717,7 @@ class SellingController(StockController):
"incoming_rate": item_row.incoming_rate,
"recalculate_rate": cint(self.is_return),
"serial_and_batch_bundle": serial_and_batch_bundle,
"is_exchange": cint(self.is_exchange),
},
)
if item_row.target_warehouse and not cint(self.is_return):
@@ -717,7 +727,12 @@ class SellingController(StockController):
def get_sle_for_target_warehouse(self, item_row):
sle = self.get_sl_entries(
item_row, {"actual_qty": flt(item_row.qty), "warehouse": item_row.target_warehouse}
item_row,
{
"actual_qty": flt(item_row.qty),
"warehouse": item_row.target_warehouse,
"is_exchange": cint(self.is_exchange),
},
)
if self.docstatus == 1:
+1 -1
View File
@@ -288,7 +288,7 @@ class StatusUpdater(Document):
# get unique transactions to update
for d in self.get_all_children():
is_dn_exchange = self.doctype == "Delivery Note" and cint(getattr(self, "custom_is_exchange", 0)) == 1
is_dn_exchange = self.doctype == "Delivery Note" and cint(self.is_exchange) == 1
if not is_dn_exchange:
if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"):
frappe.throw(
@@ -20,6 +20,7 @@
"company",
"amended_from",
"is_return",
"is_exchange",
"issue_credit_note",
"return_against",
"accounting_dimensions_section",
@@ -285,6 +286,13 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "is_exchange",
"fieldtype": "Check",
"label": "Is Exchange",
"description": "Enable exchange scenario where quantities can be mixed (positive and negative)"
},
{
"default": "0",
"depends_on": "is_return",
@@ -411,7 +411,7 @@ class DeliveryNote(SellingController):
def validate_warehouse(self):
super().validate_warehouse()
if self.doctype == "Delivery Note" and cint(getattr(self, "custom_is_exchange", 0)) == 1:
if self.doctype == "Delivery Note" and cint(self.is_exchange) == 1:
return
for d in self.get_item_list():
+14 -2
View File
@@ -121,7 +121,13 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
)
sle["outgoing_rate"] = 0.0
if sle.get("actual_qty") or sle.get("voucher_type") == "Stock Reconciliation":
# 对于换货(is_exchange=1)或退货(is_return=1)场景,即使actual_qty为0也要创建记录
is_exchange_or_return = (
sle.get("voucher_type") == "Delivery Note"
and (cint(sle.get("is_exchange")) == 1 or sle.get("is_return"))
)
if sle.get("actual_qty") or sle.get("voucher_type") == "Stock Reconciliation" or is_exchange_or_return:
sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher)
args = sle_doc.as_dict()
@@ -148,7 +154,13 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_voucher=False, cancelled=False):
if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation":
# 对于换货(is_exchange=1)或退货(is_return=1)场景,即使actual_qty为0也要重新过账
is_exchange_or_return = (
args.get("voucher_type") == "Delivery Note"
and (cint(args.get("is_exchange")) == 1 or args.get("is_return"))
)
if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation" or is_exchange_or_return:
if not args.get("posting_date"):
args["posting_date"] = nowdate()