refactor(scrap): 按单执行校验改用 SKU 主键匹配
架构要求以 SKU 为唯一校验键,原实现按 (source_table, stock_id) 匹配。
已核验数据前提:SKU 在同一库存表内唯一(0 重复),且不存在跨表重名
(stock_buy / stock_semi / stock_product 交叉 0 冲突),故 SKU 可安全
作为跨来源标识。
改动:
· 新增 _match_key():SKU 非空时以 'sku:<SKU>' 为键;个别历史库存行
SKU 为空,回退 'row:<table>#<id>',前缀区分确保绝不串键;
· _build_approved_index() 按 SKU 聚合,累加批准量并保留各行的
source_table + stock_id(rows 列表);
· 校验拆为两步并给出明确文案:SKU 是否在批准明细内、同 SKU 累计量
是否超批准量;
· ★ 扣减改为按批准单配对的 source_table + stock_id 定位库存行加锁,
不再信任前端传来的 stock_id —— 实测前端传伪造 stock_id=99999 时,
仍从批准单指定的库存行扣减,台账同样记录批准单的 stock_id;
· 同一 SKU 在批准单占多行时,按批准顺序依次分配扣减量。
实测 7 项校验用例 + 多行分配 / 批准行已删除 / 可用不足 均符合预期。
This commit is contained in:
@ -201,39 +201,75 @@ class ScrapApprovalService:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _norm_sku(sku):
|
||||
"""SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。"""
|
||||
return str(sku or '').strip()
|
||||
|
||||
@staticmethod
|
||||
def _match_key(source_table, stock_id, sku):
|
||||
"""
|
||||
★ 扫码匹配键 —— SKU 优先。
|
||||
|
||||
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
|
||||
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
|
||||
跨来源的稳定标识。
|
||||
|
||||
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
|
||||
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
|
||||
空 SKU 行绝不会与任何正常 SKU 串键。
|
||||
"""
|
||||
s = ScrapApprovalService._norm_sku(sku)
|
||||
if s:
|
||||
return ('sku', s)
|
||||
return ('row', f"{source_table}#{stock_id}")
|
||||
|
||||
@staticmethod
|
||||
def _build_approved_index(items):
|
||||
"""把申请单明细整理成 (source_table, stock_id) -> 批准数量 的索引"""
|
||||
"""
|
||||
申请单明细 → {匹配键: 批准信息}
|
||||
|
||||
每项保留其 source_table + stock_id 清单(rows),执行时据此定位到
|
||||
批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行,
|
||||
数量累加、rows 按批准顺序保留。
|
||||
"""
|
||||
index = {}
|
||||
for it in items:
|
||||
sid = ScrapApprovalService._to_int(it.get('stock_id'))
|
||||
st = str(it.get('source_table') or '').strip()
|
||||
if not st or sid is None:
|
||||
continue
|
||||
entry = index.setdefault((st, sid), {
|
||||
key = ScrapApprovalService._match_key(st, sid, it.get('sku'))
|
||||
entry = index.setdefault(key, {
|
||||
'qty': 0.0,
|
||||
'label': it.get('name') or it.get('sku') or f"{st}#{sid}",
|
||||
'label': ScrapApprovalService._norm_sku(it.get('sku'))
|
||||
or it.get('name') or f"{st}#{sid}",
|
||||
'rows': [],
|
||||
})
|
||||
entry['qty'] += float(it.get('scrap_qty') or 0)
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
entry['qty'] += qty
|
||||
entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty})
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _build_scanned_index(scanned_items, models):
|
||||
"""把前端实际扫码明细整理成 (source_table, stock_id) -> 累计扫码数量 的索引"""
|
||||
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
|
||||
index = {}
|
||||
for idx, s in enumerate(scanned_items):
|
||||
sku = ScrapApprovalService._norm_sku(s.get('sku'))
|
||||
st = str(s.get('source_table') or '').strip()
|
||||
sid = ScrapApprovalService._to_int(s.get('stock_id'))
|
||||
if not st or sid is None:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码明细缺少有效的 source_table / stock_id")
|
||||
if st not in models:
|
||||
if not sku and (not st or sid is None):
|
||||
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
|
||||
if st and st not in models:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st}")
|
||||
qty = float(s.get('quantity') or 0)
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
|
||||
entry = index.setdefault((st, sid), {
|
||||
key = ScrapApprovalService._match_key(st, sid, sku)
|
||||
entry = index.setdefault(key, {
|
||||
'qty': 0.0,
|
||||
'label': s.get('name') or s.get('sku') or f"{st}#{sid}",
|
||||
'label': sku or s.get('name') or f"{st}#{sid}",
|
||||
})
|
||||
entry['qty'] += qty
|
||||
return index
|
||||
@ -241,11 +277,13 @@ class ScrapApprovalService:
|
||||
@staticmethod
|
||||
def execute(request_id, operator_name='System', scanned_items=None):
|
||||
"""
|
||||
按单执行报废:以「实际扫码明细」为准扣减库存。
|
||||
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
|
||||
|
||||
scanned_items: [{'source_table', 'stock_id', 'quantity', 'name'/'sku'(可选)}]
|
||||
· 必须是申请单 items_json 的子集(允许少扫,不允许扫单外物品)
|
||||
· 同一物品多次扫码会累加,累计不得超过批准数量
|
||||
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
|
||||
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
|
||||
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
|
||||
· 允许合法子集(少扫 = 本次不报废该行);
|
||||
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
|
||||
"""
|
||||
models = _stock_models()
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
@ -265,50 +303,64 @@ class ScrapApprovalService:
|
||||
approved = ScrapApprovalService._build_approved_index(approved_items)
|
||||
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
|
||||
|
||||
# ★ 校验:扫码明细必须落在批准明细内,且累计量不得超批准量
|
||||
# ★ 校验一:扫码 SKU 必须在批准明细内
|
||||
for key, acc in scanned.items():
|
||||
appr = approved.get(key)
|
||||
if not appr:
|
||||
if key not in approved:
|
||||
raise ValueError(
|
||||
f"扫码物品【{acc['label']}】不在该报废申请单的明细中,禁止报废"
|
||||
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
if acc['qty'] > appr['qty']:
|
||||
raise ValueError(
|
||||
f"【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 扣减:只扣实际扫码量(未扫到的批准项视为本次不报废)
|
||||
for (st, sid), acc in scanned.items():
|
||||
model = models[st]
|
||||
row = model.query.with_for_update().get(sid)
|
||||
if not row:
|
||||
raise ValueError(f"库存记录已不存在({acc['label']})")
|
||||
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
remaining = acc['qty']
|
||||
|
||||
qty = acc['qty']
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||||
if qty > avail:
|
||||
raise ValueError(f"库存 {acc['label']} 可用不足(剩 {avail}),无法报废 {qty}")
|
||||
if qty > stock:
|
||||
raise ValueError(f"库存 {acc['label']} 实物不足(剩 {stock}),无法报废 {qty}")
|
||||
for ref in appr['rows']:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, ref['qty'])
|
||||
if take <= 0:
|
||||
continue
|
||||
|
||||
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
|
||||
row.available_quantity = avail - qty
|
||||
row.stock_quantity = stock - qty
|
||||
db.session.flush()
|
||||
st, sid = ref['source_table'], ref['stock_id']
|
||||
row = models[st].query.with_for_update().get(sid)
|
||||
if not row:
|
||||
raise ValueError(f"库存记录已不存在({acc['label']})")
|
||||
|
||||
# 写报废流水(台账)
|
||||
db.session.add(TransScrap(
|
||||
sku=getattr(row, 'sku', '') or '',
|
||||
source_table=st,
|
||||
stock_id=sid,
|
||||
quantity=qty,
|
||||
reason=req.remark or '',
|
||||
operator_name=operator_name,
|
||||
approver_name=ScrapApproval._user_name(req.actual_approver_id),
|
||||
approval_status='executed',
|
||||
scrap_request_no=req.request_no,
|
||||
))
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||||
if take > avail:
|
||||
raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}")
|
||||
if take > stock:
|
||||
raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}")
|
||||
|
||||
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
|
||||
row.available_quantity = avail - take
|
||||
row.stock_quantity = stock - take
|
||||
db.session.flush()
|
||||
|
||||
# 写报废流水(台账)
|
||||
db.session.add(TransScrap(
|
||||
sku=getattr(row, 'sku', '') or acc['label'],
|
||||
source_table=st,
|
||||
stock_id=sid,
|
||||
quantity=take,
|
||||
reason=req.remark or '',
|
||||
operator_name=operator_name,
|
||||
approver_name=ScrapApproval._user_name(req.actual_approver_id),
|
||||
approval_status='executed',
|
||||
scrap_request_no=req.request_no,
|
||||
))
|
||||
|
||||
remaining -= take
|
||||
|
||||
req.status = 3
|
||||
req.executed_at = _beijing()
|
||||
|
||||
Reference in New Issue
Block a user