Commit Graph

39 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
b57c21a4cd feat(inventory): 库存预占生命周期,消除出库/借库超卖
问题:库存超卖
--------------
改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行,
真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时
claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。

生命周期(三阶段)
------------------
  提交申请(预占)  reserve_for_items()
     用分配器把需求落到具体库存行,立即扣减 available_quantity,
     并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。

  驳回(释放)      release_reserved()
     遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。

  扫码执行(覆盖)  verify_scanned() + restore_then_deduct()
     校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次
     同时扣减 available_quantity 与 stock_quantity。

身份键:base_id 主键 + SKU 兜底(重要设计决策)
-----------------------------------------------
本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的
SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有
0000001685 与 0000001974 两个 SKU)。

若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为
身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。
故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定),
历史数据无 base_id 时降级为 (name, spec_model)。

可用量校验按物料汇总,而非按单批次
----------------------------------
开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」,
工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 +
C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单
锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。

改动文件
--------
· 新增 app/services/inventory_reservation.py(通用服务层)
· outbound_service.create_request     —— Phase 1 预占
· outbound_service.approve(reject)    —— Phase 2 释放
· outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减)
· borrow_service.submit_approval      —— Phase 1
· borrow_service.approve(reject)      —— Phase 2
· trans_service.execute_dispatch      —— Phase 3,并用统一身份键替换
                                         原有的 (name, spec_model) 字符串匹配

实测(真实 HTTP 全链路)
------------------------
初始 available=10
  ① 提交申请(需5)   → 200,available 10→5    预占生效
  ② 审批通过        → available 仍为 5        预占保留
  ③ 扫码执行(改扫另一批次 4 件) → 200
     原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6

单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复;
           扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
fd0bfd3d9c feat(records): 借还记录接入高级筛选
复用 app/utils/advanced_filter.py 的解析与谓词逻辑:
  · 父级字段(单号 borrow_no、借用人 borrower_name)走标准 SQL 谓词
  · 子级字段(SKU、物料名称)经单号子查询过滤,否定操作符走 NOT IN 整单排除
  · 物料名经三表联查(buy/semi/product JOIN material_base)

借还的单号维度查询基于 order_subq 子查询分页,故此处把过滤条件施加在
order_subq.c.borrow_no 上,与既有的状态/日期/公司隔离过滤保持同一层次。

前端 records.vue 新增「高级筛选」el-popover(字段/操作符/值 + 添加条件/
应用筛选/重置),序列化为 advancedFilters JSON 字符串随查询下发。

验证:sku ne 0000000002 → 52 单(库中无单含该 SKU,正确不减);
      sku contains 0000 → 52 单(52 单的 SKU 全部含 0000,数据巧合)。
2026-09-10 13:06:03 +08:00
c35ec9a659 feat(records): 出库/借还记录统一高级搜索版式
三个记录页(出库/借还/报废)此前搜索区形态各异:出库用裸 div + 分散样式,
借还缺日期范围,报废只有 SKU 输入框。现统一为 el-form :inline="true" +
.filter-form 版式,并补齐缺失的过滤维度。

借还记录(新增日期范围能力):
  · 后端 get_records 增加 start_date/end_date 参数,按「借出时间」过滤;
  · 边界补全时分秒(YYYY-MM-DD → 当日 00:00:00 / 23:59:59),
    与出库记录同口径,解决零点截断导致当天记录漏查的问题;
  · API 层透传两个新参数。

出库记录:
  · 版式统一为 inline 表单,日期选择器加 label;
  · 新增「重置」按钮;
  · 搜索类型切换由 500ms 防抖改为立即查询。

借还记录:
  · 新增「重置」按钮;搜索类型/状态切换改为立即查询(取消防抖)。

实测(SUPER_ADMIN):
  报废 全部/单号/SKU/操作人/物料名/日期 六类过滤均 200 且命中正确;
  借还 无过滤 52 单 / 本月 18 单 / 空区间 0 单(日期过滤生效);
  出库 无过滤 395 单 / SKU 277 / 姓名 7 / 物料名 16 / 单号 OUT-2026 命中 395。
2026-09-10 12:06:08 +08:00
b4b79c68a9 fix(borrow): 借库转报废实物不足时报错回滚,不再夹断到 0
原逻辑在 stock_quantity 减为负数时直接置 0。但借出时已扣减过
available_quantity,夹断会使 available_quantity > stock_quantity,
凭空多出可用库存,后续出库/借库可继续占用这批并不存在的实物。

改为不足即抛 ValueError,与同一事务内其余校验一致,整单回滚。
2026-09-10 10:14:36 +08:00
eff9b62224 fix(borrow,outbound): 普通用户记录只看本人——按领用人/借用人姓名(不含账号前缀)匹配
- 出库记录/借还记录:非管理者视角时按 领用人(consumer_name)/借用人(borrower_name) 过滤
- 匹配取登录名 username.split(/)[0] 的姓名;兼容库里存“姓名/xiaolongxia”全名(姓名+/前缀)
- 修复:管理员替员工创建、领用人=员工 的单,员工登录可见
2026-09-09 11:19:15 +08:00
73510d3a71 feat: 借还记录默认排序重构——有限期单按预计归还时间升序靠前,无限期单按借出时间升序靠后(优先关注快到期/逾期) 2026-09-04 16:17:42 +08:00
1527d552d4 feat: 出库/借库记录记录并展示库位快照(DB加列+后端写入+记录页展示) 2026-09-04 11:42:44 +08:00
36e9f500aa feat(borrow): 借库未归还直接报废功能(关联报废单流程)
后端:
- trans_service 新增 scrap_borrow 方法:未归还借用标记为 scrapped,
  扣减总库存 stock_quantity(可用已在借出时冻结),生成 TransScrap 报废记录
- transactions.py 新增 POST /borrow/scrap 接口(复用 op_return:operation 权限,库管/主管可操作)
- scrap.py query_records 支持 trans_borrow 来源的物料名解析 + 行级隔离

前端:
- records.vue 未归还记录新增「报废」按钮(库管/主管可见)
- 报废弹窗:勾选明细 + 填报废原因 + 二次确认
- 状态显示新增「已报废」标签;整单所有明细报废时标记为 scrapped
2026-08-31 10:21:28 +08:00
2556b77530 perf: 系统级性能优化与并发安全修复
## 并发安全修复 (4处)
- scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态
- stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁
- outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务
- trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险

## N+1 查询优化 (2处)
- inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次)
- stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次)

## BOM 列表性能重构
- bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询
- bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT)
- bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤

## Odoo 基础信息懒加载
- base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT)
- base.py: 新增 /api/v1/inbound/base/odoo-summary 路由
- buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems)
- material_base.ts: 新增 getOdooSummary API

## 前端 Bug 修复
- BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse)
- BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data)
- buyOdoo.vue: 移除 "点击展开加载" 文字
- Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
e1417d740a feat: 全模块公司隔离 + crossDomain权限码动态跨域控制
- get_current_company_filter: 新增_has_cross_domain_permission, 权限码替代硬编码
- 补全11个Service的get_current_company_filter调用(semi/product/service/outbound/bom/trans/scrap/summary)
- base/search修复: search_material此前无隔离, 已补全
- get_current_company_filter兜底: JWT缺company_name时返回__NO_COMPANY__防止放行
- permission.py: _get_operator_company补全返回值, 修复权限页保存逻辑
- 新增crossDomain迁移脚本, element_type=element挂system_mgmt下
2026-07-15 11:11:40 +08:00
DXC
1450e6c1de fix(借还记录列表): 按 borrow_no 单号维度分页 + 修 SQLAlchemy Row 适配错误
- 分页基准从明细行改为单号:21 项单号不再被拆到 3 页

- 步骤 1a 构造 GROUP BY borrow_no 的 subquery(sort_key + status 聚合)

- 步骤 2 主查询 SELECT order_subq.c.borrow_no 一列,避免触发 PG GROUP BY 严格模式 (f405)

- 步骤 3 用 page_borrow_nos 拉明细,保留前端 groupMap 期望的 items 形态

- pagination.items 用 isinstance + hasattr(_mapping) 兜底提取纯字符串(修 psycopg2 can't adapt type 'Row')

- service 加 try-except,路由层识别 500 透传 traceback

- status 过滤改为单号聚合(borrowed=至少一条未还,returned=全部归还)
2026-06-16 14:53:42 +08:00
DXC
b79b0f99af fix(借库扫码出库): 撤销 joinedload 修复 PG "FOR UPDATE cannot be applied to nullable side of outer join"
- 83b3db6 引入的 joinedload(ModelClass.base) 触发 LEFT OUTER JOIN,
  而 with_for_update() 会被 SQLAlchemy 透传到 join 的 nullable 侧,
  PG 直接抛 FeatureNotSupported,且连表加锁有死锁风险
- 退回最安全的单表 FOR UPDATE 模式,接受 N+1 lazy 加载的代价
- 在 防线3 上方加防回归注释,明确禁止未来再加 joinedload
- process_return 中的另两处 joinedload 不带 FOR UPDATE,不受 PG 限制,保留
2026-06-16 13:56:11 +08:00
DXC
83b3db693a fix(借库扫码出库): 校验 key 从 (source_table, sku) 改为 (name, spec_model) + N+1 修复
- 借库申请按 (name, spec_model) 发起,审批明细无 sku 字段;
  旧代码用 sku 做 key 会导致所有条目坍塌到同一桶,校验形同虚设
- 改为在扫码循环内即时累加、即时拦截:
  防线4 锁定 stock 行后从 material_base 取真实 (name, spec_model),
  与审批单按 strip 后的 (name, spec_model) 聚合比对
- 新增 joinedload(ModelClass.base) 一次 JOIN 加载 base,
  避免循环内 stock.base 触发 N+1
- 修正 dispatch_borrow docstring 中"sku 用于超额交叉校验"的错误描述
2026-06-16 13:50:49 +08:00
DXC
7ef22a3830 feat(借库审批流): 完整前后端实现 2026-06-12 14:08:19 +08:00
DXC
9a5e3ee6b0 TransService.get_records: 追加 material_name 字段 + SKU 兜底查询解决数据孤岛问题 2026-06-12 11:06:34 +08:00
DXC
48651ffd01 perf: 消除出库列表和还库操作的 N+1 查询,改用批量 IN + joinedload 2026-05-19 09:49:30 +08:00
DXC
71e5f075d2 feat: implement composite debounced search with prepended select and wipe out duplicate root permission nodes 2026-03-20 10:26:45 +08:00
DXC
3bb3975022 fix: use .c to access SQLAlchemy subquery columns correctly 2026-03-20 10:15:11 +08:00
DXC
34629b432a fix: correct SQLAlchemy join condition to resolve MaterialBase AttributeError 2026-03-20 10:06:22 +08:00
DXC
990399a408 feat: implement cross-table search and debounced dynamic search for borrow and return records 2026-03-20 09:58:42 +08:00
DXC
8db1015f99 fix: implement traffic-light color warning and correct ascending sort for overdue borrow records 2026-03-19 11:45:27 +08:00
DXC
b74464df6b feat: add descending sort by return date and color-coded warning for impending returns 2026-03-19 11:40:38 +08:00
DXC
79d4a365e0 feat: add partial return support with returned_quantity tracking 2026-03-18 10:41:19 +08:00
dxc
b98f89bfe4 chore: add .material->.base refactor check comments
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 11:34:50 +08:00
dxc
04ee938cd1 借库逻辑实现 2026-02-06 17:11:47 +08:00
dxc
ee9f4aed3e 修正git管理关系 2026-01-26 13:47:53 +08:00