Commit Graph

30 Commits

Author SHA1 Message Date
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
1eaa203eba fix(db): 修正存量「归还时间/报废时间」的 8 小时时区偏差(附自检)
问题
----
process_return 与 scrap_sources.deduct 原先用 datetime.now()(容器本地时间,
Docker 默认 UTC)写 return_time / operation_time,而同一行的 borrow_time 由
beijing_time() 写入 —— 同一行两个时钟,差 8 小时。

实测(开发库)trans_borrow 有 53 行呈现「归还时间早于借出时间」这种物理上
不可能的数据,例如 id=80 借出 09-09 11:04、归还 09-09 03:46。

代码侧已修(f7c49f4),新数据已是北京时间 —— 本次只处理存量。

★ 脚本刻意做了「先自检、后修正」的两段式
  服务器 API 容器的 TZ 未必是 UTC(若本就是 Asia/Shanghai,datetime.now()
  一直是北京时间,则**根本无需修正**)。若不做自检直接 +8h,会把正确的时间
  改错。故第 0 步先输出:异常行数、以及按分界切出的「UTC 段/空档/北京段」
  三段计数,由使用人据结果决定是否执行第 2 步。

★ 分界与幂等性都在脚本里写明
  · 分界 = 修复代码在该服务器**部署生效的时刻**(不是提交时刻),需按实际调整;
  · 脚本**不幂等** —— 重复执行会再加 8 小时,已显式警示并给出回滚写法。

开发库已按此逻辑修正:53 行 trans_borrow + 1 行 trans_scrap,
修正后「归还早于借出」为 0,抽样时间落在正常工作时段。
2026-09-18 09:27:17 +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
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
2f658407c3 feat(db): 基础信息新增「采购链接」字段并注册权限
需求:物料主数据增加一个可直接跳转的补货地址(淘宝/1688 等),纳入字段权限管控。

★ 设计取舍:读写**共用同一个权限码** material_list:purchaseLink
  上一轮刚修完「写用 A 码、读用 B 码」造成的读写错位(附件备注),新字段刻意
  沿用 referencePrice 的既有模式 —— 一个码同时进 field_permissions.py(读过滤)
  与 base.py 的 field_to_perm(写过滤)。单码最大的好处是**结构上不可能错位**:
  能读必然能写、反之亦然,不会再有「填了看不到」或「看得到改不了」。
  若将来要求「能看不能改」,需拆两个码并同步改三处,届时一并回归验证。

★ 列类型用 text 而非 varchar(N):外链常带很长的查询串,截断后是打不开的地址,
  且截断是静默的 —— 用户只会觉得「链接坏了」。

默认授予 SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR / INBOUND(覆盖实际补货的人
与管理物料的人)。⚠ 这是本次取的默认值,业务可按需增删 —— 改 VALUES 再执行一次即可。

幂等;不含 psql 元命令,DataGrip 可直接整段执行;含核对段与回滚段。
2026-09-17 11:26:36 +08:00
6b7b174e3d fix(db): 补注册附件备注的读写权限码(修「写通读断」的权限错位)
根因(比上一轮报告的更深一层)
----
field_permissions.py 的读过滤要求两个权限码:
    material_list:productImageRemark / material_list:manualLinkRemark
而这两个码**在 sys_element 里根本不存在**,也从未授予任何角色 —— 是「幽灵权限」。
除 SUPER_ADMIN(走 material_list:* 通配符绕过)外,没有任何人能通过读过滤。
配合写侧的「不在映射中 → 默认允许」兜底,形成:
    谁都能写、除超管没人能读 —— 填了存进去了,回显却被抹成 null,
    看起来就是「保存不了」。

本次改动
----
一、读侧:注册这两个元素,授予与 material_list:files **完全相同的 6 个角色**
    (INBOUND / OUTBOUND / SALES / SUPERVISOR / WAREHOUSE_MGR / SUPER_ADMIN)
    依据:备注依附于图片,「能看图的人就应该能看备注」。
二、写侧:新建 material_list:remark_edit,只授予 3 个核心管理角色
    (SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR)。
    ★ 为何不复用 material_list:operation:它授予了 5 个角色(含 INBOUND /
      OUTBOUND),比业务要求的 3 个更宽,达不到「只有核心管理角色可改」。
    ⚠ 该码只做精确匹配,**切勿用 @permission_required 包裹** ——
      _expand_operation_perms 会前缀桥接,把 material_list:operation 的持有者
      一并放行,等于把刚收紧的口子又捅开。已在迁移与代码注释中双处警示。

幂等,可重复执行;脚本文本不含 psql 元命令,DataGrip 可直接整段执行。
2026-09-17 11:07:50 +08:00
b26b124f8b chore(db): 部署脚本改用通用 SQL 核对段,兼容 DataGrip
psql 的 \echo 是元命令,DataGrip / DBeaver 不认,整份执行会直接报语法错误。
核对段改用 SELECT '常量' AS "核对项" 输出标签行,任何客户端都能跑
(psql 下效果相同)。同步修正头部说明中「需用 psql」的过期表述。

已在临时库重跑验证:零报错、核对结果与改前一致、重跑幂等。
2026-09-17 10:51:31 +08:00
d6234622e8 chore(db): 借库转交完整部署脚本(生产可执行)
把本轮 6 个迁移(phase4 / 4b / 4c / 4d / 4e / 4f)按依赖顺序合并为一份
可直接在生产执行的脚本,并在一个模拟「部署前状态」的临时库上完整验证。

内容
----
 1. trans_borrow 补 4 列(borrower_id / current_holder_id /
    current_holder_name / dispatch_operator)+ 2 索引
 2. trans_borrow_transfer 建表(最终形态)+ 7 索引
 3. trans_borrow_return 建表 + 3 索引
 4. 回填 trans_borrow 身份锚点(仅唯一命中者,重名/无法映射留 NULL)
 5. 回填遗留转交流水的 borrow_no 与状态
 6. 回填 reject_seen_at(存量拒收标记为已告知)
 7. 从 remark 拆出被拼接的 reject_reason
 8.(注释掉)borrow_transfer 权限码 —— 已无代码引用,默认不建
+ 执行后核对段 + 回滚段

★ 验证中发现并修掉两个真实缺陷(都不是「看起来能跑」能暴露的)
  1) 顺序缺陷:第 5 段原先把遗留流水**一律**标成 ACCEPTED,导致第 6/7 段
     按 status='REJECTED' 找行时一条都匹配不到(旧结构表里 status 是刚加的
     列、全是默认 PENDING)。真正的信号在备注的 '[拒绝原因]' 标记里,
     现据此还原真实状态。
  2) 孤儿流水:第 5 段按 borrow_id 关联,来源借用行已被删除的流水匹配不上,
     会永久停在 PENDING —— 在接收人那里变成谁也处理不掉的幽灵待办。
     已加兜底把「没有单号」的遗留行一律结掉。

★ 两处 ⚠ 警示已写入脚本:第 5/6/7 段设计为**新代码上线前执行一次**;
  若在功能已投产后重跑,会把当时真实的待接收/待告知记录误标。

验证方式:建临时库复刻部署前结构(旧 trans_borrow + 旧结构转交流水表 +
重名/无法映射/已归还/孤儿等边界数据),执行脚本后核对:DDL 与索引齐全、
身份锚点按唯一性正确回填、遗留流水状态从备注还原、原因正确拆出、
孤儿流水被结掉、无报错;再执行第二遍确认幂等(结果完全一致)。
2026-09-17 10:49:34 +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
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
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
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
a252013573 feat(perm): 逆向物流专用权限码与菜单授权
为逆向物流的每个动作拆出独立权限码,终止此前复用其它模块权限的做法。

权限码一览(全部为无冒号形式,见下方说明):

  outbound_return       出库退回
  defective_list        不良品台账查看
  defective_restock     不良品回库
  defective_scrap       不良品报废
  stock_change_status   库存状态变更
  outbound_return_list  退回记录查看

另补建两个菜单:defective_goods(不良品在管台账)、outbound_returns(退回记录),
前者原先只有前端路由、sys_menu 中无对应行,导致页面在权限管理界面不可见、
其下权限也无从授予(sys_element.menu_code 有指向 sys_menu 的外键)。

默认授予:SUPER_ADMIN、SUPERVISOR、WAREHOUSE_MGR。
刻意不含 OUTBOUND —— 业务确认出库员只负责正向拣货发货,不参与逆向物流。

★ 为什么全部用无冒号形式,而不是 <menu>:<action>:
  后端 _expand_operation_perms() 对带冒号的权限码做**前缀桥接** —— 只要用户
  持有该菜单下任一以 :operation/:edit/:delete/... 结尾的权限就被放行。
  实测验证过这个放大效应:

      outbound_list:return  <- 持有 outbound_list:operation   ->  True
      outbound_return        <- 持有 outbound_list:operation   ->  False

  且前端 hasPermission() 是精确匹配,用冒号码会出现「接口能调、按钮却看不到」
  的错位。无冒号码不触发桥接,前后端判定完全一致。

★ add_return_view_support.sql 另含一处 schema 变更:trans_return 补
  company_name 快照列。退回流水的多租户隔离原先只能靠 join 链推
  (trans_return → trans_outbound → 库存表 → 物料主表),而库存行会被入库
  模块物理删除(实测 1077 条出库记录中已有 7 条悬空),链路一断该记录就会
  对普通用户静默消失。审计视图静默丢数据不可接受,故落快照。
  表当前为空,无需回填。

三个脚本均为幂等,含预检、回滚段与执行后核对。
2026-09-16 16:44:46 +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
d1694bf245 perf(stocktake): 去掉库位树的全展开渲染,并为推荐查询补索引
业务反馈切到抽盘面板与点【获取推荐】时卡顿。实测定位:

【真正的原因在前端】先跑 EXPLAIN ANALYZE 排除了后端 ——
推荐查询在当前数据量下仅耗时 1.3ms,全走 Seq Scan + Hash Join,
Buffers shared hit=161 全部命中缓存。所以卡顿来自 DOM:
el-tree 原先带 default-expand-all,会把全库 3371 个库位节点一次性铺进 DOM。

去掉 default-expand-all 后初始只渲染根节点。勾选状态与 getCheckedNodes
不受影响 —— el-tree 的 Node store 会预先构建全树,展开与否只影响 DOM。

【索引仍补上,但说明白】实测 pg_indexes:stock_* 三表的 warehouse_location
已有索引,但 trans_outbound / trans_borrow 只有主键,时间字段与
(source_table, stock_id) JOIN 键都缺;三张库存表的时间字段也缺。
新增迁移 add_active_location_indexes.sql 补 7 个索引。

诚实说明写在迁移头部:当前数据量下加索引后本查询**仍会走 Seq Scan**,
这是小表下的正确计划,索引是为 90 天/半年后的数据增长做前瞻,不是修当前的慢。

【loading 状态】treeLoading / recLoading 已在既有实现中就位并闭环在
try/finally 中,分别绑在 el-tree 的 v-loading 与【获取推荐】按钮的 :loading 上,
本轮复核确认,未做无谓改名。

实测: 索引后执行计划仍为 Seq Scan,Execution Time 0.769ms(预期内)
2026-09-11 14:38:40 +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
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
ec66c33b06 feat(scan-draft): 扫码草稿表与接口,支持暂停后继续扫码
场景
----
扫码出库/借库的作业可能很长(一张单几十项),工人常需中途暂停去处理
更紧急的单据。改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。

由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 ——
因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是
需要重扫,不会造成库存错乱。

隔离粒度
--------
按 (user_id, biz_type, request_id) 一人一单:每个人扫自己的草稿,互不影响;
同一人可同时持有多张单据的草稿(正是「暂停 A 去出 B」的场景)。
user_id 一律取自 JWT,不接受入参覆盖,故不可能读写他人草稿。

为什么整单存一个 JSON(而非每条明细一行)
------------------------------------------
1. 保存是「全量覆盖」语义,逐行存无增量更新的收益;
2. 恢复时需要物料名称/规格/库位等展示字段,逐行方案只能回查申请单的
   items_json —— 而历史单据的 items_json 不含 stock_id,回查会错配。
   整单快照把展示字段一并存下,恢复零依赖,对老单据同样可靠。

接口
----
GET    /api/v1/scan-draft           读取草稿
POST   /api/v1/scan-draft           保存(全量覆盖;空清单则删除)
DELETE /api/v1/scan-draft           清除(提交成功后调用)
GET    /api/v1/scan-draft/overview  各单据进度,供下拉徽标

实现要点
--------
· items_json 列是 jsonb,模型必须用 db.JSON —— 用 db.Text 会让 psycopg2
  拿到 Python list/dict 时无法适配,报 "can't adapt type 'dict'",而异常
  被接口的 except 吞掉后 POST 仍返回"成功",问题极难发现(开发中实际踩到);
· 概览接口做防御:残留的空草稿不参与展示。

实测:保存/读回、跨单据隔离(B 读 A 的草稿为 0 项)、全量覆盖、
      提交后清除,全部符合预期。
2026-09-10 17:21:14 +08:00
2045a89330 feat(db): 审批单据时间口径统一 + 补注册 scrap_selection 权限
unify_approval_timezone.sql:
  · scrap_approval 的 4 个时间列由 timestamptz 改为 timestamp without time zone,
    与 outbound/borrow 对齐(该表当时无业务数据,转换零损失);
  · 三张审批表 created_at/updated_at 的 DB 默认值由 UTC 的 now()/CURRENT_TIMESTAMP
    统一为 timezone('Asia/Shanghai', now()),杜绝绕过 ORM 的写入偏 8 小时。

add_scrap_selection_perm.sql:
  GET /scrap/scan 一直声明需要 scrap_selection,但该权限码从未写入
  sys_element / sys_role_permission,精确匹配与前缀展开匹配全部落空,
  导致除 SUPER_ADMIN 外没有任何角色能调用扫码接口。现补注册,
  并授予所有已持有 scrap_execute(按单报废执行)的角色。
2026-09-10 10:14:19 +08:00
3d8fb198c5 feat(scrap): 报废审批流 DB 迁移(建表 + 权限码)
- scrap_approval 表(申请单,items_json 携带 source_table+stock_id 精准实物)
- trans_scrap 增加 scrap_request_no 关联申请单
- 注册权限码 scrap_apply / scrap_approval / scrap_execute 并按现有报废角色授予
2026-09-10 09:46:52 +08:00
c8cf2bd52b feat(db): BOM 三态互斥存量归一——归档(废弃)自动停用
- 将历史“归档且仍启用(True/True)”的行归一为 归档且停用(True/False)
2026-09-09 13:00:35 +08:00
4146f35010 feat(permission): “出库/借库需审批”开关权限化——仅主管/超管可见可改
- 新增权限码 material_list:isApprovalRequired,授予仅 SUPER_ADMIN/SUPERVISOR(收回其它角色)
- 物料列表该列仅持码角色可见;后端批量端点与字段脱敏均改按新码校验,其余角色看不到值也无法改
2026-09-09 10:47:36 +08:00
e03c035ee0 feat(db): material_base 增加 is_approval_required 开关列(出库/借库需审批)
- boolean NOT NULL DEFAULT false,存量默认不审批
2026-09-09 09:31:12 +08:00
6250fa94cb feat(bom): BOM 子件引用版本与归档 数据库迁移
- bom_table/bom_draft_table 加 child_bom_no/child_bom_version(子件引用的自制BOM版本)
- bom_table 加 is_archived 归档标记(仍启用可查看/编辑,但不再作为子件版本候选)
- 回填 60 行自制件子件引用版本,4 个多版本自制件按业务口径(79/92/226/1905)
- 3 套占位/废弃配方改标归档并恢复启用
2026-09-08 10:14:40 +08:00
9c3fcbca4c migration: 补齐 sys_element 缺失的 :operation 权限码 + 角色分配 SQL 2026-07-17 15:29:59 +08:00
3c0954598c perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板
本次提交包含本会话所有修改的最终统一提交

## 权限系统重构
- permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions
- field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase)
- decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit
- deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity)

## 采购模块
- purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤
- purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1
- purchase/index.vue: canApprove严格操作权限 + upload重复修复

## 导出/入
- base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化
- import_service.py + import_api.py: Excel批量导入(模板+预览+执行)
- ImportDialog.vue: 三步骤导入弹窗

## 异步邮件
- email_service.py: send_email_async (守护线程)
- inventory_task.py: send_email→send_email_async

## 前端对齐
- product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2
- buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载
- BomManage.vue: 懒加载分组 + 导入按钮
- list.vue: 导入按钮
- Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮
- outbound/create.vue: 出库类型必选
- AppMain.vue: 移除transition白屏修复
- material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
4934cd4d8f feat: 采购管理税率+分开单价总价列 + 按单出/借库锁定扫描 + 含税法计算
- purchase: 新增tax_rate字段(model+service+frontend), 表格拆分为不含税单价/总价/税率三列
- buy.vue: 含税法计算(含税总价=数量×含税单价), 含税总价自动计算可手动覆盖
- create.vue: 按单出库模式下未选审批单时锁定摄像头和SKU输入
- borrow.vue: 未选审批单时锁定摄像头和SKU输入
- deploy_production.sql: 整合全部数据库变更(表结构+权限码+角色分配)
2026-07-15 16:07:59 +08:00
4b1a86a870 fix: 对齐field_to_perm与sys_element权限码 + 税率6% + 含税总价反算
- buy/semi/product: 数量字段映射对齐sys_element实际注册码(qty_inbound/qty_stock/qty_available)
- 数据库: 补全buy(9)+semi(10)+product(13)缺失权限码
- deploy_production.sql: 同步更新
- buy.vue: 税率新增6%选项, 价格联动以含税为主输入, 税率变化保持最后编辑价格不变
- buy.vue: 新增含税总价可输入反算功能
2026-07-15 15:03:37 +08:00