feat(scrap): 报废全链路收口到审批流
系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True),
但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路
全部收口,只保留「申请 → 审批 → 执行」一条写入路径。
【删除】直接报废 POST /api/v1/scrap
同时移除 ScrapService.process_scrap()。该路径的一个连带影响是
「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是
repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、
trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_*
来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行
扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。
权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为
scrap_apply/scrap_execute 的授权来源。
【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request
TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。
沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为
**直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。
【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request
申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的
既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器
按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。
【服务层】ScrapApprovalService 接入来源适配层
- submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性
校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单)
- _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄**
而非放宽,扫码通道永远不接纳 trans_* 来源
- execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建),
auto 项按批准量执行。签名与调用契约不变。
- _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时
同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧
同源、键仍匹配,对既有流程零行为变更。
【修复】approve() 的 fail-open
原实现 `if user_entries and str(operator_id) not in user_entries` ——
allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。
这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。
已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods
在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。
【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除
(来源差异全部收敛到适配层);trans_service / inventory_reservation 中
指向已删方法的注释已更新指向 BorrowScrapAdapter。
This commit is contained in:
@ -233,9 +233,19 @@ class RepairInboundService:
|
||||
if status == '已出库':
|
||||
raise ValueError("禁止手动变更为已出库状态,请通过扫码出库模块进行操作")
|
||||
|
||||
# 禁止手动变更为报废转出状态,必须通过扫码报废模块进行
|
||||
# 禁止手动变更为报废转出状态。
|
||||
#
|
||||
# ★ 原文案是「请前往报废管理进行扫码操作」,但那条路已不存在:
|
||||
# 维修件报废原本由「直接报废」接口(POST /api/v1/scrap)的
|
||||
# trans_repair 分支实现,而该接口绕过审批、与系统自陈的
|
||||
# 「报废一律需审批」规则冲突,已整体移除。审批流的扫码校验
|
||||
# 也从来拒绝 trans_* 来源,故这条链路早已不通。
|
||||
#
|
||||
# 维修件报废的正式支持(含数量语义、审批接入)需单独排期。
|
||||
if status == '报废转出':
|
||||
raise ValueError("禁止手动变更为报废状态,请前往报废管理进行扫码操作")
|
||||
raise ValueError(
|
||||
"禁止手动变更为报废状态。维修件报废暂未开放,请联系管理员"
|
||||
)
|
||||
|
||||
repair = TransRepair.query.get(id)
|
||||
if not repair:
|
||||
|
||||
@ -498,9 +498,10 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
|
||||
实物数不动。理由:
|
||||
· 归还只加 available(现有实现),若借出扣了 stock,
|
||||
一借一还后 stock 会永久少一份;
|
||||
· 借库转报废(scrap_borrow)在确认损失时扣 stock,
|
||||
其注释明确假设「可用库存已在借出时冻结」,
|
||||
若借出已扣 stock 会重复扣减。
|
||||
· 借库转报废在确认损失时扣 stock,其实现明确假设
|
||||
「可用库存已在借出时冻结」,若借出已扣 stock
|
||||
会重复扣减。(该实现现已迁入
|
||||
app/services/scrap_sources.py 的 BorrowScrapAdapter)
|
||||
语义:stock = 账面实物(含借出未还),
|
||||
available = 实际可取用。
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ from sqlalchemy import func
|
||||
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -30,22 +29,10 @@ def _beijing():
|
||||
# =============================================================================
|
||||
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
||||
|
||||
|
||||
STOCK_MODELS = {}
|
||||
|
||||
|
||||
def _stock_models():
|
||||
"""延迟导入三张实物库存表,避免循环依赖"""
|
||||
if not STOCK_MODELS:
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
STOCK_MODELS.update({
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct,
|
||||
})
|
||||
return STOCK_MODELS
|
||||
# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到
|
||||
# app/services/scrap_sources.py 的来源适配层(它复用
|
||||
# inventory_reservation.stock_model_map(),避免第四份重复定义),
|
||||
# 本模块不再直接持有库存表映射。
|
||||
|
||||
|
||||
class ScrapApprovalService:
|
||||
@ -72,44 +59,54 @@ class ScrapApprovalService:
|
||||
if not items:
|
||||
raise ValueError("报废明细不能为空")
|
||||
|
||||
models = _stock_models()
|
||||
# ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的
|
||||
# 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。
|
||||
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
|
||||
# 各走直报接口绕过审批。详见 app/services/scrap_sources.py 模块头。
|
||||
from app.services.scrap_sources import get_adapter
|
||||
|
||||
normalized = []
|
||||
for idx, it in enumerate(items):
|
||||
st = (it.get('source_table') or '').strip()
|
||||
model = models.get(st)
|
||||
sid = it.get('stock_id')
|
||||
if not model or not sid:
|
||||
raise ValueError(f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准库存行)")
|
||||
adapter = get_adapter(st)
|
||||
if not adapter or not sid:
|
||||
raise ValueError(
|
||||
f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准实物)"
|
||||
)
|
||||
try:
|
||||
sid = int(sid)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"第 {idx + 1} 条 stock_id 无效")
|
||||
row = model.query.get(sid)
|
||||
row = adapter.load(sid)
|
||||
if not row:
|
||||
raise ValueError(f"第 {idx + 1} 条对应的库存记录不存在")
|
||||
raise ValueError(f"第 {idx + 1} 条对应的{adapter.label}记录不存在")
|
||||
try:
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量无效")
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量必须大于 0")
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
if qty > avail:
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量({qty})超过可用库存({avail})")
|
||||
|
||||
base = getattr(row, 'base', None)
|
||||
normalized.append({
|
||||
'source_table': st,
|
||||
'stock_id': sid,
|
||||
'base_id': getattr(row, 'base_id', None),
|
||||
'sku': getattr(row, 'sku', '') or '',
|
||||
'name': (base.name if base else '') or it.get('name') or '',
|
||||
'spec_model': (base.spec_model if base else '') or it.get('spec_model') or '',
|
||||
'location': getattr(row, 'warehouse_location', '') or '',
|
||||
'batch_number': getattr(row, 'batch_number', '') or getattr(row, 'serial_number', '') or '',
|
||||
'scrap_qty': qty,
|
||||
'available_at_apply': avail,
|
||||
})
|
||||
cap = adapter.cap(row)
|
||||
if qty > cap:
|
||||
raise ValueError(
|
||||
f"第 {idx + 1} 条报废数量({qty})超过{adapter.cap_label}({cap})"
|
||||
)
|
||||
# 提交期的额外约束(如已归还的借用记录不得再报废)
|
||||
adapter.submit_guard(row, qty)
|
||||
|
||||
normalized.append(adapter.snapshot(row, qty, it))
|
||||
|
||||
# ★ 整单执行模式必须一致。
|
||||
# 现有三个提交入口(报废申请页 / 借还记录页 / 不良品看板)各自只提交
|
||||
# 单一来源,UI 上产不出混合单;此处显式拒绝非法构造,让 execute() 的
|
||||
# 分流逻辑保持简单可验证。
|
||||
modes = {n.get('scrap_mode') for n in normalized}
|
||||
if len(modes) > 1:
|
||||
raise ValueError(
|
||||
"同一张报废申请单不能混合「需扫码」与「免扫码」两类来源,请分开提交"
|
||||
)
|
||||
|
||||
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
|
||||
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
|
||||
@ -173,10 +170,19 @@ class ScrapApprovalService:
|
||||
if req.status != 0:
|
||||
raise ValueError("当前状态不允许审批(仅待审批可操作)")
|
||||
|
||||
# 仅被指定的审批人可操作
|
||||
# 仅被指定的审批人可操作。
|
||||
#
|
||||
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
|
||||
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
|
||||
# 为空列表,条件短路为假,**任何登录用户都能审批**。这与本模块自陈的
|
||||
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
|
||||
# 等于没有审批。现改为无名单即拒绝。
|
||||
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
|
||||
allowed = req.get_allowed_approvers() or []
|
||||
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
|
||||
if user_entries and str(operator_id) not in user_entries:
|
||||
if not user_entries:
|
||||
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理")
|
||||
if str(operator_id) not in user_entries:
|
||||
raise ValueError("只有被指定的审批人可以审批该申请")
|
||||
|
||||
if action == 'approve':
|
||||
@ -262,20 +268,25 @@ class ScrapApprovalService:
|
||||
@staticmethod
|
||||
def _match_key(source_table, stock_id, sku):
|
||||
"""
|
||||
★ 扫码匹配键 —— SKU 优先。
|
||||
★ 扫码匹配键 —— SKU 优先,**且必须带来源表**。
|
||||
|
||||
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
|
||||
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
|
||||
跨来源的稳定标识。
|
||||
原实现返回 ('sku', s),其前提是「SKU 在三张库存表内唯一且跨表无重名」。
|
||||
引入逆向物流来源后这个前提不再成立:在管不良品台账的 SKU 是从
|
||||
原库存行**复制**的(退回时 sku=getattr(stock_row, 'sku', '')),
|
||||
故 `trans_defective_goods#N` 与其源 `stock_buy#M` 的 SKU **必然相同**。
|
||||
若同一张报废单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。
|
||||
|
||||
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
|
||||
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
|
||||
空 SKU 行绝不会与任何正常 SKU 串键。
|
||||
加入 source_table 后,纯库存单两侧同源、键仍匹配(对既有流程零行为
|
||||
变更),而跨来源的同 SKU 行彻底隔离。
|
||||
|
||||
空 SKU 的历史行仍回退为 row 复合键,两种键的首元素不同
|
||||
('sku' / 'row'),不会互相串键。
|
||||
"""
|
||||
st = (source_table or '').strip()
|
||||
s = ScrapApprovalService._norm_sku(sku)
|
||||
if s:
|
||||
return ('sku', s)
|
||||
return ('row', f"{source_table}#{stock_id}")
|
||||
return ('sku', st, s)
|
||||
return ('row', f"{st}#{stock_id}")
|
||||
|
||||
@staticmethod
|
||||
def _build_approved_index(items):
|
||||
@ -305,8 +316,10 @@ class ScrapApprovalService:
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _build_scanned_index(scanned_items, models):
|
||||
def _build_scanned_index(scanned_items):
|
||||
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
|
||||
from app.services.scrap_sources import is_scan_source
|
||||
|
||||
index = {}
|
||||
for idx, s in enumerate(scanned_items):
|
||||
sku = ScrapApprovalService._norm_sku(s.get('sku'))
|
||||
@ -314,8 +327,14 @@ class ScrapApprovalService:
|
||||
sid = ScrapApprovalService._to_int(s.get('stock_id'))
|
||||
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}")
|
||||
# ★ 语义收窄而非放宽:扫码通道只接纳明确声明为 scan 的来源。
|
||||
# 免扫码来源(借出未还)与未知来源一律拒绝 —— 后者能挡住
|
||||
# 「扫码扫得到、执行却拒绝」的历史错配(trans_repair 曾如此)。
|
||||
if st and not is_scan_source(st):
|
||||
raise ValueError(
|
||||
f"第 {idx + 1} 条扫码来源不支持扫码提交:{st}"
|
||||
f"(该来源为免扫码执行来源,或来源非法)"
|
||||
)
|
||||
qty = float(s.get('quantity') or 0)
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
|
||||
@ -330,15 +349,24 @@ class ScrapApprovalService:
|
||||
@staticmethod
|
||||
def execute(request_id, operator_name='System', scanned_items=None):
|
||||
"""
|
||||
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
|
||||
按单执行报废:按来源的执行模式分流处理。
|
||||
|
||||
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
|
||||
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
|
||||
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
|
||||
· 允许合法子集(少扫 = 本次不报废该行);
|
||||
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
|
||||
仅承载「实扫到的实物」。免扫码来源(借出未还)**不经过**这里,
|
||||
由后端按批准量直接执行 —— 调用契约与改造前完全一致。
|
||||
|
||||
scan 来源(库存行 / 在管不良品):
|
||||
· 以 SKU + 来源表为主校验键(来源表用于隔离同 SKU 的跨来源行);
|
||||
· 同一键多次扫码累加,累计不得超过批准总量;
|
||||
· 允许合法子集(少扫 = 本次不报废该行)。
|
||||
|
||||
auto 来源(借出未还):
|
||||
· 按 items_json 中的 scrap_qty 全量执行,不参与扫码匹配。
|
||||
"""
|
||||
models = _stock_models()
|
||||
from app.services.scrap_sources import (
|
||||
get_adapter, SCRAP_MODE_AUTO, SCRAP_MODE_SCAN,
|
||||
)
|
||||
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
if not req:
|
||||
raise ValueError("报废申请不存在")
|
||||
@ -349,29 +377,54 @@ class ScrapApprovalService:
|
||||
if not approved_items:
|
||||
raise ValueError("报废明细为空,无法执行")
|
||||
|
||||
# ★ 强制按单扫码:未提交实扫明细不允许执行
|
||||
if not scanned_items:
|
||||
# ★ 按执行模式分流。存量单据(全部是库存来源)没有 scrap_mode 字段,
|
||||
# 回落到适配器声明的模式 = scan,行为与改造前逐字一致。
|
||||
def _mode_of(item):
|
||||
mode = (item.get('scrap_mode') or '').strip()
|
||||
if mode in (SCRAP_MODE_SCAN, SCRAP_MODE_AUTO):
|
||||
return mode
|
||||
adapter = get_adapter(item.get('source_table'))
|
||||
return adapter.scrap_mode if adapter else SCRAP_MODE_SCAN
|
||||
|
||||
scan_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_SCAN]
|
||||
auto_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_AUTO]
|
||||
|
||||
# ★ 扫码仅在**本单存在需扫码明细**时强制。
|
||||
# 纯免扫码单(借出未还)允许 scanned_items 为空 —— 实物在借用人
|
||||
# 手上,要求扫码在物理上不可能。
|
||||
if scan_items and not scanned_items:
|
||||
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
|
||||
|
||||
approved = ScrapApprovalService._build_approved_index(approved_items)
|
||||
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
|
||||
approved = {}
|
||||
scanned = {}
|
||||
if scan_items:
|
||||
# 索引只由 scan 项构建:auto 项不参与匹配,避免同 SKU 串键
|
||||
approved = ScrapApprovalService._build_approved_index(scan_items)
|
||||
scanned = ScrapApprovalService._build_scanned_index(scanned_items)
|
||||
|
||||
# ★ 校验一:扫码 SKU 必须在批准明细内
|
||||
def _adapter_for(st):
|
||||
adapter = get_adapter(st)
|
||||
if adapter is None:
|
||||
raise ValueError(f"报废来源不支持:{st or '(空)'}")
|
||||
return adapter
|
||||
|
||||
# ★ 校验一:扫码物料必须在批准明细内
|
||||
for key, acc in scanned.items():
|
||||
if key not in approved:
|
||||
raise ValueError(
|
||||
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||||
f"物料【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
|
||||
# ★ 校验二:同一物料的累计扫码量不得超过批准总量
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
if acc['qty'] > appr['qty']:
|
||||
raise ValueError(
|
||||
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
f"物料【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
|
||||
# ★ scan 项扣减:按批准单配对的 source_table + stock_id 定位实物,逐行加锁扣减。
|
||||
# 扣减行为由来源适配器自持(库存行双扣 / 在管不良品只动台账)。
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
remaining = acc['qty']
|
||||
@ -383,38 +436,22 @@ class ScrapApprovalService:
|
||||
if take <= 0:
|
||||
continue
|
||||
|
||||
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']})")
|
||||
|
||||
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,
|
||||
))
|
||||
|
||||
_adapter_for(ref['source_table']).deduct(
|
||||
ref['stock_id'], take, req, operator_name,
|
||||
)
|
||||
remaining -= take
|
||||
|
||||
# ★ auto 项扣减:免扫码来源按批准量全量执行。
|
||||
# 不参与上面的扫码匹配 —— 若把它塞进同一个索引,同 SKU 会与
|
||||
# scan 项串键,扫码量被算到错误对象上。
|
||||
for it in auto_items:
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
if qty <= 0:
|
||||
continue
|
||||
_adapter_for(it.get('source_table')).deduct(
|
||||
it.get('stock_id'), qty, req, operator_name,
|
||||
)
|
||||
|
||||
req.status = 3
|
||||
req.executed_at = _beijing()
|
||||
req.executor_name = operator_name
|
||||
|
||||
@ -96,10 +96,12 @@ class TransService:
|
||||
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
|
||||
#
|
||||
# ★ deduct_stock=False:借出是**可逆的**(会归还),只冻结可用数。
|
||||
# 若此处扣了实物,会与两处现有实现冲突:
|
||||
# 若此处扣了实物,会与两处冲突:
|
||||
# · 归还(process_return)只加 available —— 一借一还后 stock 永久少一份;
|
||||
# · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设
|
||||
# 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。
|
||||
# · 借库转报废在确认损失时扣 stock,其实现明确假设「可用库存已在
|
||||
# 借出时冻结」—— 若借出已扣会重复扣减。
|
||||
# (该实现原为 TransService.scrap_borrow,现已迁入
|
||||
# app/services/scrap_sources.py 的 BorrowScrapAdapter.deduct)
|
||||
# ==============================================================
|
||||
# ★ 防线 2.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。
|
||||
#
|
||||
@ -357,90 +359,6 @@ class TransService:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def scrap_borrow(record_ids, operator_name='System', reason=''):
|
||||
"""
|
||||
借库未归还直接报废(关联报废单流程)
|
||||
|
||||
适用场景:借出的物品确认丢失/损坏/无法归还,库管/主管手动报废。
|
||||
- 该笔借用不再追讨归还,从借还记录中标记为已报废
|
||||
- 生成一条 TransScrap 报废记录(关联 trans_borrow)
|
||||
- 总库存 stock_quantity 扣减(该物品确认损失);可用库存已在借出时冻结,无需重复扣
|
||||
|
||||
Args:
|
||||
record_ids: TransBorrow 记录ID列表
|
||||
operator_name: 操作人
|
||||
reason: 报废原因
|
||||
|
||||
Returns:
|
||||
{count: 报废条数}
|
||||
"""
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
if not record_ids:
|
||||
raise ValueError('请选择要报废的借出记录')
|
||||
if not reason:
|
||||
raise ValueError('请填写报废原因')
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
created_records = []
|
||||
scraped_count = 0
|
||||
|
||||
try:
|
||||
for rid in record_ids:
|
||||
record = TransBorrow.query.with_for_update().get(rid)
|
||||
if not record:
|
||||
continue
|
||||
# 仅未归还的借用可报废
|
||||
if record.is_returned:
|
||||
continue
|
||||
pending_qty = float(record.quantity) - float(record.returned_quantity or 0)
|
||||
if pending_qty <= 0:
|
||||
continue
|
||||
|
||||
# 1. 标记借用记录为已报废(不再追讨归还)
|
||||
record.is_returned = True
|
||||
record.status = 'scrapped'
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
|
||||
# 2. 扣减总库存(该物品确认损失);可用库存已在借出时冻结
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
# 不足则报错整单回滚:直接夹到 0 会让 available_quantity > stock_quantity,
|
||||
# 凭空多出可用库存
|
||||
if pending_qty > stock_qty:
|
||||
raise ValueError(
|
||||
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {pending_qty}"
|
||||
)
|
||||
stock.stock_quantity = stock_qty - pending_qty
|
||||
|
||||
# 3. 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=record.sku,
|
||||
source_table='trans_borrow', # 标识来源为借出转报废
|
||||
stock_id=record.id,
|
||||
quantity=pending_qty,
|
||||
reason=f"[借库报废] {reason}",
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=0,
|
||||
total_loss=0
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
scraped_count += 1
|
||||
|
||||
db.session.commit()
|
||||
return {'count': scraped_count}
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
|
||||
borrower_name=None, start_date=None, end_date=None,
|
||||
|
||||
Reference in New Issue
Block a user