16 Commits

Author SHA1 Message Date
0b4a2b29d6 V3.91,9.22推送 2026-09-22 14:21:00 +08:00
3851ebd7bc fix(inbound): 导入采购单改为单价优先,修复多发/少发时单价被摊薄
原逻辑是总价优先(hasTotalPrice 分支在前):用「申请单总价 ÷ 本次入库数量」
反算单价。但申请单的 total_price 是「申请数量」的钱,实际入库数量未必相同
—— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态,用总价除以实际数量
会把单价摊薄或抬高。

改为单价优先、总价仅作兜底,总价由「单价 × 实际入库数量」得出。与后端
buy_service 的补价口径一致。

顺带修一处隐患:导入新单前清掉 postTaxTotalManuallySet / postTaxTotalInput。
此前若上一单手工改过含税总价,这两个 ref 会残留,updatePrices 末尾会拿着
旧总价回头覆盖新导入的价格 —— 连续导入两张单时必现。

影响面:库管本就拿不到价格(接口剥离),此段对其一直是空转;受影响的是有
价格权限的角色在页面上导入采购单时的计算口径。
2026-09-22 14:09:09 +08:00
d99396ecb1 fix(inbound): 按单入库时从采购申请单补回成本价
库管角色没有 inbound_purchase:unit_price / total_price 权限,采购单接口
(api/v1/purchase.py:46-53)会把价格字段整个 pop 掉,前端导入时因此带不出价
—— buy.vue 的 hasUnitPrice / hasTotalPrice 双双落空,两个分支都不进,入库
成本被记成 0。

而前端 buy.vue:1936 的注释写得很清楚:"无论前端是否对当前角色显示价格,导入
都应把采购单价格带入(隐藏字段,用于成本)"。前端想带、后端不给,价格就断在
中间——不是谁写错了,是两边对"隐藏"的理解不同。

改为在落库前从采购申请单补回:价格不经过前端,库管依旧看不到采购价,权限
边界不变,但成本可追溯。仅在前端未带价时补,有权限的角色手动填过价则尊重
提交值。采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
列名"含税单价"),需反算不含税。

顺带清理 buy_service.py:221 的函数内局部 import —— 局部 import 会让 Python
把 PurchaseRequest 视为本函数的局部变量,导致位于其之前的引用抛
UnboundLocalError(本次新增的补价逻辑正踩到这个,测试时暴露)。改用文件顶部
的全局导入。

实测:测试申请单 含税 12.5 / 税率 13%,以库管身份提交且**故意不带任何价格
字段**,落库结果 税前 11.0619 (=12.5/1.13) / 含税 12.5 / 总价 110.6195 /
税率 13.00,全部正确;响应体经 filter_item_by_permissions 后仍不含价格字段,
权限边界未变。

另:已有 6 条历史入库单(走采购申请但单价为 0)已按「单价 × 实际入库数量」
回填,不回填总价。
2026-09-22 14:09:08 +08:00
91db54982c feat(track): 入库/出库 webhook 按公司路由到对应实例
三个触发点此前都不带 company_name,无从路由:
  · product_service / semi_service:入库通知补 material.company_name
  · outbound_service:track_notifications 元组加 company(取自
    stock_record.base.company_name;维修单走 repair.base,两处
    relationship 均已定义),发送时带进 payload

出库路径同时**去掉**了显式传入的 url=TRACK_OUTBOUND_WEBHOOK_URL ——
url 参数优先级最高,保留它会让出库永远打到默认实例,company 路由形同
虚设。url 参数本身保留,需要强制覆盖时仍可用。

实测:
  LICA 入库:待仓库收货 → 已入库,IRIS 库该 SN 0 行
  LICA 出库:已入库 → 已出库,IRIS 库该 SN 0 行
  IRIS 回归:对已入库设备重发入库通知,状态幂等不变
  精确隔离:发一条 IRIS 通知,lica_backend 日志 +0 行、track_backend +19 行

注意:不做"两个实例都推一遍"——两个 Track 的 webhook key 是同一把、
两边都会鉴权通过,而 Track 按 serial_number/sku 匹配,相同型号设备会
同时命中两库,把 IRIS 的设备误标成已入库,且产生 2 倍审计与流转节点。
2026-09-22 13:13:54 +08:00
0f929468a8 fix(track): track-lookup 按公司路由,修复 LICA 扫码误报"物料不存在"
lookup_product() 此前写死读全局 TRACK_API_URL(指向 IRIS)。LICA 员工在
LICA 业务里扫码 → 请求打到 IRIS 库 → 查不到 → MOM 报"物料不存在"。

改用 get_current_company_filter() + resolve_track_route(…, 'api') 选实例,
复用既有工具而非另写一套。无请求/JWT 上下文时(脚本、后台任务)包一层
异常保护回落默认实例,不阻断查询。

实测(身份证 0000000000000001 只存在于 LICA 库):
  LICA 员工 → company_filter='LICA' → 命中 sn='0000000000000001'
  IRIS 员工 → company_filter='IRIS' → 未命中
改造前两者都会打到 IRIS,LICA 员工必然查不到。
2026-09-22 13:13:53 +08:00
b85be2dfdf feat(track): 新增 resolve_track_route,按公司解析 Track 实例地址
resolve_track_route(company_name, channel) 支持 api / inbound / outbound
三个通道。未命中一律回落到扁平变量,**绝不返回空**——漏配一个 key 就
静默断链,比配置写错更难排查。

非空但不在路由表内的值(含 get_current_company_filter() 的
'__NO_COMPANY__' 哨兵、拼写异常的公司名)会打 WARNING 暴露出来,但不
阻断业务——这类值属于主数据问题,交给业务确认,代码不猜。

notify_track() 相应扩展:新增 channel 参数(不传则按 payload['event']
推断 inbound/outbound),url 仍为最高优先级(显式指定时不做公司路由)。

实测各分支:IRIS/LICA 各通道命中正确;None/空串/未知公司/缺通道/空路由表
全部按预期回落。
2026-09-22 13:13:53 +08:00
de34a939d0 feat(track): 新增 TRACK_ROUTES 多实例路由表配置
Track 现在有两个独立实例(IRIS / LICA),MOM 此前所有 Track 配置都写死
指向 IRIS,LICA 的业务拿不到入库/出库联动,且 /track-lookup 对 LICA
员工是坏的。

新增 TRACK_ROUTES 环境变量(JSON),按 material_base.company_name 决定
业务落到哪个实例:
  {"IRIS": {"api":..., "inbound":..., "outbound":...}, "LICA": {...}}

原有三个扁平变量(TRACK_API_URL / TRACK_WEBHOOK_URL /
TRACK_OUTBOUND_WEBHOOK_URL)保留为「未命中路由表」时的回落默认值;
TRACK_ROUTES 缺省为空表时,行为与改造前逐字节一致(向后兼容)。

⚠️ JSON 解析失败直接抛异常让进程起不来(fail fast)——路由表写坏却静默
回落,会造成"某些公司悄悄断链",比启动报错难排查得多。

docker-compose.yml 用容器名互访(inventory_api / track_backend /
lica_backend 三个容器同属 projects_default 网络,不走宿主机端口)。

判定维度用 company_name 而非 category:实测 category 两个方向都会错
(10 条 LICA 物料挂在 IRIS/ 分类下;IRIS 分类里含 "IRIS/成品/LICA/..."
那是产品线名不是部门名,共 171 条)。
2026-09-22 13:13:52 +08:00
3b777af87d V3.90,9.21推送 2026-09-21 14:26:07 +08:00
2319a053df fix(bom): 备注框移到子件列表上方,改为 textarea
原位置在编号/版本号那一行的 span=6 里,850px 弹窗扣掉 label-width:120px
和 gutter 后,输入框实际只剩 60 余像素,稍长一点的备注根本看不清。

改为独立整行 textarea(rows=2, maxlength=500, show-word-limit),放在
「子件列表」标题上方;编号行改为 span 10+14 填满。

连带修两处相关缺陷:
  · 暂存草稿的 payload 补 remark —— 此前根本没往后端传
  · getDraftHash 补 remark —— 否则"只改了备注"会被防呆逻辑拦成
    "草稿数据无变动,无需重复暂存"
2026-09-21 14:13:15 +08:00
6a7761abbb fix(bom): 草稿链路透传 BOM 级备注,发布后不丢失
save_draft / get_draft_detail / publish_draft 三处此前都不带 BOM 级备注,
暂存后再恢复、或草稿直接发布,备注都会丢。

同时给 /save 的权限清洗表补上顶级 remark(与子件级 remark 共用
bom_manage:remark 权限码)。

注意:运行时真正生效的草稿入口是 bom.py:521 的 /draft/save。
app/api/v1/bom_draft.py 里的 bom_draft_bp 从未在 app/__init__.py 注册,
是死代码(两份都调同一个 BomDraftService,本次两处都改以保持同步)。

实测(临时 BOM,验证后已清理):
  草稿暂存带备注 → 读回 '草稿BOM级备注-测试'
  草稿 → 发布 → 正式表 读回 '草稿备注-发布后应保留'
2026-09-21 14:13:14 +08:00
0abc5850d8 fix(bom): save_bom 落库 BOM 级备注,get_bom_detail 改读 bom_remark
save_bom() 此前从未读取 data['remark'],父件级备注被静默丢弃;
get_bom_detail() 则用 first.BomTable.remark(第一个子件行的备注)冒充
主表备注——所以填了保存后再打开必为空,偶尔还会显示出某个子件的备注
(库里 1196 行只有 1 行有 remark,值是"白色")。

这正是"BOM 备注看不见"的根因。
2026-09-21 14:13:14 +08:00
cc80fdd9c9 fix(bom): 增加 bom_remark 列,为 BOM 级备注提供存储位置
bom_table 是子件明细表(一行 = 一个子件),表里只有子件级的 remark 列,
BOM 级备注没有任何地方可存。前端一直有备注输入框、提交时也确实放进了
payload,但后端 save_bom 收到后无处可写,只能静默丢弃。

沿用 parent_id / is_enabled 既有的冗余写法:同一 BOM 版本的每一行存同一
份值,读取时取首行。bom_draft_table 同步加列,保证草稿暂存与发布期间
不丢失。与既有 remark(子件级)语义分离,互不干扰。

存量数据无法回填——历史上填过的备注从未入库,只能从本迁移之后开始记录。

执行方式: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_bom_remark.sql
2026-09-21 14:13:13 +08:00
b6ce754ba7 V3.89,9.21推送 2026-09-21 12:03:07 +08:00
e0ea946e08 fix(outbound): 出库列表补传日期参数,修复日期筛选静默失效
前端 el-date-picker 一直传 start_date/end_date(outbound/index.vue:472),
service 层 get_grouped_list() 也早有完整实现(签名接收 + 10 位日期补全
时分秒 + outbound_time 过滤),但中间 API 层从未读取这两个参数:

  outbound.py: get_outbound_list() 只读 page/limit/keyword/search_type/
  company/advancedFilters,调用 get_grouped_list() 时也没传日期。

get_grouped_list 全项目仅此一个调用方,因此 start_date/end_date 恒为
None,service 层 `if start_date and end_date:` 永远为假——用户在页面上
选了日期,后端直接忽略,结果集与不筛选时完全一致,且不报错。

修复:
  · outbound.py 读取 start_date/end_date 并透传给 service
  · outbound_service.py 把 BETWEEN 改为分别判断(原写法只传一侧时
    整段条件静默失效,与 /returns 等接口的边界处理对齐)

实测验证(SUPER_ADMIN 全量口径,与 SQL count(distinct outbound_no) 逐项对齐):
  无日期            494  (基准)
  2026-08-24 单日     3  SQL=3
  2026-05-11 单日     9  SQL=9
  2026-08 整月       88  SQL=88
  只传 start_date   229  SQL=229
修复前上述四项均恒等于 494。运行中容器经 --reload 重载后实测一致。

影响范围:仅出库主列表。其余带日期筛选的接口(/returns、transactions、
scrap、purchase、audit、inbound_summary)均已正确读取参数,未受影响。
2026-09-21 10:33:47 +08:00
8db33fdf40 V3.88,9.20推送 2026-09-20 17:01:52 +08:00
3bb5fc6eb8 fix(outbound): 执行校验兼容无 base_id 的历史单据,修复老单必然被拒
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里没有 base_id。
这类单只要在改造上线时仍处于「已通过待执行」状态,就再也执行不了:

  批准侧 build_approval_index() → identity_key(base_id, name, spec)
    无 base_id → 退化成 ('name', 名称, 规格)
  扫码侧 stock_identity()      → ('base', id)
两侧键不同构,**永远不相等**,verify_scanned() 必然抛
「扫码物料【…】不在该申请单的批准明细中,禁止出库」——批的就是这件货。

identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。

实测(APR-OUT-20260909-1313-0007,建于 09-09,改造上线前一天):
  批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
  扫码侧 ('base', 3012)

修复:
  · 新增 stock_name_spec() —— 库存行 → 名称型身份键,刻意丢掉 base_id
  · 新增 build_legacy_approval_index() —— 无 base_id 的历史明细旁路索引
  · verify_scanned() —— 主索引落空时用库存行名称+规格再比一次;命中则
    改用**批准侧的键**继续,使 acc 累计与 approved_idx 的批准量同口径

安全边界(关键):降级只对真正来自老单据的名称型键放行,白名单是
legacy_idx 而非 approved_idx 本身——build_legacy_approval_index() 只收
identity_key() 产出名称型键的明细,有 base_id 的一律跳过;名称与规格皆空
的明细直接丢弃,绝不退化成「任意物料都能匹配」。无老单时该索引为空集,
降级分支恒不命中,行为与改造前逐字节一致。

影响范围:当时处于 status=1 的老单全库仅 1 张(即上述单号)。其余 358 张
老单(已完成 327 / 已驳回 9 / 已完结 22)均为终态,不受影响——它们在改造
上线前就已执行完毕。

实测验证:
  T1 老单 360 + 扫批准范围内物料     修复前必拒 → 修复后通过
  T2 老单 360 + 扫单外物料           仍拒绝
  T3 老单 360 + 数量超批准           仍拒绝
  T4 现代单 + 扫批准范围内物料       通过(旁路索引 0 键,未受影响)
  T5 现代单 + 扫单外物料             仍拒绝

副作用改善:降级后标签改用批准侧的键,超量报错从「物料#3012」变为
「派里肯安全箱1600(黑色)(PS-9640B001-black)」,可读性提升。
2026-09-20 16:43:37 +08:00
19 changed files with 332 additions and 42 deletions

View File

@ -0,0 +1,36 @@
-- =============================================================================
-- 一次性迁移:BOM 级备注落库
-- -----------------------------------------------------------------------------
-- 背景:
-- 前端 BomManage.vue 一直有"备注"输入框(v-model="form.remark"),提交时也
-- 确实放进了 payload(BomManage.vue:1273),但后端 BomService.save_bom() 从
-- 未读取 data['remark'],父件级备注被**静默丢弃**——填了、保存、再打开必为空。
--
-- 根因是数据模型缺位:bom_table 是子件明细表(一行 = 一个子件),表里只有
-- 子件级的 remark 列,没有任何位置存放 BOM 级备注。
-- 连带 bug:读取侧 get_bom_detail() 用 first.BomTable.remark(第一个子件行的
-- 备注)冒充主表备注,所以查看时会显示某个子件的备注(如"白色")或空。
--
-- 方案:
-- bom_table / bom_draft_table 各增加 bom_remark 列,沿用 parent_id、is_enabled
-- 既有的冗余写法:同一 BOM 版本的每一行存同一份值,读取时取首行。
-- 与既有 remark(子件级)语义分离,互不干扰。
--
-- 存量数据:
-- 无法回填 —— 历史上填过的备注从未入库,任何位置都找不到,只能从本迁移
-- 之后开始记录。这是本次修复的已知代价。
--
-- 执行方式: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- 注意: 若后端启用 Redis 缓存(bom:tree:*),部署后需清缓存或重启后端,
-- 避免 12h 内命中旧值。
-- =============================================================================
BEGIN;
ALTER TABLE bom_table ADD COLUMN IF NOT EXISTS bom_remark text;
ALTER TABLE bom_draft_table ADD COLUMN IF NOT EXISTS bom_remark text;
COMMENT ON COLUMN bom_table.bom_remark IS 'BOM级备注(父件级,冗余存于每行,读取取首行)';
COMMENT ON COLUMN bom_draft_table.bom_remark IS 'BOM级备注(父件级,冗余存于每行,读取取首行)';
COMMIT;

View File

@ -41,6 +41,16 @@ services:
TRACK_API_URL: ${TRACK_API_URL:-}
# Track 出库 webhook(发货出库时通知 Track 标记"已出库")
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-}
# ★ 多 Track 实例路由表:按 material_base.company_name 决定走哪个实例。
# 上面三个扁平变量保留为「未命中路由表」时的回落默认值(向后兼容)。
# ★ 用容器名互访(同属 projects_default 网络),不走宿主机端口。
TRACK_ROUTES: >-
{"IRIS":{"api":"http://track_backend:8000",
"inbound":"http://track_backend:8000/api/v1/external/webhooks/mom-inbound",
"outbound":"http://track_backend:8000/api/v1/external/webhooks/mom-outbound"},
"LICA":{"api":"http://lica_backend:8000",
"inbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-inbound",
"outbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-outbound"}}
depends_on:
- db

View File

@ -198,6 +198,8 @@ def save_bom():
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'bom_no': 'bom_manage:bom_no',
# ★ 顶级 remark = BOM 级备注,与子件级 remark 共用同一权限码
'remark': 'bom_manage:remark',
}
# 清洗顶级字段
for field in list(req_data.keys()):
@ -531,7 +533,12 @@ def save_draft():
if not parent_id:
return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400
bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children)
# ★ 这是运行时真正生效的草稿入口(bom_draft.py 的 bom_draft_bp 未在
# app/__init__.py 注册,是死代码),BOM 级备注必须在这里透传
bom_draft_no = BomDraftService.save_draft(
bom_no, version, parent_id, children,
bom_remark=data.get('remark', '') or ''
)
return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}})

View File

@ -238,6 +238,10 @@ def get_outbound_list():
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
company = request.args.get('company', '')
# ★ 出库日期范围:前端 el-date-picker 传 10 位 YYYY-MM-DD,
# 时分秒补全在 service 层完成(与 /returns 等处口径一致)
start_date = (request.args.get('start_date') or '').strip()
end_date = (request.args.get('end_date') or '').strip()
# ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询)
from app.utils.advanced_filter import parse_advanced_filters
@ -259,6 +263,7 @@ def get_outbound_list():
# ★ [修改] 调用分组查询服务,支持搜索类型
result = OutboundService.get_grouped_list(
page, limit, keyword, search_type=search_type,
start_date=start_date, end_date=end_date,
company=company, consumer_name=consumer_name,
advanced_filters=advanced_filters,
)

View File

@ -25,6 +25,10 @@ class BomTable(db.Model):
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
remark = db.Column(db.Text, comment='备注')
# ★ BOM 级备注(父件级):与 parent_id/is_enabled 一样冗余存于本版本每一行,读取取首行。
# 上面的 remark 是**子件级**备注,语义不同,勿混用。
bom_remark = db.Column(db.Text, comment='BOM级备注(父件级,冗余存于每行,读取取首行)')
# ★ 子件引用的"自制 BOM"版本:子件物料若本身是自制件(作为其它 BOM 的父件),
# 此列记录该子件实际引用的配方版本((bom_no, version) 共同定位),由新建/编辑 BOM 时选版本保存。
# 外购件/无下级 BOM 的子件这两列为 NULL。

View File

@ -23,6 +23,10 @@ class BomDraftTable(db.Model):
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
remark = db.Column(db.Text, comment='备注')
# ★ BOM 级备注(父件级):与 bom_table.bom_remark 对齐,草稿暂存/发布期间不丢失。
# 上面的 remark 是**子件级**备注。占位头行(child_id IS NULL)同样携带该值。
bom_remark = db.Column(db.Text, comment='BOM级备注(父件级,冗余存于每行,读取取首行)')
# ★ 子件引用的"自制 BOM"版本:与 bom_table 的 child_bom_no/child_bom_version 对齐,
# 保证草稿暂存/载入期间不丢失所选版本。
child_bom_no = db.Column(db.String(100), nullable=True, index=True, comment='子件引用的自制BOM编号')

View File

@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class BomDraftService:
@staticmethod
def save_draft(bom_no, version, parent_id, children):
def save_draft(bom_no, version, parent_id, children, bom_remark=''):
try:
# 1. 删除旧草稿
old = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
@ -22,7 +22,8 @@ class BomDraftService:
if not children:
dummy_draft = BomDraftTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=None, dosage=0, loss_rate=0, remark=''
child_id=None, dosage=0, loss_rate=0, remark='',
bom_remark=bom_remark
)
db.session.add(dummy_draft)
else:
@ -34,6 +35,7 @@ class BomDraftService:
dosage=child.get('dosage', 0),
loss_rate=child.get('loss_rate', 0),
remark=child.get('remark', ''),
bom_remark=bom_remark,
child_bom_no=child.get('child_bom_no') or None,
child_bom_version=child.get('child_bom_version') or None
)
@ -87,6 +89,7 @@ class BomDraftService:
'parent_id': parent_id,
'parent_name': parent_material.name if parent_material else '',
'parent_spec': parent_material.spec_model if parent_material else '',
'remark': first.bom_remark or '', # ★ BOM 级备注
'children': children,
}
@ -125,6 +128,8 @@ class BomDraftService:
'bom_no': bom_no,
'version': version,
'parent_id': draft['parent_id'],
# ★ 草稿的 BOM 级备注要带到正式表,否则发布即丢失
'remark': draft.get('remark', '') or '',
'children': [
{
'child_id': child['child_id'],

View File

@ -404,7 +404,9 @@ class BomService:
'parent_name': parent_material.name if parent_material else '',
'parent_spec': parent_material.spec_model if parent_material else '',
'is_enabled': first.BomTable.is_enabled,
'remark': first.BomTable.remark or '', # ★ 主表备注
# ★ BOM 级备注:改取 bom_remark。此前误取 first.BomTable.remark
# (第一个子件行的备注)冒充主表备注,是本次一并修掉的连带 bug
'remark': first.BomTable.bom_remark or '',
'children': children
}
@ -421,6 +423,8 @@ class BomService:
parent_id = data['parent_id']
children = data['children']
is_enabled = data.get('is_enabled', True)
# ★ BOM 级备注:写入本版本每一行(与 is_enabled 等父件级字段同口径)
bom_remark = data.get('remark', '') or ''
if not bom_no:
raise ValueError('BOM编号不能为空')
@ -527,6 +531,7 @@ class BomService:
child_id=child['child_id'],
dosage=child.get('dosage', 0),
remark=child.get('remark', ''),
bom_remark=bom_remark,
child_bom_no=child.get('child_bom_no') or None,
child_bom_version=child.get('child_bom_version') or None,
is_enabled=is_enabled

View File

@ -4,6 +4,7 @@ from app.extensions import db
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.models.purchase import PurchaseRequest
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError
@ -153,9 +154,27 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0)
tax_rate = float(data.get('tax_rate') or 0)
# 计算税后单价
post_tax_price = float(data.get('post_tax_unit_price') or 0)
# [新增] 按单入库:关联采购申请单
request_id = data.get('request_id')
# ★ 补价:库管角色没有 inbound_purchase:unit_price / total_price 权限,
# 采购单接口(api/v1/purchase.py)会把价格字段整个 pop 掉,前端导入时
# 因此带不出价(buy.vue 的 hasUnitPrice/hasTotalPrice 双双落空,
# 两个分支都不进)→ 入库成本被记成 0。
# 这里在落库前从采购申请单补回:价格**不经过前端**,库管依旧看不到
# 采购价,权限边界不变,但成本可追溯。
# 注:采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
# 的列名"含税单价"),需要反算不含税单价。
if request_id and u_price <= 0 and post_tax_price <= 0:
_req = PurchaseRequest.query.get(request_id)
if _req and float(_req.unit_price or 0) > 0:
tax_rate = float(_req.tax_rate or 0)
post_tax_price = float(_req.unit_price)
u_price = post_tax_price / (1 + tax_rate / 100)
# 计算税后单价(保底:只给了不含税价时反推)
if post_tax_price == 0 and u_price > 0:
tax_multiplier = 1 + (tax_rate / 100)
post_tax_price = u_price * tax_multiplier
@ -170,9 +189,6 @@ class BuyInboundService:
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku
# [新增] 按单入库:关联采购申请单
request_id = data.get('request_id')
new_stock = StockBuy(
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
@ -201,8 +217,10 @@ class BuyInboundService:
db.session.flush() # 获取 new_stock.id
# [新增] 按单入库:反写采购申请单状态为"已完成"
# 注:原先此处有一句局部 `from app.models.purchase import PurchaseRequest`,
# 它会让 Python 把 PurchaseRequest 视为本函数的局部变量,导致上方补价
# 逻辑(在 import 之前使用)抛 UnboundLocalError。已改用文件顶部的全局导入。
if request_id:
from app.models.purchase import PurchaseRequest
purchase_req = db.session.get(PurchaseRequest, request_id)
if purchase_req:
if purchase_req.status != 1:

View File

@ -226,6 +226,8 @@ class ProductInboundService:
'sku': material.spec_model or material.name,
'serial_number': new_stock.serial_number,
'quantity': float(new_stock.in_quantity or 0),
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
'company_name': getattr(material, 'company_name', None),
'operator': get_current_operator(),
})
return new_stock

View File

@ -265,6 +265,8 @@ class SemiInboundService:
'sku': material.spec_model or material.name,
'serial_number': webhook_sn,
'quantity': float(new_stock.in_quantity or 0),
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
'company_name': getattr(material, 'company_name', None),
'operator': get_current_operator(),
})
return new_stock

View File

@ -163,6 +163,23 @@ def stock_identity(model_row):
return identity_key(base_id, name, spec, getattr(model_row, 'sku', ''))
def stock_name_spec(model_row):
"""
库存行 → **名称型**身份键 ('name', 名称, 规格)。
★ 与 stock_identity() 的区别:后者优先用 base_id 产出 ('base', id),
本函数**刻意丢掉 base_id**,强制走名称兜底。用途见
build_legacy_approval_index() —— 给没有 base_id 的历史批准明细
提供一条可比的旁路键。
"""
base = getattr(model_row, 'base', None)
return identity_key(
None,
base.name if base else '',
base.spec_model if base else '',
)
# =============================================================================
# 预占 / 释放
# =============================================================================
@ -435,6 +452,48 @@ def build_approval_index(approved_items):
return index
def build_legacy_approval_index(approved_items):
"""
无 base_id 的历史明细 → {('name', 名称, 规格): {'qty', 'label'}}
★ 为什么需要这个旁路索引
------------------------
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里**没有 base_id**
(亦无 stock_id / source_table / reserved)。build_approval_index() 对它
只能产出名称型身份键 ('name', 名称, 规格);而扫码侧 stock_identity()
拿的是库存行的 base_id,产出 ('base', id)。两侧键不同构,**永远不相等**,
于是这类老单只要还没执行完就必然被 verify_scanned() 拒绝,报
「扫码物料【…】不在该申请单的批准明细中」—— 明明批的就是这件货。
实测(单 APR-OUT-20260909-1313-0007,建于 09-09、改造上线前一天):
批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
扫码侧 ('base', 3012)
identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。
★ 严格限定适用范围(避免放宽现代单据的校验)
· 只收录身份键为名称型(即无有效 base_id)的明细;有 base_id 的一律
走 build_approval_index() 的主索引,此处跳过。
· 名称与规格都为空的明细直接丢弃 —— 无法兜底,且绝不能退化成
「任意物料都能匹配」。
本索引为空时(无老单),verify_scanned() 的降级分支恒不命中,
行为与改造前完全一致。
"""
index = {}
for it in approved_items or []:
key = identity_key(it.get('base_id'), it.get('name'), it.get('spec_model'))
if key[0] != 'name':
continue # 有 base_id → 归主索引,不放宽
if not key[1] and not key[2]:
continue # 名称规格皆空,无从兜底
entry = index.setdefault(key, {'qty': 0.0, 'label': identity_label(key)})
try:
entry['qty'] += float(it.get('quantity') or it.get('allocated_qty') or 0)
except (TypeError, ValueError):
pass
return index
def verify_scanned(scanned_items, approved_items):
"""
★ Phase 3 校验:实扫明细的身份与数量必须落在批准范围内。
@ -460,6 +519,12 @@ def verify_scanned(scanned_items, approved_items):
if not approved_idx:
raise ValueError('审批单明细为空,无法执行')
# ★ 无 base_id 的历史明细旁路(说明见 build_legacy_approval_index)。
# 这里只把它当作「哪些名称型身份键确实来自老单据」的白名单 ——
# 数量口径仍以 approved_idx 为准,两者对同一身份给出相同的批准量。
# 无老单时为空集,下方降级分支恒不命中,行为与改造前完全一致。
legacy_idx = build_legacy_approval_index(approved_items)
acc = {}
normalized = []
@ -503,9 +568,22 @@ def verify_scanned(scanned_items, approved_items):
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
if key not in approved_idx:
raise ValueError(
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
)
# ★ 降级匹配(老单专用旁路)
#
# 无 base_id 的历史批准明细身份键是名称型 ('name', 名称, 规格),
# 扫码侧是 ('base', id) —— 两者不同构,直接比永远落空,
# 批的就是这件货也会被判「不在批准明细中」。
# 此处用库存行的名称+规格再比一次;命中则**改用批准侧的键**,
# 使 acc 累计与 approved_idx 的批准量保持同口径。
# 白名单是 legacy_idx 而非 approved_idx 本身:只有真正来自
# 老单据的名称型键才放行,杜绝这条旁路把现代单据的校验放宽。
alt = stock_name_spec(row)
if alt not in legacy_idx:
raise ValueError(
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
)
key = alt
label = identity_label(key)
acc[key] = acc.get(key, 0.0) + qty
if acc[key] > approved_idx[key]['qty']:

View File

@ -359,7 +359,11 @@ class OutboundService:
repair.shipping_date = current_time
# 收集 Track 联动信息(维修单带 serial_number)
track_notifications.append((getattr(repair, 'serial_number', None), source_table, quantity))
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
track_notifications.append((
getattr(repair, 'serial_number', None), source_table, quantity,
getattr(getattr(repair, 'base', None), 'company_name', None),
))
# 创建出库记录
new_record = TransOutbound(
@ -394,7 +398,11 @@ class OutboundService:
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
# 收集 Track 联动信息(库存表 serial_number = Track 身份证)
track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, quantity))
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
track_notifications.append((
getattr(stock_record, 'serial_number', None), source_table, quantity,
getattr(getattr(stock_record, 'base', None), 'company_name', None),
))
new_record = TransOutbound(
sku=item.get('sku'),
@ -419,11 +427,11 @@ class OutboundService:
db.session.commit()
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
# 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务
# 按每条的 company_name 路由到对应实例(IRIS / LICA)。
# 这里刻意**不**显式传 url:显式 url 优先级最高,会一律打到默认实例,
# 导致 LICA 的出库永远收不到联动。notify_track 自身容错,不阻断业务。
try:
from flask import current_app
outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL')
for sn, src_tbl, qty in track_notifications:
for sn, src_tbl, qty, company in track_notifications:
if not sn:
continue
notify_track({
@ -432,8 +440,9 @@ class OutboundService:
'serial_number': str(sn),
'quantity': float(qty or 0),
'outbound_type': common_data['outbound_type'],
'company_name': company,
'operator': get_current_operator(),
}, url=outbound_url)
})
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
@ -708,8 +717,11 @@ class OutboundService:
)
continue
if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
# ★ 日期边界分别判断:用 BETWEEN 时若只传一侧会整段静默失效
if start_date:
stmt = stmt.filter(TransOutbound.outbound_time >= start_date)
if end_date:
stmt = stmt.filter(TransOutbound.outbound_time <= end_date)
# 【行级数据隔离】应用公司过滤到主查询
if company_limit is not None:

View File

@ -29,15 +29,28 @@ def lookup_product(code):
sku/material_name/spec_model/material_type/order_no),未命中或异常返回 None。
"""
try:
base_url = (current_app.config.get('TRACK_API_URL') or '').strip()
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
if not base_url:
logger.info("[TrackQuery] 未配置 TRACK_API_URL,跳过查询")
return None
if not api_key:
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询")
return None
# ★ 按当前用户所属公司路由到对应的 Track 实例(IRIS / LICA)。
# 复用 get_current_company_filter():超管/跨域用户返回 None → 回落默认实例。
# 改造前这里写死读 TRACK_API_URL,LICA 员工在 LICA 业务里扫码会打到 IRIS 库,
# 查不到 → MOM 误报"物料不存在"。
from app.services.track_webhook_service import resolve_track_route
from app.utils.decorators import get_current_company_filter
try:
company = get_current_company_filter()
except Exception:
# 无请求/JWT 上下文(脚本、后台任务)时回落默认实例,不阻断查询
company = None
base_url = resolve_track_route(company, 'api')
if not base_url:
logger.info("[TrackQuery] 未解析到 Track API 地址,跳过查询")
return None
url = f'{base_url}/api/v1/external/products/lookup'
headers = {
'Content-Type': 'application/json',

View File

@ -37,6 +37,54 @@ def _post_to_track(payload, url, api_key):
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
# 各通道未命中路由表时回落到哪个扁平配置变量(保持改造前的单实例行为)
_TRACK_ROUTE_FALLBACK = {
'api': 'TRACK_API_URL',
'inbound': 'TRACK_WEBHOOK_URL',
'outbound': 'TRACK_OUTBOUND_WEBHOOK_URL',
}
def resolve_track_route(company_name, channel):
"""按公司名解析 Track 实例地址。
channel: 'api' | 'inbound' | 'outbound'
⚠️ 未命中必须**回落到扁平变量**,不能返回空 —— 漏配一个 key 就静默断链,
比配置写错更难排查。
"""
routes = current_app.config.get('TRACK_ROUTES') or {}
if company_name and company_name in routes:
url = ((routes.get(company_name) or {}).get(channel) or '').strip()
if url:
return url
# 公司在表里但该通道没配 —— 这是配置漏项,务必暴露出来
logger.warning(
"[TrackRoute] 公司 %s 的 %s 通道未配置,回落到全局默认地址",
company_name, channel,
)
elif company_name:
# 非空但不在路由表内:可能是拼写异常的公司名,也可能是
# get_current_company_filter() 的 '__NO_COMPANY__' 哨兵(用户 JWT 无公司)。
# 不猜、不阻断,只暴露给业务确认。
logger.warning(
"[TrackRoute] 公司 '%s' 不在 TRACK_ROUTES 中,回落到全局默认地址(channel=%s)。"
"若该值不是合法部门名,请业务确认主数据",
company_name, channel,
)
fallback_key = _TRACK_ROUTE_FALLBACK.get(channel)
if not fallback_key:
return ''
return (current_app.config.get(fallback_key) or '').strip()
def _channel_from_event(event):
"""从事件名推断路由通道;缺省按入库处理。"""
return 'outbound' if 'outbound' in (event or '').lower() else 'inbound'
def get_current_operator():
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
try:
@ -50,7 +98,7 @@ def get_current_operator():
return ''
def notify_track(payload, url=None):
def notify_track(payload, url=None, channel=None):
"""
异步通知 Track 系统业务事件(入库/出库等)。
@ -58,10 +106,15 @@ def notify_track(payload, url=None):
- 未配置 URL / Key 为空 -> 静默跳过
- 网络异常 / 超时 -> 仅记录日志
:param url: 指定 Track webhook 地址;不传则用默认 TRACK_WEBHOOK_URL(入库)
:param url: 显式指定 Track webhook 地址,优先级最高(指定后不再做公司路由)
:param channel: 路由通道 'inbound' | 'outbound';不传则按 payload['event'] 推断。
仅在不传 url 时生效——实例由 payload['company_name'] 决定。
"""
try:
url = (url or current_app.config.get('TRACK_WEBHOOK_URL') or '').strip()
if not url:
channel = channel or _channel_from_event(payload.get('event'))
url = resolve_track_route(payload.get('company_name'), channel)
url = (url or '').strip()
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
if not url:
logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知")

View File

@ -1,3 +1,4 @@
import json
import os
from datetime import timedelta
@ -85,3 +86,17 @@ class Config:
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '')
# =========================================================
# 9. 多 Track 实例路由表 (IRIS / LICA)
# =========================================================
# JSON 结构: {"IRIS": {"api": "...", "inbound": "...", "outbound": "..."}, "LICA": {...}}
# 由 material_base.company_name 决定业务落到哪个 Track 实例。
# 缺省为空字典 → 全部回落到上面第 7/8 节的扁平变量,行为与改造前一致。
#
# ⚠️ 解析失败直接让进程起不来(fail fast)——路由表写坏却静默回落,
# 会造成"某些公司悄悄断链",比启动报错难排查得多。
try:
TRACK_ROUTES = json.loads(os.getenv('TRACK_ROUTES', '{}') or '{}')
except ValueError as e:
raise RuntimeError(f'TRACK_ROUTES 不是合法 JSON,请检查环境变量: {e}')

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.87
当前版本:V3.91
</span>
</footer>

View File

@ -145,12 +145,7 @@
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
<el-input v-model="form.remark" placeholder="备注信息(可选)" :disabled="isReadOnlyMode" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="14">
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
<template v-if="isSaveAsMode">
<el-radio-group v-model="form.versionUpgradeType" :disabled="isReadOnlyMode" @change="onVersionUpgradeTypeChange">
@ -165,6 +160,21 @@
</el-col>
</el-row>
<!-- ★ BOM 级备注:独立整行,放在子件列表上方。
原位置在编号/版本号那一行的 span=6 里,扣掉 120px 的 label 后
输入框实际只剩 60 余像素,稍长一点的备注根本看不清。 -->
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')" style="margin-bottom: 5px;">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="BOM 级备注(可选),如适用场景、工艺要求等"
:disabled="isReadOnlyMode"
/>
</el-form-item>
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">
子件列表
<span style="font-weight: normal; font-size: 12px; color: #909399; margin-left: 10px;">(已选 {{ filteredChildren.length }} 条)</span>
@ -408,6 +418,9 @@ const getDraftHash = () => {
bom_no: form.bom_no,
version: form.version,
parent_id: form.parent_id,
// ★ BOM 级备注也要参与比对,否则"只改了备注"会被防呆逻辑
// 拦成"草稿数据无变动,无需重复暂存"
remark: form.remark || '',
children
})
}
@ -852,6 +865,7 @@ const executeSaveDraftRequest = async (targetBomNo: string) => {
bom_no: targetBomNo,
version: draftVersion,
parent_id: form.parent_id,
remark: form.remark || '',
children
})
if (res.code === 200) {

View File

@ -1943,14 +1943,21 @@ const confirmPurchaseImport = () => {
form.tax_rate = Number(po.tax_rate)
}
if (hasTotalPrice) {
// 优先: 含税总价 → 调用 post_total 逻辑反算单价
postTaxTotalInput.value = Number(po.total_price)
onPostTaxTotalChange(postTaxTotalInput.value)
} else if (hasUnitPrice) {
// 备选: 含税单价 → 反算不含税
// 导入新单前清掉上一单残留的"手工总价"标记,否则 updatePrices 末尾会用旧值回头覆盖
postTaxTotalManuallySet.value = false
postTaxTotalInput.value = undefined
if (hasUnitPrice) {
// ★ 单价优先。申请单的 total_price 是「申请数量」的钱,而实际入库数量未必相同
// —— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态。用总价去除以实际
// 入库数量会把单价摊薄或抬高,必须一律以单价为准,总价由「单价 × 实际入库数量」得出。
// 含税单价 → 反算不含税
form.post_tax_unit_price = Number(po.unit_price)
updatePrices('post')
} else if (hasTotalPrice) {
// 兜底:申请单只有总价、没有单价时,才按总价反算
postTaxTotalInput.value = Number(po.total_price)
onPostTaxTotalChange(postTaxTotalInput.value)
}
if (po.supplier_link) {
form.detail_link = po.supplier_link