diff --git a/db_migrations/fill_missing_stock_status.sql b/db_migrations/fill_missing_stock_status.sql new file mode 100644 index 0000000..d83e323 --- /dev/null +++ b/db_migrations/fill_missing_stock_status.sql @@ -0,0 +1,140 @@ +-- ============================================================================= +-- 一次性迁移:库存状态列存量洗刷(配合 status 硬隔离上线) +-- +-- 背景 +-- 三张库存表(stock_buy / stock_semi / stock_product)的 status 列自建表起 +-- 就是「只写不读」的:入库时写一次 '在库',此后全仓再无任何代码更新它, +-- 分配器也从不看它。现分配器已改为「仅 status = '在库' 才可被分配」 +-- (见 app/services/inventory_reservation.py::allocatable_filter)。 +-- +-- 该过滤是 Fail-Closed 的:status 为 NULL、空串或任何非 '在库' 值的行 +-- 都会被排除在候选集之外。因此上线该过滤**之前**必须先跑本脚本,把 +-- 本该可用的存量行刷回 '在库',否则它们将集体无法出库。 +-- +-- 洗刷范围(刻意收敛,只动 status 一列) +-- (status IS NULL OR status <> '在库') +-- AND (stock_quantity > 0 OR available_quantity > 0) +-- +-- · 为什么带 stock_quantity 条件,而不只是 available_quantity: +-- 借出未还的行 available_quantity 可能为 0 而 stock_quantity > 0。 +-- 归还时 process_return() 会给该行加回 available_quantity,届时若 +-- status 仍为 NULL,这行就永远出不去了。此处一并覆盖,堵住这个未来缺口。 +-- +-- · 为什么排除「无实物的行」(stock_quantity / available_quantity 均为空或 0): +-- 这类行多为历史脏数据(实测 stock_buy id=807 是整行全空的幽灵记录)。 +-- 把它们刷成 '在库' 等于凭空声明一批并不存在的合格库存,是有害的。 +-- 它们本来就不会被分配(分配器要求 available_quantity > 0),保持原样无害。 +-- 可用文末「0-b)」的查询把它们捞出来人工核对。 +-- +-- ● 为什么**不**刷 quality_status / inspection_status(对原始需求的偏离,务必知悉) +-- 1) stock_buy **没有** quality_status 列,只有 inspection_status。 +-- 统一写 `SET quality_status='合格'` 会直接报 column does not exist, +-- 脚本根本跑不完。 +-- 2) 更要命的是语义:stock_semi 有 24 行 quality_status='待检'、 +-- stock_buy 有 2042 行 inspection_status='未检'(其中 1766 行可用量 > 0)。 +-- 把它们刷成 '合格' 属于**伪造质检结论** —— 等于把从未检验过的货 +-- 当成合格品放行,与本次改造「不让坏件流出」的目的正好相反。 +-- 因此本脚本只洗 status。如确有补空需求,见文末「3) 可选:质量列补空」, +-- 那里只补 NULL,绝不覆盖任何已有取值。 +-- +-- 幂等性 +-- WHERE 条件保证可重复执行:第二次执行影响 0 行。 +-- +-- 执行 +-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- 0) 执行前预览:将被洗刷的行数(建议先单独跑这一段确认无误再跑 1) +-- +-- ★ 用 COALESCE 而非裸比较:库存表允许数量列为 NULL,而 `NULL > 0` 求值为 +-- NULL(非真),裸写会让「数量为 NULL」的脏行既不进洗刷、也不进 0-b 诊断, +-- 在两次查询里凭空消失。COALESCE 让两个条件互为补集,口径严密。 +-- --------------------------------------------------------------------------- +SELECT 'stock_buy' AS tbl, count(*) AS will_update + FROM stock_buy + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0) +UNION ALL +SELECT 'stock_semi', count(*) + FROM stock_semi + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0) +UNION ALL +SELECT 'stock_product', count(*) + FROM stock_product + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0); + +-- --------------------------------------------------------------------------- +-- 0-b) 被跳过的行(无实物但状态异常,多为历史脏数据)—— 仅诊断,不修改 +-- 与 0) 严格互补,两者行数之和 = 该表 status 非「在库」的总行数 +-- --------------------------------------------------------------------------- +SELECT 'stock_buy' AS tbl, id, sku, barcode, status, stock_quantity, available_quantity + FROM stock_buy + WHERE (status IS NULL OR status <> '在库') + AND COALESCE(stock_quantity, 0) <= 0 AND COALESCE(available_quantity, 0) <= 0 +UNION ALL +SELECT 'stock_semi', id, sku, barcode, status, stock_quantity, available_quantity + FROM stock_semi + WHERE (status IS NULL OR status <> '在库') + AND COALESCE(stock_quantity, 0) <= 0 AND COALESCE(available_quantity, 0) <= 0 +UNION ALL +SELECT 'stock_product', id, sku, barcode, status, stock_quantity, available_quantity + FROM stock_product + WHERE (status IS NULL OR status <> '在库') + AND COALESCE(stock_quantity, 0) <= 0 AND COALESCE(available_quantity, 0) <= 0; + +-- --------------------------------------------------------------------------- +-- 1) 洗刷 status(三表口径一致;stock_buy 无 quality_status,故不涉及) +-- --------------------------------------------------------------------------- +UPDATE stock_buy + SET status = '在库' + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0); + +UPDATE stock_semi + SET status = '在库' + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0); + +UPDATE stock_product + SET status = '在库' + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0); + +-- --------------------------------------------------------------------------- +-- 2) 执行后核对:三行结果应全部为 0 +-- (统计口径与 1) 的 WHERE 完全一致,故「有实物且状态非在库」的行应为 0) +-- --------------------------------------------------------------------------- +SELECT 'stock_buy' AS tbl, count(*) AS remaining + FROM stock_buy + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0) +UNION ALL +SELECT 'stock_semi', count(*) + FROM stock_semi + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0) +UNION ALL +SELECT 'stock_product', count(*) + FROM stock_product + WHERE (status IS NULL OR status <> '在库') + AND (COALESCE(stock_quantity, 0) > 0 OR COALESCE(available_quantity, 0) > 0); + +-- --------------------------------------------------------------------------- +-- 3) 可选:质量列补空 —— 默认**注释掉**,需要时人工确认后再放开 +-- +-- ★ 只补 NULL,不覆盖任何已有取值。 +-- '待检' / '未检' 是真实业务状态,绝不能被本脚本抹成 '合格'。 +-- 实测当前三表均无 NULL 的质量列(stock_buy.inspection_status 有 2042 行为 +-- '未检',是有值状态,不在补空范围内),故本段在当前数据上是空操作。 +-- --------------------------------------------------------------------------- +-- UPDATE stock_semi SET quality_status = '合格' WHERE quality_status IS NULL; +-- UPDATE stock_product SET quality_status = '合格' WHERE quality_status IS NULL; +-- UPDATE stock_buy SET inspection_status = '未检' WHERE inspection_status IS NULL; +-- ↑ 采购件补的是「未检」而非「合格」:默认未检验是保守且诚实的取值。 + +COMMIT; diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 184a956..73962f2 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -148,6 +148,11 @@ def scan_barcode(): 'msg': '未找到对应的库存记录,请确认条码是否正确' }), 404 + except ValueError as e: + # ★ 业务性拒绝(物料状态异常等):属于「扫到了但按规定不能出」, + # 不是系统故障 —— 返回 400 + 明确文案,便于前端红字提示工人。 + # 若落到下方 500 分支,前端只会显示「服务器错误」,工人无从判断。 + return jsonify({'code': 400, 'msg': str(e)}), 400 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500 @@ -318,6 +323,16 @@ def _allocate_bom_requirements(requirements, company_limit, # 按 (base_id, source_table) 归集 rows_by_base = {} + # ★ 状态门槛:本函数是**全系统库存分配的唯一权威入口** —— + # 出库申请与借库申请都经 reserve_for_items() 走到这里拿候选行, + # 故在此处加一条即对两者同时生效(报废不走分配器,见 scrap_approval_service)。 + # 规则见 inventory_reservation.allocatable_filter:仅「在库」可被分配, + # 「冻结」「不良品」以及 status 为 NULL 的行一律不进入候选集。 + # + # Fail-Closed 说明:若该条件导致查询异常,下方 except 会 continue 掉整张表, + # 表现为「该物料无库存」而非「放行坏件」,方向上是安全的。 + from app.services.inventory_reservation import allocatable_filter + for model, source_table, type_label, type_key in ( (StockBuy, 'stock_buy', '采购件', 'material'), (StockSemi, 'stock_semi', '半成品', 'semi'), @@ -327,6 +342,7 @@ def _allocate_bom_requirements(requirements, company_limit, q = model.query.filter( model.base_id.in_(base_ids), model.available_quantity > 0, # ★ 只取真正可用的行 + allocatable_filter(model), # ★ 硬隔离:非「在库」一律不出货 ) if company_limit is not None: q = q.filter(model.base.has(MaterialBase.company_name == company_limit)) diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 3405884..4b8d629 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -81,6 +81,60 @@ def identity_label(key): return f"{key[1]}({key[2] or '-'})" +# ============================================================================= +# 库存状态门槛(硬隔离) +# ============================================================================= +# ★ 背景:三张库存表(stock_buy / stock_semi / stock_product)建表起就带 +# status 列,但历史实现**只写不读** —— status 仅在入库时写一次 '在库', +# 此后全仓再无任何代码更新它;分配器更是只看 available_quantity。 +# 后果:被标成「冻结 / 不良品」的库存行,只要可用量不为 0 就会被正常 +# 分配出货,坏件因此可以反复流出。 +# +# 此处把 status 变成**真正的准入门槛**:只有白名单内的状态才参与分配。 +# 配套两件事,缺一不可: +# · 历史数据洗刷 —— db_migrations/fill_missing_stock_status.sql +# (存量行的 status 若为空/异常,上线本门槛后会集体无法出库) +# · 状态变更入口 —— POST /api/v1/inbound/stock//change-status +# (否则状态只能靠手工改库,逆向物流无入口) + +STOCK_STATUS_IN_STOCK = '在库' # ★ 唯一允许被分配出货的状态 +STOCK_STATUS_FROZEN = '冻结' # 盘查/争议期间临时锁定,货还在但要先查清 +STOCK_STATUS_DEFECTIVE = '不良品' # 坏件:待维修或待报废,绝不允许再发出 + +# 允许写入库存行的状态全集(变更接口据此校验,防止脏值从接口进入) +VALID_STOCK_STATUSES = ( + STOCK_STATUS_IN_STOCK, + STOCK_STATUS_FROZEN, + STOCK_STATUS_DEFECTIVE, +) + +# 「可被分配」的状态白名单。 +# ★ 用元组而非单值比较:将来若要放行更多状态(如「待检」),只改这里一处。 +ALLOCATABLE_STATUSES = (STOCK_STATUS_IN_STOCK,) + + +def allocatable_filter(model): + """ + 库存行的「可被分配出货」SQL 条件,供分配器拼进 WHERE。 + + ★ Fail-Closed:status 为 NULL 的行**不会**被选中(NULL IN (...) 求值为 + NULL,非真)。这正是我们要的语义 —— 状态不明的货宁可不出,但代价是 + 上线前必须先把存量数据洗刷干净,否则全库无法出库。 + 洗刷脚本见 db_migrations/fill_missing_stock_status.sql。 + """ + return model.status.in_(ALLOCATABLE_STATUSES) + + +def is_allocatable(row): + """ + 单行版判断:该库存记录当前是否可被分配。 + + 供扫码(get_stock_by_barcode)与执行阶段(restore_then_deduct)等 + 不走 SQL 过滤的路径复用,保证全链路用的是**同一套**状态语义。 + """ + return norm_text(getattr(row, 'status', None)) in ALLOCATABLE_STATUSES + + # ============================================================================= # 库存行动态解析 # ============================================================================= @@ -384,6 +438,22 @@ def verify_scanned(scanned_items, approved_items): key = stock_identity(row) label = identity_label(key) + # ★ 状态准入(最终防线):状态异常的实物整单拒绝。 + # + # 与分配器 allocatable_filter()、扫码入口 _assert_scan_allocatable() + # 共用同一个 is_allocatable() 判定,三处语义不会分叉。 + # + # 这道覆盖的是「申请已通过 → 工人正在扫」这段窗口内被冻结/标不良的 + # 情形 —— 分配器管不到(那是申请时刻),扫码入口也可能被绕过 + # (前端可被绕过、草稿可陈旧)。这里是写库前的最后一道。 + # + # 抛错即整单回滚(调用方负责),预占由 restore_then_deduct 的调用方 + # 统一处理 —— 实际语义见 create_outbound_batch 里的 rollback 兜底: + # 单据保持 status=1,可重试或走撤回/驳回,不会留下半扣留状态。 + if not is_allocatable(row): + status = (getattr(row, 'status', None) or '').strip() or '未设置' + raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库") + if key not in approved_idx: raise ValueError( f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库" diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index e7000f9..69c162c 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -75,6 +75,7 @@ class OutboundService: .filter(MaterialBase.company_name == company_limit) prod = prod_q.first() if prod: + OutboundService._assert_scan_allocatable(prod) res = OutboundService._format_scan_result(prod, 'stock_product', reserved_map) res['price'] = get_price(prod, 'stock_product') return res @@ -87,6 +88,7 @@ class OutboundService: .filter(MaterialBase.company_name == company_limit) semi = semi_q.first() if semi: + OutboundService._assert_scan_allocatable(semi) res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map) res['price'] = 0 return res @@ -99,15 +101,19 @@ class OutboundService: .filter(MaterialBase.company_name == company_limit) buy = buy_q.first() if buy: + OutboundService._assert_scan_allocatable(buy) res = OutboundService._format_scan_result(buy, 'stock_buy', reserved_map) res['price'] = get_price(buy, 'stock_buy') return res - # 查询维修单表 (按SKU或序列号查询,排除已出库状态) + # 查询维修单表 (按SKU或序列号查询) + # ★ 维修单不走库存 status 体系,用自身的 repair_status 做同等准入判断: + # 已出库(货已交回客户)、报废转出(实物已销毁)都不该再被扫出库。 + # 原实现只排除 '已出库',报废转出的维修件仍可被扫走。 repair = TransRepair.query.filter( or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code) ).filter( - TransRepair.repair_status != '已出库' + TransRepair.repair_status.notin_(['已出库', '报废转出']) ).first() if repair: res = { @@ -134,6 +140,27 @@ class OutboundService: return None + @staticmethod + def _assert_scan_allocatable(item): + """ + 扫码准入校验:状态异常的实物一律拦在扫码环节。 + + ★ 为什么扫码就拦,而不只在提交时拦(verify_scanned 已有一道): + 提交时的校验是整单粒度的,报错只说「物料 X 状态异常」,工人得自己 + 在一整单里找是哪一件。扫码即拦能立刻定位到手上这一件。 + 两道校验用同一个 is_allocatable() 判定,语义不会分叉。 + + ★ 与分配器的关系:分配器只决定"申请时能不能把这行算进候选", + 而冻结可能发生在「申请已通过、工人正在扫」的窗口内 —— + 扫码这道是覆盖该窗口的必要补充。 + """ + from app.services.inventory_reservation import is_allocatable + + if not is_allocatable(item): + # status 为空时给出可读文案,避免报出 "当前为 " 这种半截话 + status = (getattr(item, 'status', None) or '').strip() or '未设置' + raise ValueError(f"物料状态异常(当前为 {status}),禁止出库") + @staticmethod def _format_scan_result(item, table_name, reserved_map=None): """ @@ -216,21 +243,37 @@ class OutboundService: beijing_tz = timezone(timedelta(hours=8)) current_time = datetime.now(beijing_tz).replace(tzinfo=None) - # ★ 审批单相关逻辑 + # ================================================================== + # ★ 强制按单出库(Fail-Closed):所有出库必须关联有效的出库申请单 + # + # 背景:改造前 request_id 为空时会跳过预占再平衡,落到所谓「散单」 + # 分支,而该分支的库存扣减逻辑从未落地 —— 旧注释点名的 + # _apply_reservation_override() 在全仓并不存在。实测后果:散单出库 + # 只写 TransOutbound 台账,stock_quantity / available_quantity + # 分毫不动,同一批货可被反复出库而不减库存。 + # + # 前端已彻底移除散单入口(views/outbound/create.vue 在未选单时把 + # 扫码框整块 disabled),但 HTTP 接口仍可被直接调用绕过 —— 故在此 + # 后端硬阻断,防 API 级绕过。 + # + # ★ 强制按单同时保证库存扣减路径唯一: + # 申请预占 → 扫码校验 → restore_then_deduct() + # status 硬隔离(仅有「在库」可被分配)也依赖这条路径才全程有效。 + # ================================================================== request_id = data.get('request_id') - approval = None - if request_id: - # 根据 request_id 查询审批单 - approval = OutboundApproval.query.get(request_id) - if not approval: - raise ValueError(f"关联的审批单不存在 (ID: {request_id})") - if approval.status != 1: - status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'} - current_status = status_map.get(approval.status, str(approval.status)) - raise ValueError( - f"关联的审批单状态不允许出库 (当前状态: {current_status})," - f"仅已通过的审批单方可执行出库" - ) + if not request_id: + raise ValueError("非法操作:所有出库必须关联有效的出库申请单") + + approval = OutboundApproval.query.get(request_id) + if not approval: + raise ValueError(f"关联的审批单不存在 (ID: {request_id})") + if approval.status != 1: + status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'} + current_status = status_map.get(approval.status, str(approval.status)) + raise ValueError( + f"关联的审批单状态不允许出库 (当前状态: {current_status})," + f"仅已通过的审批单方可执行出库" + ) model_map = { 'stock_buy': StockBuy, @@ -242,18 +285,25 @@ class OutboundService: track_notifications = [] # ================================================================== - # ★ Phase 3:预占再平衡(仅针对关联审批单的出库) + # ★ Phase 3:预占再平衡 —— 库存扣减的唯一路径 # - # 关联审批单时,申请阶段已把货预占在「申请时选定的批次」上。 - # 工人实际扫的可能是同物料的**另一个批次**(物理覆盖),因此这里: + # 申请阶段已把货预占在「申请时选定的批次」上。工人实际扫的可能是 + # 同物料的**另一个批次**(物理覆盖),因此这里: # 1. 校验实扫的身份/数量未超出批准范围(base_id 主键,允许换批次) # 2. 释放全部预占 # 3. 对实扫批次扣减 available_quantity 与 stock_quantity # 之后主循环只写 TransOutbound 流水,不再重复扣库存。 # - # 无关联审批单(散单)时跳过,走原有逐行扣减逻辑。 + # ★ 本段已改为**无条件执行**:上方强制 request_id 必填,approval 恒非 + # None。原「无关联审批单(散单)时跳过,走原有逐行扣减逻辑」的分支 + # 已删除 —— 它正是「散单出库不扣库存」的成因(被跳过的这里就是全仓 + # 唯一的扣减入口,而所谓的"原有逐行扣减逻辑"并不存在)。 # ================================================================== - if approval is not None: + # ★ 自带 rollback:restore_then_deduct() 先释放预占、后校验可用量, + # 中途抛错时 session 已带脏改动。它自身不 commit,若此处不兜底, + # 脏状态会一路带到请求结束。校验失败一律整单回滚,单据保持 + # status=1 可重试(或走撤回/驳回),不会产生半扣留状态。 + try: from app.services.inventory_reservation import ( verify_scanned, restore_then_deduct, ) @@ -274,6 +324,9 @@ class OutboundService: verify_scanned(_scanned, _approved) restore_then_deduct(_scanned, _approved) + except Exception: + db.session.rollback() + raise try: for item in items: @@ -347,10 +400,10 @@ class OutboundService: ) db.session.add(new_record) - # ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成" - if approval: - approval.status = 3 # 3-已完成 - # updated_at 会在 commit 时由 SQLAlchemy 自动更新 + # ★ 出库成功后更新审批单状态为"已完成" + # approval 恒非 None(上方已强制 request_id 必填),无需再判空 + approval.status = 3 # 3-已完成 + # updated_at 会在 commit 时由 SQLAlchemy 自动更新 # ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间 db.session.commit() @@ -767,13 +820,21 @@ class OutboundService: grouped_map[ono]['total_amount'] += subtotal + returned = float(d.returned_quantity or 0) grouped_map[ono]['items'].append({ + # ★ 退回功能所需:前端「退回」按钮要把本字段回传给 + # POST /api/v1/inbound/stock/return-from-outbound 的 outbound_id。 + # 注意它是**出库明细行**的主键,不是出库单号。 + 'id': d.id, 'sku': d.sku, 'name': item_name, 'spec_model': item_spec, 'category': item_cat, 'material_type': item_type, 'quantity': qty, + # ★ 退回额度三件套:前端据此显示「已退 / 可退」并置灰已退满的行 + 'returned_quantity': returned, + 'returnable_quantity': qty - returned, 'unit_price': price, 'subtotal': subtotal, 'batch_sn': batch_sn,