Commit Graph

573 Commits

Author SHA1 Message Date
dec33b685c feat(purchase): 新增待采购池接口(防重复采购)
GET /api/v1/purchase/pending-pool:找出「有效供给仍低于预警线」的物料。

  有效供给 = 物理总库存 + 在途量
  过滤规则 = 有效供给 <= 红/黄阈值 才进池
  建议采购量 = max(1, 目标阈值 - 有效供给)

★ 为什么不用「有活跃单就排除」:红线 10、库存 0、某采购员只建了一张
  数量 5 的单时,该物料会立刻从池中消失,剩下 5 个缺口永远无人认领。
  改为按量计算后它继续留池,suggested_qty 自动降到 5。

★ 用 <= 而非 < 是与业务方确认后刻意维持的口径(与物料列表预警、预警邮件
  同源),勿擅自改成严格小于 —— 只改本接口会造成「列表亮黄灯、池子却排除」
  的撕裂,真要改必须三处一起动。代价是恰好等于阈值时建议量落到保底的 1,
  前端 tooltip 已单独说明,不展示算不成立的错误算式。

权限:新增 inbound_purchase:pending_pool(注册为 sys_element 而非新建
SysMenu)。因 ensure_default_permissions 在角色已有权限时整段跳过,另写了
存量角色自动迁移,并按源行镜像 company_name 避免跨公司作用域泄漏。
2026-09-18 14:30:53 +08:00
fc5e62c848 feat(purchase): 新增「活跃采购单」判定与在途量计算模块
防重复采购的唯一真相源。刻意回答两个不同的问题,别混用:
- active_purchase_exists()  有没有人在买?      → 展示用
- in_transit_subquery()     已经买了多少还没到? → 算术用

活跃单定义:status 0/1,或 status=3 且累计入库 < 采购量。
status=4(强制结案)刻意不在其中 —— 短交不补发、来料拒收不补发这类
异常单永远达不到采购量,靠库管强制结案解锁,避免物料被永久占用。

「部分到货」无需改表即可算出:StockBuy.request_id 早已存在,且
StockBuy.in_quantity 是入库原始量(不被出库扣减,区别于 stock_quantity),
按 request_id 聚合即得每张单的累计到货量。
2026-09-18 14:30:40 +08:00
eafdf992c7 fix(borrow): 借还记录「归还人」列错标成了经手库管,拆成两列
问题
----
列表里那一列标着「归还人」,读的却是 trans_borrow.return_operator —— 而该字段
存的是**办理还库的库管**,不是来还东西的人。两者本就是不同的人:
  · returner_id(trans_borrow_return)—— 把东西交回窗口的人,已校验 == 当时持有人
  · return_operator                    —— 经手办理的库管
实测(借用行 120):return_operator = 杜邢宸/duxingchen(库管),
而实际归还人是 returner_id = 21(测试)—— 页面却显示成了「杜邢宸」。

改动
----
一、后端 get_records 增补 returners:从 trans_borrow_return 取 returner_id 并
    反查 sys_user 得到姓名(去重按明细挂回)。批量查一次,不做 N+1。
二、前端拆成两列,各自名副其实:
      「归还人」   ← returners(后端新增)
      「经手库管」 ← return_operators(原列改为正确标签)
三、顺带修展示口径:return_operator 存的是**完整 username**(高闯/gaochuang),
    未按全站口径截断。_display_borrow_operator 现统一归一到展示名
    (数字 id 反查 / 姓名、斜杠前段 / 已是展示名 原样),并修正其 docstring
    —— 它原本也把该字段称作「归还人」,是同一个误解的源头。

★ 历史数据的现实
  实际归还人流水是二期才建的,**历史归还没有这个记录**。这部分行的「归还人」
  显示为空并挂 tooltip 说明「该笔归还发生在实际归还人记录上线之前」——
  刻意不拿库管的名字顶上,那正是本次要修的错。

验证
  BOR-20260918-0001 → 归还人=['测试']、经手库管='杜邢宸'  ✓ 两者分开
  BOR-20260914-0001 → 归还人=[](历史)、经手库管='高闯'    ✓
  归一化:'高闯/gaochuang'→'高闯'、'21'→'测试'             ✓
  前端 vite build 通过;本次无需 DB 迁移。
2026-09-18 09:43:25 +08:00
e3fe1fc12a feat(borrow): 借还记录「已归还」页签改为按归还时间倒序
问题
----
三个页签共用同一套排序(有限期单在前 → 最早应还时间 ASC → 最早借出时间 DESC),
这套「优先关注快到期/逾期」的逻辑对「未归还」是对的,但对「已归还」正好**
反了**:已归还列表要回答的是「最近还了哪几笔」,而按应还时间排会让最近刚还的
那几笔排到最后。

改动
----
order_subq 增加 max_return_time(单号内**最晚**一次归还时间)作为排序键,
并按页签分流:
  · 已归还     → nullslast(max_return_time DESC),borrow_no DESC 兜底保证稳定
  · 全部/未归还 → 原三级排序不变

★ 为什么取「最晚」而不是「最早」一次归还时间
  多明细分批归还时,整单结清的那一刻才是有意义的节点;且与主行「归还时间」列
  的展示口径一致(前端同样取 latest),排序依据与可见值不会打架。

验证(真实数据)
  已归还页签:09-15 11:41 → 09-14 16:02 → 09-09 11:46 → 09-08 13:22 →
              09-04 17:26 → 09-04 09:45 → 09-03 15:25,第 2 页续 08-27 → …,
              严格递减且**跨页连续**;
  未归还页签:排序与改动前完全一致(回归确认)。
2026-09-18 09:34:45 +08:00
ccdd6e7306 fix(purchase): 商家地址链接放宽为 text,修超长链接保存失败
现象
----
采购管理新建采购申请时粘贴电商商品链接报错。实测该链接 **516 字符**,而
purchase_request.supplier_link 是 varchar(500) —— 超出 16 个字符,
PostgreSQL 直接拒绝(value too long for type character varying(500))。

★ 为什么用 text 而不是把 500 调大
  电商外链(淘宝/1688)普遍带很长的跟踪参数,长度没有稳定上界:本例已 516,
  再叠加一轮营销参数就可能破千。任何有限的 varchar(N) 都只是把报错往后推,
  而且**截断是静默的** —— 用户只会觉得「链接打不开」。
  同 material_base.purchase_link 的处理(那边一开始就用 text)。

附带确认(全部扫描,无需改动)
  从数据库层面扫了所有「有长度上限、且列名像 link/url/photo/image/
  signature/path」的列:
    · purchase_request.supplier_link(500)  ← 本次唯一真正有问题的
    · sys_menu.path(200)、sys_warehouse_location.full_path(500)  内部生成数据
    · stock_adjustment.linked_sku(100)/linked_outbound_no(50)     SKU 与单号
  用户粘贴外链的列仅此一处。

迁移用 DO 块先判断当前类型,可重复执行;含核对段与回滚段。
验证:把那条 516 字符的真实链接写入再读回,长度一致、内容未截断。
2026-09-18 09:34:44 +08:00
191724a176 fix(outbound): 修复扫码出库 500 —— applicant_id 用了尚未赋值的 approval
现象
----
所有扫码出库报 500(前端 create.vue 提交即失败)。

根因
----
上一个提交把 'applicant_id': approval.applicant_id 写进了 common_data,
但 common_data 构造在第 234 行,而 approval 直到第 269 行(「强制按单出库」
那一段)才查询赋值 —— 典型的变量先用后赋,直接 UnboundLocalError。
它影响的是**每一条**出库请求,属于必然复现而非偶发。

修复
----
把赋值移到 approval 取出并校验**之后**:common_data['applicant_id'] = ...
并在原处留注释说明为什么不能写在字典字面量里,避免后人搬回去。

★ 我的测试为什么没抓到
  上一轮只测了「退回 → 补发」这条路径,而 applicant_id 的写入在**扫码出库**
  路径上 —— 两条路径不重合,所以漏了。本次补测了完整的
  「出库申请(预占) → 扫码出库(扣减)」链路。

验证(6 项断言全通过,走真实接口链路)
  申请单创建(status=1,申请人=12,预占 stock_buy#2058 两件)
    → 扫码出库不再抛 500
    → 出库明细生成且 applicant_id = 12(不再是 NULL)
    → consumer_name 照常写入
    → 审批单置为已完成
    → 库存实际扣减 2
  清理后库存与数据零残留。

  ★ 顺带在真实数据上得到印证:生产库中已有一笔由业务方重试成功的出库
    (OUT-20260917-1333-0001),其 applicant_id 正确等于所关联申请单的申请人。
2026-09-17 13:35:47 +08:00
0005a689dc fix(outbound): 补发申请人取原申请人,绝不再回退为库管
问题
----
上一版在库管未指定「补发给谁」时,把补发单申请人回退成了**当前操作人(库管)**。
但补发是**原申请人的需求**,挂到库管名下逻辑不通 —— 那张单会出现在库管的
「我的申请」里,而真正该拿东西的人什么也看不到。

根因
----
trans_outbound 只有 consumer_name(**扫码时前端自由填写**的领用人/客户名,
既不可靠也可能是外部客户),没有任何指回原审批单的关联,所以当时只能退而求其次。

根治
----
一、trans_outbound 新增 applicant_id,**创建出库时从关联审批单带出**
    (request_id 已强制必填、approval 恒非 None,故新单据必然有值)。
    落这一列后,「退回 → 补发」即可自动找回真正的原申请人。
    ⚠ 存量行为 NULL —— 存量出库与其来源审批单之间没有任何可用关联,无从回填。
      刻意留 NULL 而不是按姓名猜(consumer_name 是自由文本,会重名错绑),
      与 dispatch_operator 同一取舍:宁可留空,也不猜。

二、退回接口的申请人优先级改为:
      ① 前端显式指定 reissue_applicant_id
      ② 原出库明细记录的 applicant_id(真实原申请人)
      ③ 都没有 → **报错要求指定**
    ★ 彻底移除「回退为当前操作人」—— 那正是本次要修的逻辑错误。

三、出库列表明细返回 applicant_id,供前端精确预填。

验证(9 项断言全通过)
  · 新单据 → 申请人 = 原申请人(12),绝不是库管(7)
  · 显式指定优先于原申请人
  ★ 历史单据未指定 → 接口拒绝、要求选择「补发给谁」、整笔回滚、
    且**未生成任何挂在库管名下的补发单**
  · 历史单据 + 显式指定 → 正常
  库存与数据零残留。
2026-09-17 12:07:21 +08:00
27e5589a5e feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口
一、补发申请人可选择(原单退回)
   退回接口新增 reissue_applicant_id:
     ① 前端指定 → 校验用户存在后落库;
     ② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。
   为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单
   的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈
   「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。

二、抽出中性人员名单 GET /api/v1/common/active-users
   实现抽到 common.active_user_options(),借库的 /transactions/borrow/users
   改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现
   「出库为什么在调借库的接口」这种跨模块语义错位。
   仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。

★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的
  outbound_approval.applicant_id。

验证(打桩/真实 token 直连接口,12 项断言全通过)
  · 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致
  · 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人
  · 不指定 → 回退为当前操作人
  ★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库)
  库存与数据零残留。
2026-09-17 12:01:11 +08:00
7071c89305 feat(outbound): 原单退回后可自动生成补发单
背景
----
原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有
被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载
「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建
一张出库申请,而那张单与原单看不出任何关系。

改动
----
· outbound_approval 新增 source_return_id(非空 = 补发单),把「退回 → 补发」
  串成闭环。
· POST /inbound/stock/return-from-outbound 新增 need_reissue / reissue_qty:
  勾选即自动生成一张**免审批**出库单(status=1,直接进入待执行),
  沿用原单出库类型,并关联回本笔退回。

★ 为什么只存退回单 ID,不加 is_reissue 布尔列
  「是不是补发」完全由来源是否存在决定,再加一列就是同一事实的两处存储,
  必然有不同步的一天。也不冗余存原出库单 ID:trans_return 已有 outbound_id。

★ 库存不足 → 整笔回滚(关键取舍)
  补发走 reserve_for_items(strict=True),与出库申请同一口径。不足时抛错,
  退回也一并回滚 —— 若只让补发静默失败,「需要补发」的意图就丢了,
  那正是本功能要解决的问题。库管看到提示后取消勾选即可只做退回过账。

★ 一个被发现的数据约束(改变了原设计)
  原打算把补发单的申请人设为「原出库单的申请人」,但 **trans_outbound 既没有
  申请人字段,也没有指回原审批单的关联**(扫码出库时只把审批单状态置为 3)。
  按 consumer_name 反查会重蹈「重名错绑」的覆辙。故申请人取**当前操作人**,
  原领用人写入备注供人工追溯。
  ⚠ 若业务要求补发单挂在原领用人名下,需要前端在退回弹窗里加一个「补发给谁」
    的人员选择 —— 请确认是否需要。

验证(打桩 JWT 直连真实接口,22 项断言全通过)
  勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注;
  不勾选 → 不生成补发单、良品正常回库;
  ★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚);
  补发量 > 退回量被拒;库存与数据零残留。
2026-09-17 11:56:38 +08:00
6cb30a21fd feat(material): 采购链接接入读写映射与服务层
· models/base.py:新增 purchase_link 列(text)并加入 to_dict(purchaseLink)
· field_permissions.py:读过滤映射 purchaseLink → material_list:purchaseLink
· inbound/base.py:POST 与 PUT 两处 field_to_perm 同时补入(漏掉任一处,
  新增/修改就会各缺一半)
· base_service.py:create_material 写入、update_material 按字段存在与否更新

★ 写侧刻意**显式纳入映射**而非依赖「不在映射中→默认允许」的兜底分支 ——
  那个分支正是上一轮附件备注「谁都能写」的成因,新字段不再走它。

验证(6 个角色)
    SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR / INBOUND → 读✓ 写✓
    OUTBOUND / SALES                                   → 读✗ 写✗
  读写逐角色一致;超管走 material_list:* 通配分支、过滤整段跳过(已单独复核)。
2026-09-17 11:26:36 +08:00
fd084bf8fd fix(material): 附件备注纳入写权限管控,废止「不在映射中→默认允许」的兜底
问题
----
POST /inbound/base/ 与 PUT /inbound/base/<id> 的 field_to_perm 里**没有**
productImageRemark / manualLinkRemark,于是这两个字段落入
「不在映射中 → 默认允许」的兜底分支 —— 任何能调通该接口的角色都能改。
配合读侧引用幽灵权限码,形成「谁都能写、除超管没人能读」的错位。

改动
----
两处映射(创建 + 修改)同时显式补入:
    'productImageRemark': 'material_list:remark_edit',
    'manualLinkRemark':   'material_list:remark_edit',
无写权限者的请求不会携带该字段进入服务层 —— 是「丢弃本次修改」而非
「写成空值」,因此不会误清既有内容。

★ 超管不受影响:base.py 的 get_current_user_permissions() 对超管返回的
  硬编码列表以 'material_list:*' 开头,命中通配符分支后整段过滤被跳过。

验证(6 个角色 × 读写,12 项断言全通过)
    超管 / 主管 / 库管    → 读✓ 写✓
    入库 / 出库 / 销售    → 读✓ 写✗
2026-09-17 11:07:57 +08:00
681607bd43 feat(borrow): 拒收原因独立成列,与转交备注彻底分离
背景
----
拒收原因此前是**拼进 remark** 的:
    transfer.remark = f"{remark}\n[拒绝原因] {reason}"
前端拿到的是「3333\n[拒绝原因] 5555」这样一坨,时间线上两句挤在一起,
无法分辨哪句是发起备注、哪句是对方拒收的原因。

改动
----
· trans_borrow_transfer 新增 reject_reason text 列;
  reject_transfer 改为写入该列,不再拼进 remark。
· 存量按 '[拒绝原因] ' 标记切分回填(实测仅 #22:
  remark 3333 / reject_reason 5555)。
· 时间线事件带出 reject_reason,前端才能分行展示。

★ 为什么拆列而不是让前端解析字符串
  1) 拼接格式是隐式契约:改分隔符或加前缀,前端解析就静默失效且难排查;
  2) 用户完全可能在备注里自己打出 '[拒绝原因]' 字样,按标记切分必然误判 ——
     已加测试用例锁定该场景;
  3) 结构化字段才能参与查询与统计(如按拒收原因归类)。
  存储层能表达的东西,不该靠字符串约定去还原。

★ 一个迁移期踩到的坑:btrim 默认只去空格、不去换行。
  拼接留下的是 '3333\n',只写 btrim(x) 会残留换行;必须显式给出字符集
  btrim(x, E' \t\r\n')。已修正脚本并对存量做了一次清理。

验证(7 项断言全通过)
  备注不被污染、原因写独立列、无原因时为 None、
  用户备注含同名标记也不误判、库存零副作用、数据零残留。
2026-09-17 10:46:31 +08:00
f24797ff1f feat(borrow): 拒收须告知发起方(责任回到他手上,不能静默)
背景
----
双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却
对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表,
就会误以为已经交接出去,责任链出现静默断点。

(ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)

改动
----
· trans_borrow_transfer 新增 reject_seen_at(NULL 且 REJECTED = 尚未告知)。
  ★ 为什么需要持久标记而不是前端去重:换台电脑、换个浏览器就会重新提醒;
    而这条信息的分量(责任归属)值得一个持久标记。
  ★ 存量已拒绝的流水一律标记为已告知:它们产生于本功能上线之前,
    追溯提醒只会打扰(实测仅 1 条:#22,验收时的测试数据)。
· get_unseen_rejects(user_id):返回「我发起、被拒、尚未告知我」的转交,
  并批量解析物料名 —— 只说「某笔转交被拒」发起方仍不知是哪件东西还在
  自己手上,必须让他一眼认出来。
· ack_rejects(user_id, ids):发起方确认后写 reject_seen_at,幂等。
· GET .../transfer/pending-count 的响应并入 rejects:与待接收数量共用同一次
  轮询,前端不必多打一个请求。
· POST .../transfer/reject-ack:无 permission_required,同 accept/reject。

顺带补一处同源显示缺口
----
流转时间线里,被拒绝的转交与成功的长得一模一样 —— 发起方翻记录时同样会
误判。现将转交状态一并带出时间线事件。

验证(15 项断言全通过)
----
发起方收到待告知的拒绝(含物料名/接收人/拒绝原因);接收人与无关人看不到;
ack 后不再提醒且幂等;ACCEPTED 不产生告知;None/非法 user_id 均安全返回;
库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
1c58789fd9 feat(borrow): 新增「待我接收」转交数量接口
GET /api/v1/transactions/borrow/transfer/pending-count -> { count: X }

用途:双向握手引入后,发起方提交了转交,接收人若不来借还记录页主动查看就
完全处于盲区 —— 物品挂着「待接收」,责任悬空。该接口供前端做全局强提醒。

设计
----
· 刻意做成极轻量:一次 count,不联表、不解析物料名。轮询接口必须便宜,
  否则会从「提醒」变成「后台噪音」。
· 无 permission_required:接收人可能是普通员工,待办提醒必须人人可见
  (与 accept/reject 同级 —— 员工处置自己名下资产,非库管职权)。
· user_id 为 None / 非法时返回 0 而不是抛错:提醒类接口不该因边界输入 500。

路由无冲突
----
「transfer」匹配不了 <int:borrow_id>,「pending-count」也匹配不了
<int:transfer_id>,Werkzeug 按转换器精确分派。实测:
  GET /borrow/transfer/pending-count -> 401(已注册且受 JWT 保护)
  GET /borrow/11/transfer            -> 405(路径命中但方法不符,证无冲突)

验证(9 项断言全通过)
----
发起后接收人计数 +1、非接收人不变;拒绝/接收后均回落;
None 与非法 user_id 返回 0 不报错;库存零副作用、数据零残留。
2026-09-17 10:30:07 +08:00
4bd6765ab4 feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离)
背景
----
此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 ——
任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为
**只有该物品的当前持有人本人可以发起**。

改动(前后端同改,缺一不可)
----
· service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细
  current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。
· get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端
  localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。
· 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有
  的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来
  (后端会拒,列出来只会误导)。

★ 连带调整:移除 route 上的 permission_required('borrow_transfer')
  责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限,
  再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人
  反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。
  真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。

  ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与
    4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口,
    可在此基础上加豁免;若确定不需要,该权限码可择期下线。

验证(13 项断言全通过)
----
· 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒
· 持有人本人发起成功,from_user_id 正确记为持有人
· can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True
· 接收环节不受影响;库存零副作用、数据零残留
2026-09-17 10:20:34 +08:00
7d9cdeb297 feat(borrow): 转交粒度下沉到明细行,支持部分转交
背景(业务方推翻上一轮约束)
----
上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个
持有人」当作 bug 去修。业务方验收后明确纠正:

    物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人),
    单内多持有人才是符合现实的正常状态。

故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。

改动
----
· transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。
  转出方 = 该行当前持有人;数量 = 该行待还量。
· accept_transfer:只转移 transfer.borrow_id 指向的那一行 ——
  整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。
· 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」:
  同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。
· get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no),
  否则同单多项待接收会互相覆盖。
· 删除已无用的 _load_slip_for_update。

★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id,
  「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中
  「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用;
  接口对传入的非整行数量会明确提示「应另立一条明细行」。

数据层
----
无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与
分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。
存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动**
—— 它现在不再是 bug,而是部分转交的正常形态。

验证(合成 2 明细单,21 项断言全通过)
----
· 只转工具A:工具B 完全不受影响
· 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒
· accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人)
· 两个持有人、以及待接收人,三方各自都能在列表中看到该单
· pending_transfer 挂在正确的明细行上,is_mine 判定正确
· reject 后主表持有人不变;非整行数量被拒并提示拆行
· 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:12:31 +08:00
1a8e3e3dc0 feat(borrow): 转交改为整单覆盖 + 双向握手,并修复接收人可见性
一、整单覆盖(修复漏行 / 单内撕裂)
----
transfer_borrow 现在按 borrow_no 定位**整张单**,覆盖全部未还明细;accept 时
整批转移持有权。原先只改传入的那一行,2 明细的单转完会出现两个持有人。

新增 _load_slip_for_update():按单号锁整单,且按 id 升序取行锁 —— 并发下所有
事务以相同顺序加锁,避免与归还/转交交叉加锁死锁。

二、双向握手
----
· transfer_borrow(发起):只落一条 PENDING 流水,**不再改主表 current_holder**。
  东西还没到对方手上,责任仍归原持有人 —— 这是与旧实现最本质的区别。
  同一单号已有 PENDING 时拒绝再次发起,避免两个接收人争抢同一批实物。
· accept_transfer:流水置 ACCEPTED,把该单**全部未还明细**的持有人改为接收人。
. reject_transfer:流水置 REJECTED,主表不动。
  两者都强校验「当前登录人 == to_user_id 本人」。

★ accept/reject 刻意**不加 permission_required**:这不是库管职权,而是员工对
  自己名下资产的确认动作,加库管权限会把接收人挡在门外。

三、接收人可见性(OR 过滤)
----
get_records 普通用户过滤原先只比对 borrower_name,接收人在自己的列表里看不到
已经接收的东西。现改为三种关系任一成立:
    ① 我是借用人
    ② 我是**当前持有人**(转交接收后)
    ③ 有一条**待我接收**的 PENDING 转交 —— 东西还在对方手上、主表尚未转移,
       ② 匹配不到,必须单独并入,否则接收人看不到待办、无从确认
ID 与姓名双口径并存,兼容只有姓名没有 ID 的历史行。

列表项附加 pending_transfer(含后端判定的 is_mine)—— 前端 localStorage 里
只有 username 没有 user_id,靠姓名比对既有歧义又不可靠,故由后端标记。

四、验证(合成 2 明细单,25 项断言全通过)
----
· 发起后两条明细持有人均未变(责任未转移)
· 非接收人无法 accept / reject;重复发起被拒
· ★ accept 后**两条明细**持有人一并转移(漏行修复的核心)
· 接收前凭 PENDING 分支可见、接收后凭 current_holder 可见
· reject 后主表持有人不变
· 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:04:12 +08:00
b271ca3a49 feat(borrow): 转交流水表补 borrow_no 与 status(双向握手数据层)
背景
----
1) **单内撕裂**:原 /transfer 收的是**明细行 ID**,只更新那一行。一张单有
   2 条明细时,转交后一条归接收人、另一条仍是原借用人 —— 前端按 borrow_no
   聚合便同时显示两个名字(实测 BOR-20260917-0001:108→测试 / 109→杜邢宸)。
2) **单向强塞**:原实现发起即生效,接收人在毫不知情的情况下背上资产责任。

本次改动
----
· 新增 borrow_no —— 单据身份。一次转交要覆盖该单**多行**,单行 ID 表达不了
  覆盖范围;accept 时据此批量更新。borrow_id 保留为「发起时的代表明细」供追溯。
· 新增 status —— PENDING / ACCEPTED / REJECTED 状态机。
· 存量 1 行按旧语义(发起即生效)标记为 ACCEPTED 并回填 borrow_no:
  它事实上已经生效,若标 PENDING,接收人会收到一条早已生效的待办。
· 建 borrow_no / status / (to_user_id,status) 索引,后者支撑「待我接收」查询。

同单号同一时刻只允许一条 PENDING(应用层强制),避免两个接收人争抢同一批实物。
2026-09-17 10:04:05 +08:00
15d0c6b97a fix(borrow): 修复借还记录排序被静默丢弃,无限期改按借出时间从近到远
现象
----
借还记录列表的排序看起来毫无规律:无限期单排在有限期前面,有限期内
10-01 排在 11-01 之后。业务方反馈「不是逾期的、剩余天数最近的排前面吗?」

根因
----
三级复合排序(trans_service get_records 步骤 2)算得完全正确,但**结果被
后面一步覆盖**:

    # 步骤 2:算出分页用的 page_borrow_nos(顺序正确)
    # 步骤 3:再按集合把明细拉回来 ——
    detail_records = TransBorrow.query.filter(borrow_no.in_(page_borrow_nos))
                          .order_by(TransBorrow.borrow_no.asc(), ...)

单号形如 BOR-YYYYMMDD-NNNN,**它的字母序恰好等于借出日期序**。于是这 10 条
明细被重排成「按借出日期升序」,那份精心设计的排序被整套丢弃。

实测(修复前,未归还页签第 1 页):
    1 BOR-20260413-0001  无限期  04-13   ← 无限期在最前
    4 BOR-20260611-0001  无限期  06-11
    5 BOR-20260903-0010  逾期    09-10   ← 逾期单反而最后
    8 BOR-20260904-0005  10-01            ← 10-01 排在 11-01 之后

★ 该功能自上线起从未生效:
    1450e6c (06-16) 引入按 borrow_no 重排的明细拉取
    73510d3 (09-04) 才加入三级复合排序 —— 加在了被覆盖的路径上,
                    提交信息「借还记录默认排序重构」名存实亡。

修复
----
按 page_borrow_nos 的顺序还原输出(明细内部仍按 id 升序,即扫码顺序)。
同时按业务方要求调整第二梯队方向:

  ① 有限期单在前(有任何明细含预计归还时间)
  ② 有限期内按单内最早预计归还时间**升序** —— 逾期优先,其后剩余天数由近到远
  ③ 无限期内按单内最早借出时间**降序**(从近到远)
     ★ 原为升序「借出越久越靠前,暴露呆滞借用」,业务方明确要求反转

修复后实测(未归还页签):
    有限期 09-10(逾期7天) → 09-11(逾期6天) → 09-15(逾期2天) → 10-01 → 11-01 → 11-27
    无限期 09-17 → 09-14 → 09-11 → 09-10 → … → 04-13(跨页连续)

验证:borrowed / returned 两个页签各 3 页顺序全部核对通过;关键词、物料名、
高级筛选、日期范围、空结果六条过滤路径冒烟通过;同单号明细未被跨单号打散。
2026-09-17 09:53:26 +08:00
8d83d4404e fix(borrow): 恢复选中审批单后的「一键带出」,并补上领用人自动回填
现象
----
借库执行页选中一张已通过的审批单后,下方的「预计归还日期」「备注说明」
不再自动带出;「领用人」在改为用户下拉后也恒为空,库管每次都得手工补填。

根因(不在前端下拉框,而在后端持久化)
----
borrow_service.submit_approval 会把 reserve_for_items() 的返回值直接
set_items() 落库 —— 即审批单的 items_json **就是预占结果**。

而 reserve_for_items 内部是 `reserved.append({...})` **重建**字典,只保留
库存定位与数量,把申请端按明细传上来的 expected_return_time / is_indefinite
全部丢弃。执行页读的正是 firstItem.expected_return_time,读不到就落到
「清空」分支。

这是 2026-09-10 库存预占改造引入的回退:

    b57c21a  fix 前:  approval.set_items(items)           # 原始申请明细
    b57c21a  fix 后:  approval.set_items(reserved_items)  # 重建字典,字段丢失

数据完全吻合该提交的上线时刻(09-10 14:59):
    最后一张「有日期」的审批单  id=40  创建于 09-10 09:30
    第一张「丢失日期」的审批单 id=41  创建于 09-10 16:20

修复
----
1) inventory_reservation.reserve_for_items:透传申请「意图字段」
   (expected_return_time / is_indefinite / remark)到预占结果。
   · 一条申请明细会被拆到多个批次行(同物料多批次),故按 base_id 建索引,
     把同一份意图回填到它拆分出的每一行;
   · **只透传意图,不透传库存定位与数量** —— 实扫可能换批次,
     这里写什么执行页就按什么释放,绝不能覆盖分配结果;
   · 跳过 None:无限期借用提交的 expected_return_time 本就是 null。

2) borrow.vue handleApprovalChange:实现领用人自动回填
   · 口径差异:审批单上的 borrower_name 是**完整 username**
     (「杜邢宸/duxingchen」),人员名单返回的是展示名(「杜邢宸」)。
     两边归一化到「斜杠前段」再比对,否则永远匹配不上;
   · borrower_name 优先,匹配不到才回退 applicant_id —— 库管代建时申请人是
     库管本人,回退到它会选错人;
   · 名单加载改为可重复 await:选中单据时要拿它反查,名单没回来就比对会
     误判为「找不到」而清空;
   · 匹配不到时保持未选,不回退到自由文本 —— 借用人 ID 是转交/归还责任链的
     唯一锚点,宁可让库管手选也不能猜。

可编辑性
----
三个字段均保持可改:领用人下拉、备注文本框无 disabled;日期选择器仅在勾选
「无限期/长期借用」时置灰(原有语义,取消勾选即可重新填写)。

影响与验证
----
· 存量:仅 1 张待执行审批单(id=46)受影响,其日期在库中已无任何留存,
  无法回填,需库管手工补一次;已完结单据不受影响。
· 回归:reserve_for_items 透传 9 项断言、submit_approval 端到端 5 项断言
  全部通过;库存精确还原、无残留数据。
2026-09-17 09:26:42 +08:00
f7c49f41a8 fix(borrow): 统一归还/报废时间口径为北京时间,并修正过期注释
时间口径(与归还流水同时引入,故随本轮一并修)
----
process_return 与 scrap_sources.deduct 原用 datetime.now() 写 return_time,
而 datetime.now() 取的是**容器本地时间**(Docker 下为 UTC);同一行的
borrow_time 却由 beijing_time() 写入 —— 两个字段差 8 小时,台账时间线
自相矛盾,并会让新做的流转时间线出现「先借出、后归还,却显示归还更早」
的倒序假象。

统一改用 beijing_time(),并与新增的归还流水共用同一个时间戳,保证主表快照
与流水逐笔完全对齐。

(与 db_migrations/unify_approval_timezone.sql 处理的是同一类问题:aware/naive
与本地/北京时间的口径混用。)

注释修正
----
borrow_service.submit_approval 的 docstring 写着「仅存储意向,不扣库存」,
但 Phase 1 起该函数已调用 reserve_for_items() 预占库存(扣 available_quantity)。
过期注释会误导后续开发者(尤其是做转交的人以为库存没动过),已改写为实际的
预占语义与三种释放路径(驳回 / 撤回 / 扫码执行)。
2026-09-17 09:18:02 +08:00
cccd6f6081 feat(borrow): 转交接口、身份ID锚点与流转时间线后端
责任链收口
----
· execute_dispatch:强制 borrower_id,姓名一律由 sys_user 反查,**不回退**到
  申请单姓名 —— 申请单上的姓名是「申请意向」,与扫码时实际来领的人本就可能
  不同(库管代建场景尤甚),回退会让 current_holder 从第一刻就记错人。
  同时落库 dispatch_operator,补齐「谁经手发货」。
· process_return:新增 returner_id,记录有 current_holder_id 时强校验二者相等,
  不符即整单回滚(原实现只要持有 op_return:operation 即可归还任何人借出的
  物品,函数内从不读取 borrower_name,归还环节责任链是断的);
  每次归还写 trans_borrow_return 流水;全量归还清空 current_holder
  (borrower_id 保留作历史)。
· transfer_borrow(新):整单全量转交,写转交流水 + 推进主表 current_holder。

为什么一期只允许整单全量转交
----
trans_borrow 是单行模型,只能存一个 current_holder_id。部分转交(借 10 转 5)
会让这一行同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
若未来需支持,必须改为按数量**拆行**(新建一条承接转出量、原行扣减),
而不是在单行上加字段打补丁。

为什么转交绝不触碰库存
----
借出期间 available 已冻结、stock 仍含借出未还量(deduct_stock=False)。
转交若动库存,会同时破坏两个既有假设:「归还只加 available」会算多,
「借库转报废在确认损失时扣 stock」(scrap_sources.BorrowScrapAdapter)
会重复扣减。

接口
----
· POST /borrow/<id>/transfer      转交(borrow_transfer 权限 + 防抖锁 + 行锁)
· GET  /borrow/<id>/history       单品流转历史(供精确追溯)
· GET  /borrow/slip/<no>/history  整单时间线(实测单号最多 21 条明细,
                                  逐条调用会产生 21 个请求,故聚合返回)
· GET  /borrow/users              人员名单(借出/转交/归还共用,公司隔离)

一个隐蔽缺陷(本轮发现并修复)
----
整单时间线初版按展示用的时间字符串排序,而该字符串截断到**秒** —— 同一秒内的
连续动作(如「转交 A→B 紧接着转交 B→C」)会退化为并列,顺序取决于数据库返回
顺序,时间线随机错乱。已改为按真实 datetime 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
b998b00889 feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列)
背景
----
原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串:
「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时
return_time/return_operator/return_signature 被逐次覆盖,部分归还下
「谁在什么时候还了多少」永久丢失。

本次改动(全部增量,无破坏性 DDL,可回滚)
----
1) trans_borrow 补列
   · borrower_id / current_holder_id / current_holder_name —— 身份锚点
   · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库)
2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新
3) 新建 trans_borrow_return   —— 归还流水,逐次记录,根治部分归还失忆症
4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user;
   未归还的 32 行全部绑定 current_holder
5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms
   前缀桥接把权限放大给所有持有 op_borrow:operation 的角色)

设计取舍
----
· current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。
  这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号,
  归还校验不会对已结清单据误触发。
· 三张表都不建外键:与 trans_return / trans_defective_goods 一致 ——
  库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。
· dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的
  数据源;用申请人或借用人冒充实物交接人比留空更危险。

验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
d1337d12e1 fix(scrap): 修复扫码时库存行遮蔽在管不良品导致的「不在批准明细中」
现象
----
报废申请单指向在管不良品时,待执行清单里明明能看到该 SKU,扫码却报
「不在该报废申请单的批准明细中,禁止报废」。

根因
----
在管不良品的 SKU 是从原库存行**复制**的(退回时 sku=getattr(stock_row,'sku','')),
因此同一个条码可能同时命中 stock_buy#M 与 trans_defective_goods#N —— 这是
**两个不同的实物**。

而 ScrapService.get_stock_by_barcode 原实现按固定顺序
(stock_product → stock_semi → stock_buy → trans_defective_goods)返回
**首个命中**。原库存行只要还在,就永远遮蔽在管不良品,扫码结果恒为
stock_buy。改造前双方都用 SKU 作为匹配键,遮蔽不暴露问题;上一轮给匹配键
加上来源表后,`sku:trans_defective_goods:X` ≠ `sku:stock_buy:X`,
问题才浮出水面。

实测复现(业务报障的 SKU 0000000590):
    stock_buy#589           在库 6 件
    trans_defective_goods#24 在管 1 件(同一 SKU)
    批准明细期望 -> ('trans_defective_goods', 24)
    扫码实际返回 -> stock_buy#589   → 键不匹配 → 报错

修复
----
条码本身无法区分这两个实物,**只有单据上下文能决定该扫到哪一个**,故把
单据上下文引入扫码解析:

- get_stock_by_barcode(barcode, prefer_pairs=None):改为**收集全部候选**,
  再按 prefer_pairs(当前申请单批准明细的 (source_table, stock_id) 集合)
  优先命中;无上下文时退回固定顺序,行为与改造前一致
- GET /scrap/scan 新增可选 request_id:据此加载该单的批准明细构造优先集。
  优先集构造失败不阻断扫码,仅告警并回退默认选路
- 前端 scanBarcode(barcode, requestId) 与 create.vue 调用处带上当前申请单 id

验证(隔离数据端到端,非仅单元):
    同一 SKU 建于 stock_buy#2202 与 trans_defective_goods#25
    不带上下文扫码 -> stock_buy#2202          (命中批准明细? False ← 旧行为)
    带上下文扫码   -> trans_defective_goods#25(命中批准明细? True)
    执行后:在管 2→0、状态=已报废;**库存行 10/10 分毫未动**(未误扣错误实物)
2026-09-16 17:22:51 +08:00
58fa42bff3 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。
2026-09-16 17:14:17 +08:00
e4d2b2ec68 feat(scrap): 报废来源适配层(纯新增,零行为变更)
把「报废来源差异」从审批服务里抽出来,为后续把借出未还、在管不良品
接入审批流铺路。

背景
----
报废审批流原先只认三张库存表(ScrapApprovalService._stock_models 硬编码),
导致另外两类来源只能各走直报接口绕过审批 —— 与系统自陈的
「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL)冲突,构成职责分离
漏洞:同一个库管可自行宣告实物销毁而无人复核。

三类来源的语义差异
------------------
  维度        库存行            在管不良品        借出未还
  执行模式    scan              scan              auto(免扫码)
  可报废量    available_qty     remaining_qty     quantity-returned
  扣减        available 与      只动在管台账,    只扣 stock_quantity
              stock 同扣       不碰任何库存表    (可用量已在借出时冻结)
  成本        沿用现口径        defective_unit_cost  0/0

★ 为什么在管不良品保留扫码、借出未还免扫码:
  坏件实物就在仓库里、有 SKU,扫码是有效的「申请报 A、实际毁 B」防护;
  借出物在借用人手上,物理上不可能扫到,且执行只改台账与总库存、
  不产生任何可被挪用的可用库存,风险等级不同量级。

其他要点
--------
- defective_unit_cost() 从 api/v1/inbound/stock.py 迁入本模块:服务层不得
  反向 import API 层。复用 inventory_reservation.stock_model_map(),
  避免第四份库存表字典。
- 提交期新增 submit_guard 钩子,用于修复「提交只校验 available、执行却校验
  both」的校验不对称(会出现申请通过、执行必失败的单据)。
- is_scan_source() 对未知来源返回 False(Fail-Closed):扫码通道只接纳明确
  声明为 scan 的来源,杜绝 trans_repair 那类「扫得到、执行却拒绝」的错配。

本步不触碰任何现有路由,现网仍走老路径,可独立评审与验证。
2026-09-16 17:14:05 +08:00
9eb4792d4a feat(return): 退回流水看板接口与权限收口
新增只读台账接口:
- GET /api/v1/outbound/returns  退回流水(分页 + 关键词 + 类型 + 时间过滤)
  返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 /
  操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按
  多态来源批量解析,均为批量查询无 N+1。

权限收口(配合 db_migrations 里的三个权限码):
- return-from-outbound   inventory_stocktake:operation -> outbound_return
- GET /stock/defective   inventory_stocktake           -> defective_list
- restock                inventory_stocktake:operation -> defective_restock
- scrap                  inventory_stocktake:operation -> defective_scrap
- change-status          inventory_stocktake:operation -> stock_change_status

  原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。
  实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份
  不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后,
  实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。

trans_return 补 company_name 快照:
  退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除
  (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户
  静默消失。改由退回时落快照,隔离不再依赖任何 join。
2026-09-16 16:45:52 +08:00
dda6e4c787 feat(return): 退回、回库、报废与在管台账接口
打通逆向物流的全部后端入口。

新增接口(app/api/v1/inbound/stock.py):
- POST /stock/<id>/change-status      库存状态变更(在库/冻结/不良品)
- POST /stock/return-from-outbound    通用原单退回
- POST /stock/defective/<id>/restock  不良品修好回库(支持部分回库)
- POST /stock/defective/<id>/scrap    不良品报废销毁
- GET  /stock/defective               在管台账分页查询

设计要点:
- 良品退回加回原库存行;不良品退回则库存表分毫不动,只写独立在管台账。
  这样坏件从根上不会混进可分配池
- 全链路 Fail-Closed 守卫:良品退回到非「在库」行会被拒(status 是行级
  属性,加回已冻结/不良品的行会让良品被连带隔离);原库存行已不存在会被
  拒(入库模块会物理删除库存行,实测 1077 条出库记录中已有 7 条悬空)
- 三个写接口均加 with_for_update 行锁 + prevent_double_submit 幂等锁。
  装饰器顺序为 permission_required → prevent_double_submit,顺序颠倒会因
  JWT 未验证而抛错、被自身 except 捕获后 fail-open 降级
- 报废同时写 trans_scrap 台账(source_table 用 trans_defective_goods 并
  存台账自身主键,与 trans_borrow/trans_repair 作来源时的约定一致),
  成本按原库存行 best-effort 取价,取不到记 0 而不中断报废

报废报表集成(app/api/v1/scrap.py):
- _resolve_materials 补 trans_defective_goods 分支。物料名已在台账冗余存储,
  不联表——坏件的原库存行可能已被删除,联表取名称会得到空值
- 公司隔离补 defective_subq 分支。原先按 source_table 逐个构造子查询,
  未知来源会被整体过滤,导致这类记录对普通用户静默消失
- 无审批单号的分组前缀按来源分流:不良品直报不再套用 LEGACY-(它是新业务
  记录,不是历史脏数据)。分组键同步带上来源标记,且 _order_key_pred 的
  SQL 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
69c38a1bf7 feat(return): 逆向物流数据模型与迁移
新增原单退回与不良品在管的持久化结构。

- TransOutbound 增 returned_quantity(numeric(19,4),非 float):该值参与
  「return_qty <= quantity - returned_quantity」判等,浮点误差会让反复部分
  退回后出现「已退满却判定未退满」的错判
- 新增 TransReturn:退回流水,每次退回写一条而非覆盖式更新。刻意与
  trans_borrow 划清界限——后者部分归还时会覆盖 return_time/operator,
  导致归还历史永久丢失
- 新增 TransDefectiveGoods:不良品在管台账。坏件全程不入库存表,因为
  status 是行级属性而质量是件级属性,把坏件加回原行只能整行打不良
  (实测 stock_buy 单行最大 4789 件、中位 8 件,整行打不良会凭空损失良品)
- 状态机:待处理 → 处理中 → {已回库|已报废|已闭环}。终态由累计去向推导
  而非「最后一次动作」——一批坏件可能既回库过又报废过,按最后动作定状态
  会产生误导
- restocked_qty/scrapped_qty 两列:二期用 quantity-remaining_qty 反推回库量,
  三期加入报废出口后该反推失效
- 审计白名单与模型预加载同步登记(监听器绑定 18 → 20 个模型)

迁移脚本均为纯追加式 DDL,含预检、回滚段与执行后核对。首个脚本用
COALESCE 包裹数量列——库存表允许数量为 NULL,而「NULL 大于 0」求值为
NULL 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
2026-09-16 15:45:22 +08:00
fbc9296056 feat(stock): status 硬隔离与出库通道封堵
激活库存表长期「只写不读」的 status 列,使其成为分配准入的硬门槛。

- inventory_reservation: 新增状态语义单一事实来源(STOCK_STATUS_*/
  allocatable_filter/is_allocatable);verify_scanned 增加状态准入校验
  (覆盖出库与借库两条提交路径)
- outbound: _allocate_bom_requirements 过滤条件加 allocatable_filter,
  非「在库」一律不进入候选集;扫码路由把 ValueError 转为 400
- outbound_service: create_outbound_batch 强制 request_id 必填。原先无单
  时会跳到所谓「散单」分支,而该分支的扣减逻辑从未落地(注释点名的
  _apply_reservation_override 全仓不存在),实测只写台账不扣库存,
  同一批货可被反复出库;get_stock_by_barcode 接入扫码准入;维修单扫码
  补排除「报废转出」;预占再平衡块补 rollback 兜底
- fill_missing_stock_status.sql: 把存量行刷回「在库」。该过滤是
  Fail-Closed 的,不先洗刷会让全库无法出库

破坏性变更:不传 request_id 的出库请求现在会被拒绝。
2026-09-16 15:45:02 +08:00
b695592178 feat(stocktake): 明细去 SKU 改显批号/序列号,备注可选可填,列宽适配平板
【1. 去 SKU】明细表删除 SKU 列,新增「批次号」「序列号」。
SKU 是账务口径的物料主键,现场照着它抄数等于把账面信息递给盘点人。
(搜索框仍支持按 SKU 搜,只是列表不展示。)

后端 merged-list 的 UNION ALL 需要小心:stock_product 表**没有
batch_number 列**(只有 serial_number),故该分支必须写 '' AS batch_number
占位,否则列数不齐 / column does not exist。实测成品行 batch='' serial='205'。

【2. 列宽】名称/规格改 min-width=120(超长省略+tooltip);库位固定 120;
账面数/实盘数/差异固定 100,保证平板上输入框有足够触控面积;备注 min-width=160。

【3. 备注升级为可选项】el-input 换成 el-select(filterable allow-create
default-first-option),预设 ['包装破损','找不到实物','标签丢失','账实不符','实物变质'],
可点选也可自行输入。

★ 顺带修一个真 bug:handleRemarkChange 原本是个空函数,只 console.log,
  备注填了**根本没落库**。现复用 update-quantity 接口保存,后端新增可选的
  remark 字段(用 `remark is not None` 判断,只改数量时不会清空备注)。
  未扫过的行没有草稿记录可挂,备注框同样禁用。

实测: 备注落库='包装破损';只改数量后备注仍在(未被清空)。
2026-09-14 09:45:38 +08:00
04ed489ab5 fix(stocktake): 未扫码的行不允许直接填实盘数
【问题】盘点明细抽屉列出全部在范围内的物料,其中尚未扫码的行也可以
直接在「实盘数」列填数字。填完前端发 POST /stocktake/update-quantity,
后端只会 UPDATE 已存在的草稿行、找不到就返回 404「未找到盘点记录」,
于是工人填的数字根本没保存,只弹一句「更新失败」。

【为什么不改成 UPSERT】那等于允许不扫码就填数 —— 任何人照账面抄一遍
就能"完成"盘点,扫码这道工序形同虚设,盘盈盘亏也无从查起。
所以这里保持"必须先有草稿行"的语义,改为在 UI 上把这条路堵住。

【改动】
- 前端:draft_id 为空的行不再渲染 el-input-number,改显示灰色「未扫」;
  已扫行照常可改。失败时提示改为透传后端 msg,并 fetchInventoryList()
  回滚本地被 v-model 改动的值。
- 后端:404 文案由「未找到盘点记录」改为
  「该物料尚未扫码,请先扫描条码后再修改实盘数」,作为绕过前端时的兜底。

实测:
  draft_id 已扫行=11379 → 可编辑;未扫行=None → 不可编辑
  绕过前端直接改未扫行 → 404 该物料尚未扫码,请先扫描条码后再修改实盘数
2026-09-11 15:15:05 +08:00
e573185ea4 perf(stocktake): 库位树按公司前缀后端裁剪,树与推荐并行请求
【后端】/tree 支持 ?prefixes=Y 或 ?prefixes=C,L(逗号分隔)
只在**顶层**按 name / full_path 前缀过滤,命中即整棵子树保留 ——
不递归裁剪,避免把子树打散导致前端勾选语义错乱。不传则全量。
刻意不用懒加载:setCheckedKeys / getCheckedNodes 依赖全树已构建。

实测节点数(含子树):
  全量 3371 → IRIS (Y) 500(↓85%)→ LICA (C,L) 2871(↓15%)
IRIS 收益很大;LICA 的前缀覆盖了树的大部分分支,故提升有限。

【前端】
- getWarehouseTree(prefixes?) 透传前缀,loadLocationTree 从
  getAllowedLocPrefixes(selectedCompany) 取;后端已做过滤,
  前端不再重复过滤,删掉冗余的 filterTreeByCompany。
- fetchRecommendLocations 改为 Promise.all 并行拉树与推荐,
  取代原来的串行 await(两段网络等待不再叠加);
  setCheckedKeys 前仍保留 await nextTick() 等树渲染完。

【顺带修一个上一轮引入的 bug】
右侧自 leafOnly 改造后只存末级路径,而推荐返回的是库存级路径、可能是
非末级,原来的逐字比对必然对不上,会把正常勾选的库位误报成
「未能勾选」。改为按「自身或其祖先」判定覆盖。

实测: prefixes 过滤正确(IRIS 8 个顶层 / LICA 25 个 / 不传 33 个)
2026-09-11 14:45:23 +08:00
e9468673c0 feat(stocktake): 抽盘范围改为「系统推荐 + 人工在库位树上勾选」
【后端】
- 新增 GET /stocktake/recommend-locations?days=30&top_n=50
  调 get_active_locations 返回推荐库位 full_path 列表 + moves/sku_count/
  last_move 明细。只推荐不落库,days/top_n 做范围钳制(1~365 / 1~500)。
- /draft/start-new 在 scope_type=active 时不再自行计算活跃库位,改为读取
  payload 的 scope_config.locations 并校验归一化(去空白、去重、保序、
  空列表 400、上限 2000)。范围决定权交还前端 UI —— 否则用户手改的勾选
  会被后端覆盖。

【前端】欢迎页选中「活跃库位抽盘」时展开配置区:
- 「近 30 天最活跃的前 [N] 个库位」+【获取推荐】
- el-tree(show-checkbox)数据取自 /v1/warehouse/tree,按公司前缀过滤
  (IRIS 只留 Y*,LICA 只留 C*/L*,复用 getAllowedLocPrefixes)
- 获取推荐后 setCheckedKeys 自动勾选;用户可自由增删
- 提交时 getCheckedNodes().map(n => n.full_path) 打包进 scope_config.locations

两个实现细节:
- 推荐里有、但树上勾不到的库位(不在当前公司前缀内等)会明确告警并打印,
  不让它们静默落选 —— 否则工人以为盘到了、实际没进范围。
- 已选库位数实时显示,因为 el-tree 默认级联:勾一个父节点会连带勾中整棵
  子树,规模可能远超推荐数量,需要让用户看得见。

实测:
  recommend-locations(top_n=10) → 10 个库位 + 明细
  start-new 传 3 个库位 → 落库正是这 3 个,总品项 36(全仓 1126)
  不传 locations → 400;勾选为空 → 400
  公司前缀过滤: IRIS→8 个(Y1~Y8),LICA→25 个,无越界
2026-09-11 14:13:10 +08:00
fc95357662 feat(stocktake): 跨公司扫码给精准报错,0 库存盘盈在明细中可见
【跨公司拦截】
get_stock_info 带 company_name 过滤后,扫到别家公司的货会直接查不到,
返回 404「未找到物料」—— 与「条码根本不存在」完全无法区分,现场人员
既不知道是扫错了还是扫了别人的货。

新增 find_stock_owner_company():不施加公司过滤地全局查条码归属;
get_stock_info 未命中时由 _classify_missing_stock() 复核,区分两种情形:
  · 条码存在但属于别家公司 → 403「条码 [X] 属于【LICA】,请勿跨公司盘点」
  · 条码不存在             → 404「未找到该物料库存: X」

该方案的前提「SKU/条码全系统唯一」已核验成立:三张库存表的表内重复、
跨公司重复、跨表重复六项检查均为 0,故归属至多命中一条,无歧义。
/scan 与 /draft/add 两个入口均已接入。

【0 库存盘盈可见性】
merged-list 的 union_sql 原本严格要求 stock_quantity > 0,导致账面为 0、
但已被扫入的盘盈物料不出现在明细抽屉里,而 total_scanned 却把它计入
「已盘」—— 工人看到已盘 +1 却在明细里找不到,以为系统丢了数据。

现改为 stock_quantity > 0 OR id IN (本会话该表的草稿 stock_id)。
条件严格限定在**本会话**的草稿,不会把全库 0 库存物料都放出来。

实测:
  IRIS 扫 LICA 条码 → 403「条码 [0000002097] 属于【LICA】,请勿跨公司盘点」
  扫不存在条码      → 404「未找到该物料库存: NOPE-99999」
  已扫的 0 库存物料 → 明细可见(账面 0 / 实盘 5 / 差异 +5 / 库位 Y3/3/3/4)
  未扫的 0 库存物料 → 明细 0 行(范围正确)
2026-09-11 14:05:59 +08:00
eabe70b00a feat(stocktake): 落地动盘(活跃库位抽盘)与导出盲盘保护
【导出收尾】/export-stocktake
会话 active 且 mode=blind 时,汇总表/差异明细/账实相符/未盘点四张 Sheet 的
账面数与差异数一律输出 '—',实盘数照常输出。会话 finished 后自动解锁 ——
盘点结束后核对差异是正常流程,否则报告本身没法用。
批量替换时误伤了 update_stocktake_quantity 里的差异计算,被静态检查脚本
抓出(会拿占位符 '—' 做减法),已改回按真实账面值计算。

【动盘算法】get_active_locations(company_name, days=30, top_n=50)
近 N 天异动最频繁的库位。出库经 (source_table, stock_id) 回查库存表取库位
(trans_outbound.warehouse_location 历史上 900 条里 829 条为空,实测回查
覆盖率 298/299);入库以库存表自身的 in_date / production_date 计时;
借出用 trans_borrow.location。报废/维修表无库位字段,未纳入。

【范围冻结】/draft/start-new
移除 scope_type=active 的 400 拦截,改为在开单时算出活跃库位并**冻结**进
scope_config。必须开单时算好 —— 若各接口实时计算,两次调用之间活跃度变化
会导致范围漂移,结束盘点时把已掉出范围的库存误判成盘亏。

【同源过滤】all-items / merged-list / generate-missing
三者都读同一份冻结的 scope_config。generate-missing 是重中之重:漏盘比对
只针对范围内库存,绝不把范围外未盘库存标成盘亏。
merged-list 一并加了过滤(原指令未提),否则抽屉仍会列出范围外物资,
与「总品项」对不上。

【前端】移除「活跃库位抽盘」的 disabled。

实测(IRIS,全仓 1126 → 抽盘 537):
  生成盘亏 537 条 | 落在抽盘范围内 537 | 落在范围外 0
  导出: 盲盘 active → 账面数/差异数 = '—';盲盘 finished → 76 / -76(解锁)
2026-09-11 13:41:56 +08:00
97c1a9131c feat(stocktake): 明/盲盘由后端强制驱动,前端解除屏蔽
原实现是前端把「账面数」「差异」两列用 HTML 注释藏掉 —— 数据仍在
HTTP 响应里明文下发,抓包或用改过的客户端即可看到,等于没有屏蔽。

【后端】/draft/merged-list 先查该 session_id 的 StocktakeSession.mode:
- blind:stock_qty 与 diff_qty 一律置 None,真实账面数不出现在响应中
- open :正常下发
差异必须一并置空 —— diff_qty 由账面数算出,下发差异等于变相泄漏账面数。
响应新增 data.mode 供前端提示。

Fail-Safe:查不到会话行时按盲盘处理。宁可界面少显示两列,也不能因为
会话记录缺失就把账面数泄漏出去。

【前端】删除屏蔽用的 HTML 注释,恢复两列正常渲染,不再承担数据屏蔽职责。
但 null 必须渲染为「—」而非 0:原 差异 模板用 `row.diff_qty || 0`,
会把「未下发」显示成「无差异」,工人会误以为账实相符。
另在抽屉表头加模式标签,便于核对。

实测:
  盲盘 → 账面数=None 差异=None   mode=blind
  明盘 → 账面数=1.0  差异=-1.0   mode=open
  未知 session_id → 同盲盘(Fail-Safe 生效)
2026-09-11 13:34:36 +08:00
3f964614e3 feat(stocktake): start-new 落库会话配置,active-session 改查会话表
- /draft/start-new 接收 mode / scope_type / scope_config,插入一条
  StocktakeSession(status='active')。同一公司原有活跃会话先置为 finished
  (同一事务),否则会撞 uq_stocktake_session_one_active。
  公司取值:普通用户强制本公司;跨域角色必须显式指定,否则 400。
  scope_type 目前只放行 'full' —— 抽盘的范围过滤尚未实现,此时放行会导致
  结束盘点时 generate-missing 把抽盘范围外的库存批量标成盘亏。

- /draft/active-session 改为直接查 stocktake_session
  (company_name + status='active'),并返回 mode / scope_type 供前端展示。
  旧实现靠 max(scan_time) 从草稿行猜,空会话识别不了。

- /stocktake/generate-missing 漏盘比对完成后把会话置为 finished,
  否则 active-session 会继续把它当活跃会话,其他 PDA 会加入一个已结束的盘点。

实测:
  ① 超管+IRIS 创建 blind/full 会话 → 200
  ② SALES/IRIS 查活跃会话 → 拿到同一 session_id 与 mode=blind
     (scanned=0 的空会话也能查到,旧实现此处返回 null)
  ③ 再开一轮 → superseded_count=1,旧的转 finished
  ④ scope_type=active → 400「活跃库位抽盘尚未实现」
  ⑤ DB 层验证唯一索引拒绝重复活跃会话
2026-09-11 13:28:49 +08:00
aeb852c64d feat(stocktake): 新增盘点会话表 StocktakeSession
盘点原本只有 stocktake_draft(草稿行),没有会话实体,导致三个问题:

1. 盲盘/明盘、全盘/抽盘这类**会话级配置**无处存放,塞进草稿表就要每行
   冗余一份,改一次配置得 UPDATE 上万行;
2. 「空会话」无法表示 —— 会话开了但还没扫码时草稿表里没有行,旧实现只能
   靠 max(scan_time) 猜哪个会话活跃,于是新开的空会话会被其他 PDA 忽略、
   反而加入上一轮的旧会话,多端协同直接分裂;
3. 没有状态字段,generate-missing 跑完后旧会话仍被当成活跃。

本表把活跃会话判定变成 company_name + status='active' 的精确查询。

迁移的要点:
- 唯一部分索引 uq_stocktake_session_one_active 保证「一个公司同时只有一个
  活跃会话」,从数据库层挡住多台 PDA 并发开启导致的进度分裂;
  因此 /draft/start-new 必须先把原有活跃会话置为 finished 再插入新记录。
- CHECK 约束限定 mode/scope_type/status 的取值域。
- 不回填存量:旧草稿没有对应会话行,部署后显示「无进行中的盘点」,数据不丢。

执行: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_stocktake_session.sql
2026-09-11 13:28:43 +08:00
f8403d2fb2 feat(stocktake): 活跃会话接口补充已扫件数与发起人
多人协同场景下,加入者需要知道「谁开的、盘到哪了」,原接口只返回会话ID和
行数,信息不足以提示。

- /draft/active-session 新增 scanned(按 source_table+stock_id 去重,
  且排除 user_id='system' 的自动漏盘记录,避免进度虚高)
  与 initiator(该会话最早一条人工扫码记录的操作人姓名)
- 把 export_stocktake 内嵌的 get_user_name 提到模块级 _resolve_user_name
  供两处复用,并补齐它漏掉的 user_id 格式:
    盘点/流水里实际存的是 JWT 的 display_name,形如 "孙霞(sunxia)",
    而原实现只认 SysUser.username 的 "姓名/账号" 格式,导致解析不出来时
    原样返回。现兼容 "姓名(账号)" / "姓名/账号" / 纯数字ID 三种。

实测: SUPER_ADMIN 无参数 → scanned=1117, initiator='韩善龙'
      SALES + IRIS    → 会话 company_name 为 NULL(迁移前遗留),正确返回空
2026-09-11 12:42:55 +08:00
97ff63523c fix(export): 公司隔离移出 if filters 判定,确保无条件执行
export_excel 的行级公司隔离原本嵌套在 `if filters:` 内部:
只要调用方不传或传空筛选条件,整段隔离会被跳过且不抛错 —— 静默失效。
(实测: 修复前 export_excel({}, None) 会导出跨公司全部 1816 行。)

现把 get_current_company_filter() 及其 filter_conditions.append 提到
if filters: 之前,无论有无筛选条件都绝对执行。

同时清理路由里 filters 字典的 'company' 死键 —— export_excel 从不消费它,
真正生效的是 get_current_company_filter() 直接读 request.args 上的公司标识。

注意:前端 handleExport 的 company 参数必须保留(已在代码中加注说明)。
实测 WAREHOUSE_MGR 带 ?company=IRIS 导出 1126 行仅 IRIS,不带则 1816 行
涵盖 IRIS+LICA —— 跨域角色按公司收窄范围完全依赖这个 query 参数。

实测(修复后):
  SALES/IRIS          filters={}          → 1126 行, 仅 IRIS
  SALES/IRIS          filters={keyword:''}→ 1126 行, 仅 IRIS
  WAREHOUSE_MGR       ?company=IRIS       → 1126 行, 仅 IRIS
  WAREHOUSE_MGR       (无参数)            → 1816 行, IRIS+LICA
2026-09-11 12:41:27 +08:00
d69a77cec1 fix(export): 补上 export_excel 缺失的表头定义
GET /api/v1/inbound/base/export(物料页「导出库存统计」)在
base_service.py 里执行 ws.append(headers),而 headers 全文件从未赋值,
每次调用必抛 NameError,导出功能 100% 不可用。

按 _write_stock_rows 实际写入的 22 列顺序补齐表头,命名对齐同仓库
inbound_summary_service 与 stock.py 的既有报表。

实测: 容器内导出成功 203282 字节,1817 行(含表头),表头与数据行
均为 22 列,未出现串列。
2026-09-11 11:44:35 +08:00
f4d97b6c4f fix(image-search): 成品库存回填误取 batch_number,导致以图搜图必 500
POST /api/v1/common/image-search 在回填 StockProduct 业务数据时读取
r.batch_number,但 stock_product 表没有该列(只有采购件、半成品有),
AttributeError 把整个检索打成 500 —— 即便 stock_buy / stock_semi 两路
已经查好也一起丢掉。

改为与 stock.py /stocktake/all-items 的既有约定一致,用 serial_number 兜底,
保持三个模块回填的字段形状不变。

实测: StockProduct 样例 hasattr(batch_number)=False,serial_number='205'。
2026-09-11 11:44:31 +08:00
02e03e6fb6 fix(stocktake): 补上模块级 traceback 导入,修复异常分支的 NameError
stock.py 模块级从未 import traceback,却有 3 处裸调 traceback.print_exc():

- scan_stock_by_barcode (/scan) —— 本次改动之前就存在
- get_stocktake_companies (新增)
- get_active_session    (新增)

后果是真正的错误被 NameError 盖住:例如 company_name 列不存在时,
日志里只剩「name 'traceback' is not defined」,看不到根因。

其余 4 处在 except 块内写了局部 import 因而一直正常,现统一由模块级提供。
2026-09-11 11:34:23 +08:00
b8d18c71d8 fix(stocktake): 盘点链路按公司隔离,开启新盘点不再清空整表
修复三处跨公司数据污染:

1. /draft/start-new 原本执行 StocktakeDraft.query.all() 后逐条 delete,
   任一库管点「开启新盘点」就会物理删除全公司所有人的盘点进度。
   改为只签发新 session_id,历史数据原样保留;清理走 /draft/clear,
   且强制要求 session_id(不传直接 400),杜绝误清整表。

2. get_stock_info() 全局按 barcode/sku 匹配三张库存表,不同公司的同码
   物料互相串货。新增 company_name 参数,精确与模糊两段查询均经由 base
   关系按 material_base.company_name 过滤;草稿去重键同步改为
   (uuid, session_id, company_name)。

3. 盘点相关读写接口统一施加公司隔离:/draft/list、/draft/add、/draft/clear、
   /variance-report、/draft/merged-list、/stocktake/all-items、
   /stocktake/generate-missing、/stocktake/update-quantity、/export-stocktake、
   /adjust、/scan。

顺带修复 /export-stocktake:差异与相符两张 Sheet 原本不按 session 过滤,
过去依赖 start-new 清空整表才恰好等价于当前会话,现显式按 session_id 过滤,
否则历史会话会被一并导出。

新增接口:
- GET /stocktake/companies   盘点页公司下拉(普通用户只返回本公司,
  避免复用 /inbound/buy/options 时因缺 inbound_buy 权限而 403)
- GET /draft/active-session  该公司最近一次活跃会话,供多设备加入
2026-09-11 11:33:12 +08:00
6586d3cb75 feat(stocktake): 盘点草稿表增加公司隔离字段
stocktake_draft 原本没有任何公司维度,导致开启新盘点清空整表时会误删
其他公司的进度,扫码时也无法区分不同公司的同码物料。

- StocktakeDraft 增加 company_name(带索引),to_dict 同步输出
- 新增迁移 add_stocktake_draft_company_name.sql:加列 + (company_name,
  session_id) 复合索引;存量行保持 NULL(超管仍可见),不做删除

执行: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_stocktake_draft_company_name.sql
2026-09-11 11:33:04 +08:00
06cb5f1cac fix(reservation): 实扫来源不可识别时整单失败,避免预占永久锁死
出库与借库两条执行路径原先写作:

    _scanned = [i for i in items if i.get('source_table') != 'trans_repair']
    if _scanned:
        verify_scanned(...); restore_then_deduct(...)

`if _scanned:` 为假时校验与释放被整体跳过,而下方仍无条件把单据置为
status=3(已完成)。此时申请阶段 reserve_for_items() 扣掉的 available_quantity
无人归还,且 status=3 之后 /close(要求 status==1)与 /withdraw(要求
status∈(0,1))都拒绝再释放 —— 预占就此永久锁死,成为谁也领不走的幽灵库存,
且没有流水可供追溯。

改为 Fail-Closed:实扫明细的来源必须全部落在 model_map 内,否则整单失败。
事务回滚后单据保持 status=1、预占原样保留,库管可重试执行,或走驳回/撤回 ——
任一路径都能正常归还预占。

- outbound_service.create_outbound_batch:以 model_map 白名单过滤,
  并要求 _scanned 非空
- trans_service.execute_dispatch:先显式校验来源合法性并列出非法值,
  再要求 _scanned 非空
2026-09-11 10:34:55 +08:00
a8a3c82331 fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负
available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处
缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。

1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct)

   物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行
   穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量
   5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都
   跳过,是裸扣。

   已实测复现:借库与出库路径均可把单行扣成 -3。

   校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖):
   释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证
   必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。

2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound)

   按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被
   预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。
   正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。

配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支
(同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成
服务端故障。

验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行
本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、
真实借库单 52 行全额执行正常、全库无负可用数。
2026-09-11 10:34:15 +08:00
cbcedecba2 fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数
出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占,
防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的
上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次:

  · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」
  · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失
  · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」

改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占
不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验
的数字精确相等,是同一口径而非近似。

后端:
- inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具
- outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives
- outbound_service.py 的 _format_scan_result 返回归一化可用量

两道门禁:
- biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被
  出库页与借库页共用),否则借库单 ID 会命中另一张出库单
- 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后
  items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会
  二次回加 → 真超卖(现有 3 张已完成单据即属此形态)

不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
f702a70fe5 fix(borrow): 借出只冻结可用数,不再扣减实物库存
问题
----
上一轮库存预占改造(b57c21a)把借库执行改用了 restore_then_deduct(),
而该函数是按**出库语义**设计的(物品永久离开仓库),会同时扣减
available_quantity 与 stock_quantity。借库是可逆的,于是产生两个缺陷:

1) 借出再归还后,实物库存永久少一份
     初始   (stock=20, avail=20)
     借出5  (stock=15, avail=15)
     归还5  (stock=15, avail=20)   ← 实物没回来,丢了 5

2) 借出后转报废会重复扣减
   scrap_borrow 的注释明确写着「扣减总库存(该物品确认损失);
   可用库存已在借出时冻结,无需重复扣」—— 它假设借出**没动实物**。
   借出已扣 stock 后,转报废再扣一次:
     借出5  stock=15 → 转报废 stock=10(正确应为 15)

修复
----
restore_then_deduct 增加 deduct_stock 参数:
  出库 deduct_stock=True (默认)—— 可用数、实物数同时扣
  借库 deduct_stock=False          —— 只冻结可用数,实物数不动

★ 这是**恢复原有设计**,不是新设计。git 历史显示从最初的
  04ee938 借库逻辑实现 起,历经 7ef22a3 / 83b3db6 / b79b0f9 /
  2556b77 / 1527d55 六次提交,一直是:
      stock.available_quantity = float(stock.available_quantity) - qty
  (只减可用,不动实物)。b57c21a 把它替换掉了。

  盘点的差异计算也印证了该口径的正确性:
      adjusted_stock_qty = stock_quantity - 借出未还
      diff_qty = 实盘数 - adjusted_stock_qty
  这段逻辑要求 stock_quantity **包含**借出未还的实物,否则会重复扣减。

最终语义
--------
  stock_quantity     = 账面实物总数(含借出未还)
  available_quantity = 实际可取用数

  出库申请(预占)  —    available ↓
  出库执行        stock ↓  available ↓
  借库申请(预占)  —      available ↓
  借库执行        —      available ↓
  借库归还        —      available ↑
  借库转报废      stock ↓  —

实测(stock, available)
------------------------
  借还循环:初始(20,20) → 借出5(20,15) → 归还5(20,20)  完全可逆
  出库:    预占4(20,16) → 执行(16,16)  实物确实减
  转报废:  借出3(20,17) → 转报废(17,17)  实物减一次,不重复
2026-09-11 09:39:20 +08:00