Compare commits
16 Commits
4f922a55f3
...
3.0AI添加
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b4a2b29d6 | |||
| 3851ebd7bc | |||
| d99396ecb1 | |||
| 91db54982c | |||
| 0f929468a8 | |||
| b85be2dfdf | |||
| de34a939d0 | |||
| 3b777af87d | |||
| 2319a053df | |||
| 6a7761abbb | |||
| 0abc5850d8 | |||
| cc80fdd9c9 | |||
| b6ce754ba7 | |||
| e0ea946e08 | |||
| 8db33fdf40 | |||
| 3bb5fc6eb8 |
36
db_migrations/add_bom_remark.sql
Normal file
36
db_migrations/add_bom_remark.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}})
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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编号')
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,跳过通知")
|
||||
|
||||
@ -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}')
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user