Compare commits
7 Commits
3b777af87d
...
0b4a2b29d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b4a2b29d6 | |||
| 3851ebd7bc | |||
| d99396ecb1 | |||
| 91db54982c | |||
| 0f929468a8 | |||
| b85be2dfdf | |||
| de34a939d0 |
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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.90
|
||||
当前版本:V3.91
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -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