7 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
10 changed files with 156 additions and 27 deletions

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

@ -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

@ -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}")

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_URLLICA 员工在 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.90
当前版本:V3.91
</span>
</footer>

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