Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea8a05459 | ||
|
|
e7b2dd64f1 | ||
|
|
1eb96ece68 | ||
|
|
c088fdb823 | ||
|
|
862f868979 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -26,3 +26,6 @@ tmp_cache/
|
||||
# 合同生成输出文件
|
||||
static/uploads/contract_output/
|
||||
ai_config.py
|
||||
|
||||
# 本机/云端运行密钥(不进入版本库)
|
||||
.secrets/
|
||||
|
||||
2
app.py
2
app.py
@ -137,7 +137,7 @@ def spa_fallback(path):
|
||||
SPA_PATHS = {
|
||||
'knowledge', 'planning', 'products', 'bom', 'purchase',
|
||||
'orders', 'inventory', 'warehouse', 'suppliers',
|
||||
'users', 'data', 'config', 'logs', 'permissions',
|
||||
'users', 'organization', 'department-matching', 'data', 'config', 'logs', 'permissions',
|
||||
'login-records',
|
||||
'contract-templates', 'personnel',
|
||||
'collaboration', 'spot', 'ai-analysis', 'product-order',
|
||||
|
||||
17
deploy/dy-dingtalk-org-sync.service
Normal file
17
deploy/dy-dingtalk-org-sync.service
Normal file
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Synchronize DingTalk organization structure
|
||||
After=network-online.target mysql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
WorkingDirectory=/opt/dy-dashboard
|
||||
Environment=HOME=/home/ubuntu
|
||||
Environment=TZ=Asia/Shanghai
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/opt/dy-dashboard/venv/bin/python /opt/dy-dashboard/scripts/sync_dingtalk_organization.py
|
||||
TimeoutStartSec=30min
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
10
deploy/dy-dingtalk-org-sync.timer
Normal file
10
deploy/dy-dingtalk-org-sync.timer
Normal file
@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Synchronize DingTalk organization structure every day at 02:00
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 02:00:00
|
||||
Persistent=true
|
||||
Unit=dy-dingtalk-org-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -14,6 +14,8 @@ from routes.contract import contract_bp
|
||||
from routes.ai import ai_bp
|
||||
from routes.sku_forecast import sku_forecast_bp
|
||||
from routes.spot import spot_bp
|
||||
from routes.organization import organization_bp
|
||||
from routes.department_matching import department_matching_bp
|
||||
|
||||
def register_blueprints(app):
|
||||
app.register_blueprint(auth_bp)
|
||||
@ -30,7 +32,10 @@ def register_blueprints(app):
|
||||
app.register_blueprint(ai_bp)
|
||||
app.register_blueprint(sku_forecast_bp)
|
||||
app.register_blueprint(spot_bp)
|
||||
app.register_blueprint(organization_bp)
|
||||
app.register_blueprint(department_matching_bp)
|
||||
|
||||
__all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp',
|
||||
'inventory_bp', 'planning_bp', 'purchase_bp', 'warehouse_bp',
|
||||
'users_bp', 'data_bp', 'knowledge_bp', 'contract_bp', 'ai_bp', 'sku_forecast_bp', 'spot_bp']
|
||||
'users_bp', 'data_bp', 'knowledge_bp', 'contract_bp', 'ai_bp', 'sku_forecast_bp', 'spot_bp',
|
||||
'organization_bp']
|
||||
|
||||
110
routes/ai.py
110
routes/ai.py
@ -40,7 +40,7 @@ def _save_history(user_id, question, summary, datasource, result_type, content=N
|
||||
# 数据源使用说明(供 AI 参考参数格式)
|
||||
AI_PARAMS_GUIDE = """
|
||||
数据源参数说明(params 为 JSON 对象,可选字段):
|
||||
- product_code: 按产品编码/名称筛选(模糊匹配)
|
||||
- product_code: 按产品编码/名称筛选(模糊匹配);operation_history 也支持 product_name 按产品名称检索旧日志文本
|
||||
- brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit /
|
||||
material_inventory / inventory_value / brand_overview 均支持)
|
||||
- sort_by: 排序字段(必须是该数据源返回的字段名)
|
||||
@ -48,6 +48,8 @@ AI_PARAMS_GUIDE = """
|
||||
- limit: 返回行数上限(默认50,最大200)
|
||||
- kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品
|
||||
- days: 出库统计排行和按日出库统计均支持,最近 N 天(如问"最近30天"传 30)
|
||||
- operation_history 专用参数:module=板块名称,field=修改字段,operator=操作人,start_date/end_date=YYYY-MM-DD。
|
||||
- 查询修改记录时必须提供 product_code 或明确的板块/人员/日期条件;不做无条件全库扫描。
|
||||
"""
|
||||
|
||||
|
||||
@ -86,6 +88,51 @@ def _can_view_datasource(uid, role, key):
|
||||
return False
|
||||
|
||||
|
||||
def _filter_operation_history_permissions(uid, role, rows):
|
||||
"""日志查询逐条遵守原业务页面查看权限及字段查看权限。"""
|
||||
if is_admin_role(role):
|
||||
return rows
|
||||
from routes.auth import _has_effective_page_permission
|
||||
from services.field_perms import field_perms_for_page
|
||||
page_fields = {
|
||||
'planning': 'planning', '产品信息管理': 'products', 'products': 'products',
|
||||
'计划预测': 'planning', '国内订单管理': 'order_domestic', 'order_domestic': 'order_domestic',
|
||||
'海外订单管理': 'order_overseas', 'order_overseas': 'order_overseas',
|
||||
'包材订单管理': 'order_packaging', 'order_packaging': 'order_packaging',
|
||||
'待下单管理': 'purchase_pending', '下单备份管理': 'purchase_backup',
|
||||
'物料清单管理': 'bom', 'bom': 'bom', '供应商管理': 'suppliers', 'suppliers': 'suppliers',
|
||||
'现货采样申请': 'spot_application', '现货采样确认': 'spot_confirmation',
|
||||
'现货销量管理': 'spot_sales',
|
||||
}
|
||||
page_ids = set(page_fields.values())
|
||||
fields_cache = {}
|
||||
filtered = []
|
||||
for row in rows:
|
||||
page = str(row.get('板块') or '')
|
||||
page_id = page_fields.get(page, page if page in page_ids else '')
|
||||
if not page_id:
|
||||
row = dict(row, **{'修改内容': '该历史日志所属菜单无法确认,修改内容已隐藏'})
|
||||
filtered.append(row)
|
||||
continue
|
||||
if page_id not in fields_cache:
|
||||
fields_cache[page_id] = field_perms_for_page(uid, role, page_id)
|
||||
field_perms = fields_cache[page_id]
|
||||
content = row.get('修改内容') or ''
|
||||
# 修改内容由“字段: 旧值 → 新值”组成;无结构化字段时隐藏原文,避免泄露未授权字段。
|
||||
if ' → ' in content and field_perms:
|
||||
visible_changes = []
|
||||
for change in content.split(';'):
|
||||
key = re.split(r'[::]\s*', change, maxsplit=1)[0]
|
||||
if field_perms.get(key, {}).get('view', False):
|
||||
visible_changes.append(change)
|
||||
content = ';'.join(visible_changes) or '修改字段内容因字段权限不可见'
|
||||
else:
|
||||
content = '修改内容未提供字段级权限定义,已隐藏'
|
||||
row = dict(row, **{'修改内容': content})
|
||||
filtered.append(row)
|
||||
return filtered
|
||||
|
||||
|
||||
def _can_export_ai(uid, role):
|
||||
"""导出权限:显式配置优先,未配置时跟随页面查看权限"""
|
||||
if is_admin_role(role):
|
||||
@ -435,7 +482,28 @@ def ai_ask():
|
||||
denied.append(ds.get('name') or key)
|
||||
continue
|
||||
try:
|
||||
result = ds['handler'](task.get('params') or {}) or {}
|
||||
params = dict(task.get('params') or {})
|
||||
if key == 'operation_history' and not is_admin_role(role):
|
||||
from routes.auth import _has_effective_page_permission
|
||||
page_ids = ['planning', 'products', 'order_domestic', 'order_overseas', 'order_packaging',
|
||||
'purchase_pending', 'purchase_backup', 'bom', 'suppliers', 'spot_application',
|
||||
'spot_confirmation', 'spot_sales']
|
||||
allowed_ids = {page for page in page_ids if _has_effective_page_permission(uid, role, page)}
|
||||
params['_allowed_pages'] = [page for page, page_id in {
|
||||
'planning': 'planning', '计划预测': 'planning', 'products': 'products', '产品信息管理': 'products',
|
||||
'order_domestic': 'order_domestic', '国内订单管理': 'order_domestic',
|
||||
'order_overseas': 'order_overseas', '海外订单管理': 'order_overseas',
|
||||
'order_packaging': 'order_packaging', '包材订单管理': 'order_packaging',
|
||||
'purchase_pending': 'purchase_pending', '待下单管理': 'purchase_pending',
|
||||
'purchase_backup': 'purchase_backup', '下单备份管理': 'purchase_backup',
|
||||
'bom': 'bom', '物料清单管理': 'bom', 'suppliers': 'suppliers', '供应商管理': 'suppliers',
|
||||
'spot_application': 'spot_application', '现货采样申请': 'spot_application',
|
||||
'spot_confirmation': 'spot_confirmation', '现货采样确认': 'spot_confirmation',
|
||||
'spot_sales': 'spot_sales', '现货销量管理': 'spot_sales',
|
||||
}.items() if page_id in allowed_ids]
|
||||
result = ds['handler'](params) or {}
|
||||
if key == 'operation_history':
|
||||
result['rows'] = _filter_operation_history_permissions(uid, role, result.get('rows') or [])
|
||||
meta = datasource_meta(key)
|
||||
datasets.append({
|
||||
'key': key,
|
||||
@ -468,8 +536,35 @@ def ai_ask():
|
||||
json.dumps(payload, ensure_ascii=False, default=str))
|
||||
return jsonify({"code": 200, "data": payload})
|
||||
|
||||
# 操作日志结果直接确定性总结,避免模型改写或混淆精确匹配与历史文本匹配。
|
||||
if all(d['key'] == 'operation_history' for d in datasets):
|
||||
ds = datasets[0]
|
||||
total = int((ds.get('summary') or {}).get('row_count') or 0)
|
||||
returned = len(ds.get('rows') or [])
|
||||
exact = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '精确对象匹配')
|
||||
fallback = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '历史文本匹配')
|
||||
subject = ((ds.get('params') or {}).get('product_code')
|
||||
or (ds.get('params') or {}).get('product_name') or '指定条件')
|
||||
summary = '找到%s条与%s相关的操作记录。' % (total, subject) if total else '没有找到符合条件的操作记录。'
|
||||
limitations = []
|
||||
if total > returned:
|
||||
limitations.append('当前明细最多展示%s条,匹配总数为%s条。' % (returned, total))
|
||||
if fallback:
|
||||
limitations.append('%s条为历史文本匹配,旧日志可能未保存完整的修改前值。' % fallback)
|
||||
if exact:
|
||||
limitations.append('%s条为精确业务对象匹配。' % exact)
|
||||
analysis = {
|
||||
'title': '操作记录查询', 'summary': summary,
|
||||
'findings': [{'title': '匹配结果', 'detail': '符合条件%s条,返回%s条;其中精确对象匹配%s条,历史文本匹配%s条。' % (total, returned, exact, fallback), 'level': '信息'}],
|
||||
'risks': [], 'actions': [], 'evidence': [], 'follow_ups': [], 'confidence': '高' if not fallback else '中',
|
||||
'limitations': limitations, 'chart': {'type': 'none'},
|
||||
}
|
||||
else:
|
||||
analysis = None
|
||||
|
||||
# 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。
|
||||
analysis = _apply_authoritative_inventory_summary(None, datasets, question)
|
||||
if analysis is None:
|
||||
analysis = _apply_authoritative_inventory_summary(None, datasets, question)
|
||||
if analysis is None:
|
||||
try:
|
||||
analysis = _synthesize_analysis(question, plan, datasets, denied, failed)
|
||||
@ -555,7 +650,7 @@ def _normalize_tasks(raw_tasks):
|
||||
# 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。
|
||||
allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days',
|
||||
'supplier', 'status', 'product_type', 'planner', 'follower', 'operator',
|
||||
'material_code'}
|
||||
'material_code', 'module', 'field', 'start_date', 'end_date', 'product_name'}
|
||||
params = {k: v for k, v in params.items() if k in allowed}
|
||||
try:
|
||||
params['limit'] = max(1, min(int(params.get('limit') or 50), 200))
|
||||
@ -566,6 +661,10 @@ def _normalize_tasks(raw_tasks):
|
||||
seen.add(key)
|
||||
if len(tasks) >= 6:
|
||||
break
|
||||
# 查询操作日志时仅允许读取该数据源,避免同一问题额外触发业务数据查询。
|
||||
history_tasks = [task for task in tasks if task['datasource'] == 'operation_history']
|
||||
if history_tasks:
|
||||
return history_tasks[:1]
|
||||
return tasks
|
||||
|
||||
|
||||
@ -639,6 +738,9 @@ def _plan_analysis(question, history, last_result):
|
||||
'7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,'
|
||||
'所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。'
|
||||
'全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n'
|
||||
'8. 问某产品或板块的手动编辑、谁修改过、修改前后、操作历史时,使用operation_history;'
|
||||
'优先传入product_code;用户只提供产品名称时传product_name,指定板块/人员/日期时传module/operator/start_date/end_date;'
|
||||
'不要为查询操作记录调用其他业务数据源。\n'
|
||||
'只输出JSON对象,格式:'
|
||||
'{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",'
|
||||
'"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],'
|
||||
|
||||
@ -109,6 +109,8 @@ PAGE_FEATURES = {
|
||||
'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}],
|
||||
'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}],
|
||||
'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}],
|
||||
'organization': [{'id':'view','name':'查看'},{'id':'sync','name':'同步钉钉组织架构'}],
|
||||
'department_matching': [{'id':'view','name':'查看与匹配测试'},{'id':'create','name':'新增规则'},{'id':'edit','name':'编辑规则'},{'id':'delete','name':'删除规则'}],
|
||||
'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}],
|
||||
'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}],
|
||||
'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}],
|
||||
|
||||
@ -6,7 +6,11 @@ from flask import Blueprint, render_template, request, jsonify, session, redirec
|
||||
from services.db import query
|
||||
from routes.auth import login_required, role_required, operation_required
|
||||
from services.simple_cache import get as cache_get, set as cache_set, delete as cache_del
|
||||
from services.inventory_calc import resolve_cost_price, compute_inventory_cost_summary
|
||||
from services.inventory_calc import (
|
||||
active_warehouse_condition,
|
||||
compute_inventory_cost_summary,
|
||||
resolve_cost_price,
|
||||
)
|
||||
from services.field_perms import filter_rows as _filter_field_rows
|
||||
from services.audit_schema import ensure_audit_schema
|
||||
import config as app_config
|
||||
@ -45,6 +49,14 @@ def settings_page():
|
||||
return render_template('base.html', page='settings')
|
||||
|
||||
|
||||
@dashboard_bp.route('/organization')
|
||||
@login_required
|
||||
def organization_page():
|
||||
if not role_required('organization'):
|
||||
return redirect(url_for('auth.index'))
|
||||
return render_template('base.html', page='organization')
|
||||
|
||||
|
||||
@dashboard_bp.route('/favicon.ico')
|
||||
def favicon():
|
||||
from flask import redirect as _rd
|
||||
@ -668,12 +680,12 @@ def _inventory_board_core(month, force_system_refresh=False):
|
||||
口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料
|
||||
板块2 经营效率:
|
||||
- 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径)
|
||||
- 月销货成本:daily_outbound 近 30 天 SUM(货品成本)
|
||||
- 月销货成本:daily_outbound 所选自然月 SUM(货品成本)
|
||||
- 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位
|
||||
- 月采购入库:arrival_batches 近 30 天 SUM(arrival_qty)
|
||||
- 月采购入库:arrival_batches 所选自然月 SUM(arrival_qty)
|
||||
- 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率
|
||||
- 动销SKU(>30/>100):近 30/100 天有出库记录的成品口径 SKU(排除内料)
|
||||
- 总SKU:products 成品/清库存/现货/赠品计数;动销率 = 动销SKU(30天)/总SKU
|
||||
- 动销SKU(≥30/≥100):所选月累计出库达到 30/100 件的成品口径 SKU(排除内料)
|
||||
- 总SKU:月末启用仓库库存分别达到 1/200 件的成品口径去重 SKU
|
||||
板块3 呆滞治理:
|
||||
- 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天
|
||||
首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量
|
||||
@ -692,8 +704,11 @@ def _inventory_board_core(month, force_system_refresh=False):
|
||||
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
# 此看板包含按月份人工保存的数据。Gunicorn 多 worker 下进程内缓存无法同步,
|
||||
# 会导致保存后请求落到其他 worker 时回显旧值,因此该接口始终读取数据库最新值。
|
||||
# 月末可用库存快照。若所选月份早于数据源起始日期,不再回退到最新月份,全部系统计算值按 0 处理。
|
||||
latest_date = query("SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date < %s", (next_month,))[0]['d']
|
||||
# 所选月内最后一个库存快照。该月没有快照时不借用上月/未来月份数据,避免月份与数据日期错位。
|
||||
latest_date = query(
|
||||
"SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date >= %s AND data_date < %s",
|
||||
(month_start, next_month),
|
||||
)[0]['d']
|
||||
if not latest_date:
|
||||
result = {
|
||||
'all_amount': 0, 'wh01_amount': 0, 'wh15_amount': 0, 'cloud_amount': 0, 'overseas_amount': 0, 'domestic_self_amount': 0,
|
||||
@ -756,19 +771,22 @@ def _inventory_board_core(month, force_system_refresh=False):
|
||||
'stockout_sku_lingdong': '缺货SKU次数(国际-灵动)',
|
||||
'stockout_sku_chunyue': '缺货SKU次数(国际-春跃)',
|
||||
}
|
||||
# 这6个输入框以月份外部录入表为唯一数据源,不能被系统计算值或“人工新建”旧值覆盖。
|
||||
# 有外部录入时覆盖系统值;未录入或清空时回退系统计算值。
|
||||
for key in external_fields:
|
||||
value = external_map.get(key)
|
||||
if value in ('', None):
|
||||
result[key] = 0
|
||||
if key not in external_map or external_map.get(key) in ('', None):
|
||||
continue
|
||||
value = external_map[key]
|
||||
try:
|
||||
result[key] = float(value)
|
||||
except (TypeError, ValueError):
|
||||
result[key] = 0
|
||||
continue
|
||||
# 根据当月手工值实时计算派生指标。
|
||||
result['active_sku_30'] = result['active_sku']
|
||||
result['return_rate'] = round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2) if result['monthly_ship_cost'] else 0
|
||||
result['return_rate'] = (
|
||||
round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2)
|
||||
if result.get('monthly_return_cost') is not None and result.get('monthly_ship_cost')
|
||||
else None
|
||||
)
|
||||
result['turnover_rate'] = round(result['monthly_ship_cost'] / result['inventory_total_amount'], 4) if result['inventory_total_amount'] else 0
|
||||
month_days = (next_month - month_start).days
|
||||
result['turnover_days'] = round(month_days / result['turnover_rate'], 1) if result['turnover_rate'] else 0
|
||||
@ -833,10 +851,20 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
_inventory_signature = query(
|
||||
"SELECT COUNT(*) AS row_count, COALESCE(SUM(available_qty), 0) AS qty_sum, "
|
||||
"DATE_FORMAT(MAX(updated_at), '%%Y%%m%%d%%H%%i%%s%%f') AS updated_signature "
|
||||
"FROM inventory_detail WHERE data_date=%s", (latest_date,))[0]
|
||||
_cache_key = 'inventory_board_system:%s:%s:%s:%s:%s' % (
|
||||
month, latest_date, _inventory_signature['row_count'],
|
||||
_inventory_signature['qty_sum'], _inventory_signature['updated_signature'] or '')
|
||||
f"FROM inventory_detail WHERE data_date=%s AND {active_warehouse_condition('inventory_detail')}",
|
||||
(latest_date,),
|
||||
)[0]
|
||||
# 仓库启停/属性变化也必须使缓存失效;使用短摘要避免 cache_key 超出表字段长度。
|
||||
import hashlib
|
||||
_warehouse_signature = query(
|
||||
"SELECT warehouse_code, warehouse_property, disabled FROM warehouses ORDER BY warehouse_code"
|
||||
)
|
||||
_signature_text = json.dumps({
|
||||
'inventory': _inventory_signature,
|
||||
'warehouses': _warehouse_signature,
|
||||
}, ensure_ascii=False, default=str, sort_keys=True)
|
||||
_cache_digest = hashlib.sha1(_signature_text.encode('utf-8')).hexdigest()[:20]
|
||||
_cache_key = f'inventory_board_system:{month}:{latest_date}:{_cache_digest}'
|
||||
if not force_refresh:
|
||||
_cached = cache_get(_cache_key)
|
||||
if _cached is not None:
|
||||
@ -923,42 +951,65 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
rows = query(f"""
|
||||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||||
FROM inventory_detail id
|
||||
LEFT JOIN products p ON id.product_code = p.product_code
|
||||
WHERE id.data_date = %s AND id.warehouse_code != '20' AND {wh_cond}
|
||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
||||
WHERE id.data_date = %s
|
||||
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
|
||||
AND {active_warehouse_condition('id')} AND {wh_cond}
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||
)
|
||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||
GROUP BY id.product_code
|
||||
""", (target_date,) + tuple(params) + ('%-NL', '%内料%'))
|
||||
return round(sum(float(r['qty'] or 0) * _cmap.get(r['code'], 0) for r in rows), 2)
|
||||
|
||||
def _warehouse_match(codes):
|
||||
numeric = sorted({int(str(code)) for code in codes if str(code).isdigit()})
|
||||
text = sorted({str(code) for code in codes if not str(code).isdigit()})
|
||||
clauses, params = [], []
|
||||
if numeric:
|
||||
clauses.append("(id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) IN (%s))" %
|
||||
','.join(['%s'] * len(numeric)))
|
||||
params.extend(numeric)
|
||||
if text:
|
||||
clauses.append("id.warehouse_code IN (%s)" % ','.join(['%s'] * len(text)))
|
||||
params.extend(text)
|
||||
return ('(' + ' OR '.join(clauses) + ')', params) if clauses else ('0=1', [])
|
||||
|
||||
amounts = {
|
||||
'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品
|
||||
'wh01_amount': _wh_finished_amount("id.warehouse_code = %s", ('01',)), # 蝶雅仓成品
|
||||
'wh15_amount': _wh_finished_amount("id.warehouse_code = %s", ('15',)), # 加工厂仓成品
|
||||
'wh01_amount': _wh_finished_amount(
|
||||
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (1,)),
|
||||
'wh15_amount': _wh_finished_amount(
|
||||
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (15,)),
|
||||
}
|
||||
_cloud_codes = [r['warehouse_code'] for r in query(
|
||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('云仓', warehouse_property) > 0 "
|
||||
"AND warehouse_code != '01'")]
|
||||
"AND COALESCE(disabled, '否') != '是' "
|
||||
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)")]
|
||||
if _cloud_codes:
|
||||
_ph = ','.join(['%s'] * len(_cloud_codes))
|
||||
amounts['cloud_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _cloud_codes)
|
||||
_cond, _params = _warehouse_match(_cloud_codes)
|
||||
amounts['cloud_amount'] = _wh_finished_amount(_cond, _params)
|
||||
else:
|
||||
amounts['cloud_amount'] = 0.0
|
||||
_overseas_codes = [r['warehouse_code'] for r in query(
|
||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0")]
|
||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0 "
|
||||
"AND COALESCE(disabled, '否') != '是'")]
|
||||
if _overseas_codes:
|
||||
_ph = ','.join(['%s'] * len(_overseas_codes))
|
||||
amounts['overseas_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _overseas_codes)
|
||||
_cond, _params = _warehouse_match(_overseas_codes)
|
||||
amounts['overseas_amount'] = _wh_finished_amount(_cond, _params)
|
||||
else:
|
||||
amounts['overseas_amount'] = 0.0
|
||||
# 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓)
|
||||
_self_codes = [r['warehouse_code'] for r in query(
|
||||
"SELECT warehouse_code FROM warehouses WHERE (warehouse_name LIKE %s OR warehouse_code IN ('13', '14')) "
|
||||
"AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND warehouse_code != '01'",
|
||||
"AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND COALESCE(disabled, '否') != '是' "
|
||||
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)",
|
||||
('%自营%',))]
|
||||
if _self_codes:
|
||||
_ph = ','.join(['%s'] * len(_self_codes))
|
||||
amounts['domestic_self_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _self_codes)
|
||||
_cond, _params = _warehouse_match(_self_codes)
|
||||
amounts['domestic_self_amount'] = _wh_finished_amount(_cond, _params)
|
||||
else:
|
||||
amounts['domestic_self_amount'] = 0.0
|
||||
return amounts
|
||||
@ -987,25 +1038,38 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
turnover_rate = round(monthly_ship_cost / inventory_total_amount, 4) if inventory_total_amount else 0
|
||||
turnover_days = round(30 / turnover_rate, 1) if turnover_rate else None
|
||||
|
||||
# 9. 动销SKU数量:>30天 / >100天 两个口径(近 N 天有出库、成品口径、排除内料)
|
||||
def _active_sku(days):
|
||||
return int(query("""
|
||||
SELECT /*+ INDEX(d idx_date_product) */ COUNT(DISTINCT d.货品编号) AS v
|
||||
FROM daily_outbound d
|
||||
LEFT JOIN products p ON d.货品编号 = p.product_code
|
||||
WHERE d.日期 >= %s AND d.日期 < %s
|
||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
||||
AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s
|
||||
""", (max(month_start, latest_date - timedelta(days=days - 1)), next_month, '%内料%', '%-NL'))[0]['v'])
|
||||
# 9. 动销SKU数量:所选自然月累计出库分别达到 30 / 100 件的 SKU 数。
|
||||
_monthly_sales = query("""
|
||||
SELECT d.货品编号 AS code, SUM(d.货品数量) AS qty
|
||||
FROM daily_outbound d
|
||||
WHERE d.日期 >= %s AND d.日期 < %s
|
||||
AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=d.货品编号
|
||||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=d.货品编号 AND p0.deleted_at IS NULL)
|
||||
)
|
||||
GROUP BY d.货品编号
|
||||
""", (month_start, next_month, '%内料%', '%-NL'))
|
||||
active_sku_30 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 30)
|
||||
active_sku_100 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 100)
|
||||
|
||||
active_sku_30 = _active_sku(30) # 动销SKU数量(>30天)
|
||||
active_sku_100 = _active_sku(100) # 动销SKU数量(>100天)
|
||||
|
||||
# 10. 总SKU数量
|
||||
total_sku = int(query(
|
||||
"SELECT COUNT(*) AS v FROM products p "
|
||||
"WHERE p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL"
|
||||
)[0]['v'])
|
||||
# 10. 总SKU数量:月末启用仓库的成品库存分别达到 1 / 200 件的去重 SKU 数。
|
||||
_month_end_sku_rows = query(f"""
|
||||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||||
FROM inventory_detail id
|
||||
WHERE id.data_date = %s AND {active_warehouse_condition('id')}
|
||||
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
|
||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||
)
|
||||
GROUP BY id.product_code
|
||||
""", (latest_date, '%-NL', '%内料%'))
|
||||
total_sku = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 1)
|
||||
total_sku_200 = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 200)
|
||||
|
||||
# 11. 动销率(30天动销口径)
|
||||
active_rate = round(active_sku_30 / total_sku * 100, 2) if total_sku else 0
|
||||
@ -1015,6 +1079,7 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
# 注:不能加内料过滤 WHERE,否则无法走 idx_product_date 覆盖索引,全表扫描 180万行需 30s+
|
||||
_first_date = {r['product_code']: r['first_date'] for r in query("""
|
||||
SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail
|
||||
WHERE """ + active_warehouse_condition('inventory_detail') + """
|
||||
GROUP BY product_code
|
||||
""")}
|
||||
# 近30天销量(日均销量分母)
|
||||
@ -1025,12 +1090,15 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
GROUP BY 货品编号
|
||||
""", (max(month_start, latest_date - timedelta(days=29)), next_month, '%内料%', '%-NL'))}
|
||||
# 最新库存日:按商品聚合可得库存数量(成品口径、排除内料)
|
||||
_inv_by_code = query("""
|
||||
_inv_by_code = query(f"""
|
||||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||||
FROM inventory_detail id
|
||||
LEFT JOIN products p ON id.product_code = p.product_code
|
||||
WHERE id.data_date = %s
|
||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
||||
WHERE id.data_date = %s AND {active_warehouse_condition('id')}
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||
)
|
||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||
GROUP BY id.product_code
|
||||
""", (latest_date, '%-NL', '%内料%'))
|
||||
@ -1060,15 +1128,20 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
|
||||
# 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。
|
||||
# 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。
|
||||
_stock_rows = query("""
|
||||
_stock_rows = query(f"""
|
||||
SELECT id.product_code AS code, id.data_date AS dt, SUM(id.available_qty) AS qty
|
||||
FROM inventory_detail id
|
||||
LEFT JOIN products p ON id.product_code = p.product_code
|
||||
WHERE id.data_date >= %s AND id.data_date < %s
|
||||
AND id.available_qty = 0
|
||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
||||
AND {active_warehouse_condition('id')}
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||
)
|
||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||
GROUP BY id.product_code, id.data_date ORDER BY id.product_code, id.data_date
|
||||
GROUP BY id.product_code, id.data_date
|
||||
HAVING SUM(id.available_qty) <= 0
|
||||
ORDER BY id.product_code, id.data_date
|
||||
""", (month_start, next_month, '%-NL', '%内料%'))
|
||||
_stockout_codes = set()
|
||||
_stockout_by_code = {}
|
||||
@ -1088,6 +1161,8 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
_stockout_codes.add(_prev_code)
|
||||
_run = 0
|
||||
_prev_code, _prev_date = code, dt
|
||||
# 缺货监控只针对统计窗口内存在实际出库需求的 SKU,排除长期零库存且无销售的停销档案。
|
||||
_stockout_codes.intersection_update(_sales_30.keys())
|
||||
_prod_platform = {r['code']: (r['platform'] or '') for r in query("""
|
||||
SELECT product_code AS code, platform FROM products
|
||||
WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL
|
||||
@ -1146,7 +1221,7 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
||||
'active_sku_100': active_sku_100, # 动销SKU数量(>100天)
|
||||
'active_sku': active_sku_30, # 兼容旧字段(外部录入覆盖)
|
||||
'total_sku': total_sku,
|
||||
'total_sku_200': 0,
|
||||
'total_sku_200': total_sku_200,
|
||||
'active_rate': active_rate,
|
||||
'active_rate_100': 0,
|
||||
# 板块3 呆滞治理
|
||||
|
||||
82
routes/department_matching.py
Normal file
82
routes/department_matching.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""部门匹配规则维护和只读试算。"""
|
||||
import json
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from routes.auth import login_required, operation_required, role_required
|
||||
from services.db import query, add_operation_log
|
||||
from services.department_matching import records, validate_record, match_department, check_samples
|
||||
|
||||
department_matching_bp = Blueprint('department_matching', __name__)
|
||||
|
||||
|
||||
@department_matching_bp.route('/api/department-matching/records')
|
||||
@login_required
|
||||
def list_records():
|
||||
if not role_required('department_matching'):
|
||||
return jsonify(code=403, msg='无部门匹配管理查看权限'), 403
|
||||
return jsonify(code=0, data=records())
|
||||
|
||||
|
||||
@department_matching_bp.route('/api/department-matching/save', methods=['POST'])
|
||||
@login_required
|
||||
def save_record():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify(code=400, msg='请求格式不正确'), 400
|
||||
if not operation_required('department_matching', 'edit' if data.get('id') else 'create'):
|
||||
return jsonify(code=403, msg='无规则维护权限'), 403
|
||||
try:
|
||||
kind, payload, priority, enabled = validate_record(data)
|
||||
row_id = int(data.get('id') or 0)
|
||||
old = query('SELECT * FROM department_matching_records WHERE id=%s', (row_id,)) if row_id else []
|
||||
if row_id and (not old or old[0]['kind'] != kind):
|
||||
return jsonify(code=404, msg='记录不存在或类型不正确'), 404
|
||||
if row_id:
|
||||
query('UPDATE department_matching_records SET payload=%s,priority=%s,enabled=%s WHERE id=%s',
|
||||
(json.dumps(payload, ensure_ascii=False), priority, enabled, row_id))
|
||||
else:
|
||||
row_id = query('INSERT INTO department_matching_records (kind,payload,priority,enabled) VALUES (%s,%s,%s,%s)',
|
||||
(kind, json.dumps(payload, ensure_ascii=False), priority, enabled))
|
||||
add_operation_log('编辑部门匹配规则' if old else '新增部门匹配规则', f'记录{row_id},类型{kind}',
|
||||
page='department_matching', object_id=str(row_id),
|
||||
old_values=old[0] if old else None, new_values=data)
|
||||
return jsonify(code=0, msg='保存成功', id=row_id)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return jsonify(code=400, msg=str(exc)), 400
|
||||
|
||||
|
||||
@department_matching_bp.route('/api/department-matching/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_record():
|
||||
if not operation_required('department_matching', 'delete'):
|
||||
return jsonify(code=403, msg='无删除权限'), 403
|
||||
try:
|
||||
row_id = int((request.get_json(silent=True) or {}).get('id', 0))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify(code=400, msg='记录编号不正确'), 400
|
||||
old = query('SELECT * FROM department_matching_records WHERE id=%s', (row_id,))
|
||||
if not old:
|
||||
return jsonify(code=404, msg='记录不存在'), 404
|
||||
query('DELETE FROM department_matching_records WHERE id=%s', (row_id,))
|
||||
add_operation_log('删除部门匹配规则', f'记录{row_id}', page='department_matching',
|
||||
object_id=str(row_id), old_values=old[0])
|
||||
return jsonify(code=0, msg='删除成功')
|
||||
|
||||
|
||||
@department_matching_bp.route('/api/department-matching/test', methods=['POST'])
|
||||
@login_required
|
||||
def test_match():
|
||||
if not role_required('department_matching'):
|
||||
return jsonify(code=403, msg='无查看权限'), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict) or any(not isinstance(data.get(k, ''), str) or len(data.get(k, '')) > 4000 for k in ('shop', 'source')):
|
||||
return jsonify(code=400, msg='店铺和来源必须为4000字以内的文本'), 400
|
||||
return jsonify(code=0, data=match_department(data.get('shop', ''), data.get('source', '')))
|
||||
|
||||
|
||||
@department_matching_bp.route('/api/department-matching/check', methods=['POST'])
|
||||
@login_required
|
||||
def check_matching():
|
||||
if not role_required('department_matching'):
|
||||
return jsonify(code=403, msg='无查看权限'), 403
|
||||
return jsonify(code=0, data=check_samples())
|
||||
@ -13,6 +13,7 @@ from flask import Blueprint, request, jsonify, session, redirect, url_for, rende
|
||||
from services.db import query, query_iter, get_db
|
||||
from routes.auth import login_required, role_required, operation_required
|
||||
from services.field_perms import filter_rows as _filter_field_rows
|
||||
from services.inventory_calc import active_warehouse_condition
|
||||
|
||||
inventory_bp = Blueprint('inventory', __name__)
|
||||
|
||||
@ -411,6 +412,7 @@ def inventory_list():
|
||||
{join_type}
|
||||
{warehouse_join}
|
||||
WHERE {where_detail_clause}
|
||||
AND {active_warehouse_condition('d')}
|
||||
""", params)[0]
|
||||
# 获取采购跟单、采购计划、供应商信息
|
||||
prod_fields = ", p.procurement_follower AS 采购跟单, p.procurement_planner AS 采购计划"
|
||||
|
||||
37
routes/organization.py
Normal file
37
routes/organization.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""钉钉组织架构页面 API。"""
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from routes.auth import login_required, operation_required, role_required
|
||||
from services.db import add_operation_log
|
||||
from services.dingtalk_organization import organization_snapshot, sync_organization
|
||||
|
||||
|
||||
organization_bp = Blueprint('organization', __name__)
|
||||
|
||||
|
||||
@organization_bp.route('/api/organization/tree')
|
||||
@login_required
|
||||
def organization_tree():
|
||||
if not role_required('organization'):
|
||||
return jsonify({'code': 403, 'msg': '无组织架构查看权限'}), 403
|
||||
try:
|
||||
return jsonify({'code': 0, 'data': organization_snapshot()})
|
||||
except Exception as exc:
|
||||
return jsonify({'code': 500, 'msg': str(exc)}), 500
|
||||
|
||||
|
||||
@organization_bp.route('/api/organization/sync', methods=['POST'])
|
||||
@login_required
|
||||
def organization_sync():
|
||||
if not operation_required('organization', 'sync'):
|
||||
return jsonify({'code': 403, 'msg': '无组织架构同步权限'}), 403
|
||||
try:
|
||||
result = sync_organization('manual')
|
||||
add_operation_log(
|
||||
'同步组织架构',
|
||||
f"部门{result['department_count']}个,人员{result['user_count']}人,关联账号{result['matched_count']}人",
|
||||
page='organization',
|
||||
)
|
||||
return jsonify({'code': 0, 'msg': '组织架构同步成功', 'data': result})
|
||||
except Exception as exc:
|
||||
return jsonify({'code': 500, 'msg': str(exc)}), 500
|
||||
@ -19,6 +19,7 @@ from services.forecast import (is_new_product, get_product_grade,
|
||||
from routes.auth import login_required, role_required, operation_required, _ensure_personnel_table
|
||||
from services.field_perms import filter_rows as _filter_field_rows
|
||||
from services.planning_snapshot import consecutive_days_map, attach_consecutive_days
|
||||
from services.order_progress import attach_unfinished_order_progress, unfinished_order_progress_map
|
||||
import json
|
||||
|
||||
planning_bp = Blueprint('planning', __name__)
|
||||
@ -336,11 +337,13 @@ def _merge_pending_formal_qty(rows):
|
||||
if not codes:
|
||||
return
|
||||
placeholders = ','.join(['%s'] * len(codes))
|
||||
pending = query(f"SELECT product_code, formal_order_qty FROM pending_orders WHERE status IN ('pending','draft') AND product_code IN ({placeholders})", codes)
|
||||
pending = query(f"""SELECT product_code, formal_order_qty FROM pending_orders
|
||||
WHERE status IN ('pending','draft') AND product_code IN ({placeholders})
|
||||
ORDER BY CASE status WHEN 'draft' THEN 0 ELSE 1 END, updated_at, id""", codes)
|
||||
qty_map = {r['product_code']: int(r['formal_order_qty'] or 0) for r in pending}
|
||||
for row in rows:
|
||||
pc = row.get('product_code', '')
|
||||
if pc in qty_map and qty_map[pc] > 0:
|
||||
if pc in qty_map:
|
||||
row['正式下单数量'] = qty_map[pc]
|
||||
|
||||
|
||||
@ -720,6 +723,9 @@ def planning_list(filters=None):
|
||||
extended_sales=extended_sales)
|
||||
row['连续建议天数'] = snapshot_map.get(str(row.get('product_code') or ''), 0)
|
||||
|
||||
attach_unfinished_order_progress(all_rows)
|
||||
|
||||
for row in all_rows:
|
||||
# 筛选
|
||||
match = True
|
||||
if match and type_filter and type_filter.lower() not in (row.get('product_type') or '').lower():
|
||||
@ -936,6 +942,7 @@ def planning_list(filters=None):
|
||||
cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map,
|
||||
extended_sales=extended_sales)
|
||||
attach_consecutive_days(rows)
|
||||
attach_unfinished_order_progress(rows)
|
||||
|
||||
# 获取所有匹配的产品编号(用于全选)
|
||||
all_codes_rows = query(f"SELECT product_code FROM products p WHERE {where_clause}", params)
|
||||
@ -970,7 +977,7 @@ def planning_export():
|
||||
return jsonify({"code": 400, "msg": "请先在字段设置中选择要导出的字段"}), 400
|
||||
# 只允许计划预测字段,排除操作列及未知字段。
|
||||
allowed_fields = {
|
||||
'product_type','产品种类','product_code','product_name','brand','批注','产品分级',
|
||||
'product_type','产品种类','product_code','product_name','brand','批注','产品分级','在途进度',
|
||||
'昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存',
|
||||
'云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨',
|
||||
'生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数',
|
||||
@ -1303,16 +1310,18 @@ def planning_column_values():
|
||||
'库存工厂待生产可支撑天数','总支撑天数',
|
||||
'最低销量偏差率','最高销量偏差率','14天波动率',
|
||||
}
|
||||
if field in COMPUTED_NUMERIC_FIELDS:
|
||||
COMPUTED_TEXT_FIELDS = {'在途进度'}
|
||||
COMPUTED_FIELDS = COMPUTED_NUMERIC_FIELDS | COMPUTED_TEXT_FIELDS
|
||||
if field in COMPUTED_FIELDS:
|
||||
# 计算字段:走全量查询+enrichment 后提取唯一值
|
||||
need_full_fetch = True
|
||||
|
||||
if not db_col and field not in COMPUTED_NUMERIC_FIELDS:
|
||||
if not db_col and field not in COMPUTED_FIELDS:
|
||||
return jsonify({"code": 0, "data": []})
|
||||
|
||||
from services.db import query
|
||||
|
||||
if field in COMPUTED_NUMERIC_FIELDS:
|
||||
if field in COMPUTED_FIELDS:
|
||||
# 计算字段:获取全量产品,走 enrichment 提取唯一值
|
||||
try:
|
||||
all_rows = query(f"""
|
||||
@ -1344,6 +1353,7 @@ def planning_column_values():
|
||||
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
|
||||
gc = _ensure_global_config()
|
||||
snapshot_map = consecutive_days_map([r.get('product_code') for r in all_rows])
|
||||
progress_map = unfinished_order_progress_map(r.get('product_code') for r in all_rows)
|
||||
|
||||
seen = {}
|
||||
values = []
|
||||
@ -1362,6 +1372,7 @@ def planning_column_values():
|
||||
extended_sales=extended_sales
|
||||
)
|
||||
enriched['连续建议天数'] = snapshot_map.get(str(enriched.get('product_code') or ''), 0)
|
||||
enriched['在途进度'] = progress_map.get(str(enriched.get('product_code') or ''), '')
|
||||
# 联动筛选:有 plan_filter 时过滤其他字段的条件
|
||||
if plan_filter_str:
|
||||
try:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""产品管理路由"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from flask import (Blueprint, current_app, request, jsonify, session, redirect,
|
||||
@ -19,6 +20,7 @@ from services.forecast import (is_new_product, get_product_grade,
|
||||
sales_predict_config_cached)
|
||||
from routes.auth import login_required, role_required, operation_required
|
||||
from services.field_perms import filter_rows as _filter_field_rows
|
||||
from services.order_progress import unfinished_order_progress_map
|
||||
|
||||
products_bp = Blueprint('products', __name__)
|
||||
|
||||
@ -1008,6 +1010,16 @@ def product_detail_with_bom():
|
||||
SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark
|
||||
FROM bom_packaging WHERE product_code=%s
|
||||
""", (code,))
|
||||
pending_snapshot_rows = query("""SELECT formal_order_qty, remark, bom_items, status
|
||||
FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft')
|
||||
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC
|
||||
LIMIT 1""", (code,))
|
||||
pending_snapshot = pending_snapshot_rows[0] if pending_snapshot_rows else None
|
||||
if pending_snapshot and isinstance(pending_snapshot.get('bom_items'), str):
|
||||
try:
|
||||
pending_snapshot['bom_items'] = json.loads(pending_snapshot['bom_items'])
|
||||
except (TypeError, ValueError):
|
||||
pending_snapshot['bom_items'] = []
|
||||
pkg_map = {}
|
||||
for pr in pkg_rows:
|
||||
mc = str(pr['material_code'] or '').strip()
|
||||
@ -1089,6 +1101,7 @@ def product_detail_with_bom():
|
||||
"total_suggested_qty": int(order_data['total_suggested_qty'] or 0)
|
||||
},
|
||||
"bom_materials": bom_materials,
|
||||
"pending_order": pending_snapshot,
|
||||
"sales": sales_data,
|
||||
"orders_in_transit": orders_in_transit
|
||||
}
|
||||
@ -1152,6 +1165,7 @@ def product_planning_detail():
|
||||
ext_inv_map=ext_inv_map,
|
||||
extended_sales=extended_sales,
|
||||
)
|
||||
row['在途进度'] = unfinished_order_progress_map([pc]).get(pc, '')
|
||||
inv15_val = float(row.get('加工厂库存', 0) or 0)
|
||||
|
||||
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
|
||||
|
||||
@ -23,6 +23,7 @@ from services.product_type import get_product_type_cached
|
||||
from routes.planning import (_enrich_row_with_planning_data, _ensure_global_config,
|
||||
_personnel_group_members, _personnel_group_for_value)
|
||||
from services.planning_order_flag import normalize_new_old
|
||||
from services.order_progress import unfinished_order_progress_map
|
||||
|
||||
purchase_bp = Blueprint('purchase', __name__)
|
||||
|
||||
@ -36,7 +37,7 @@ PLANNING_SALES_SNAPSHOT_FIELDS = (
|
||||
|
||||
|
||||
def _build_planning_sales_snapshot(product_code, product_name='', product_type=''):
|
||||
"""按计划预测默认数据口径生成一次性销量快照。"""
|
||||
"""按计划预测“云仓数据”页签口径生成一次性销量快照。"""
|
||||
where = ['p.product_code=%s', 'p.deleted_at IS NULL']
|
||||
params = [product_code]
|
||||
if product_name:
|
||||
@ -68,6 +69,7 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type='
|
||||
planning_row.get('product_name', ''), planning_row.get('brand', ''),
|
||||
planning_row.get('product_type'))
|
||||
planning_row['批注'] = planning_row.get('remark') or ''
|
||||
extended_sales = compute_product_sales_extended_cached()
|
||||
planning_row = _enrich_row_with_planning_data(
|
||||
planning_row,
|
||||
compute_product_sales_cached(),
|
||||
@ -78,9 +80,42 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type='
|
||||
get_global_config(),
|
||||
cloud_inv_map=compute_cloud_inventory_cached(),
|
||||
ext_inv_map=compute_external_inventory_cached(),
|
||||
extended_sales=compute_product_sales_extended_cached(),
|
||||
extended_sales=extended_sales,
|
||||
)
|
||||
return {field: planning_row.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS}
|
||||
# 与“计划预测 → 详情 → 销量信息 → 云仓数据”保持同一取数口径。
|
||||
cloud_sales = extended_sales.get('cloud', {}).get(product_code, {})
|
||||
cloud_inventory = planning_row.get('云仓库存', 0)
|
||||
cloud_3 = cloud_sales.get('3天平均销量', 0)
|
||||
cloud_7 = cloud_sales.get('7天平均销量', 0)
|
||||
cloud_14 = cloud_sales.get('14天平均销量', 0)
|
||||
cloud_forecast = planning_row.get('云仓日销量预测', 0)
|
||||
try:
|
||||
cloud_forecast_number = float(cloud_forecast) if cloud_forecast not in ('近期无销售', '', None) else 0
|
||||
except (TypeError, ValueError):
|
||||
cloud_forecast_number = 0
|
||||
cloud_snapshot = {
|
||||
'产品分级': planning_row.get('产品分级', ''),
|
||||
'昨日销量': cloud_sales.get('昨日销量', 0),
|
||||
'销售趋势': _determine_trend(cloud_3, cloud_7, cloud_14),
|
||||
'库存': cloud_inventory,
|
||||
'加工厂库存': planning_row.get('加工厂库存', 0),
|
||||
'生产在途': planning_row.get('生产在途', 0),
|
||||
'采购在途': planning_row.get('采购在途', 0),
|
||||
'日销量预测': cloud_forecast,
|
||||
'3天平均销量': cloud_3,
|
||||
'7天平均销量': cloud_7,
|
||||
'14天平均销量': cloud_14,
|
||||
'库存可支撑天数': round(float(cloud_inventory or 0) / cloud_forecast_number, 1)
|
||||
if cloud_forecast_number else 999,
|
||||
'加工厂库存支撑天数': round(float(planning_row.get('加工厂库存', 0) or 0) / cloud_forecast_number, 1)
|
||||
if cloud_forecast_number else 999,
|
||||
'总支撑天数': planning_row.get('云仓支撑天数', 999),
|
||||
'30天平均销量': cloud_sales.get('30天平均销量', 0),
|
||||
'极值判断': '-',
|
||||
}
|
||||
return {'__scope': 'cloud', **{
|
||||
field: cloud_snapshot.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS
|
||||
}}
|
||||
|
||||
|
||||
# 海外产品清单专属备货通知:名单是非敏感业务配置,Webhook/加签只从运行环境读取,
|
||||
@ -556,13 +591,17 @@ def _normalize_backup_bom(items, enrich=True, source_product_code='', source_ord
|
||||
if source_product_code and source_ordered_at:
|
||||
orderer_rows = query(
|
||||
"""SELECT GROUP_CONCAT(DISTINCT NULLIF(`下单人`, '')
|
||||
ORDER BY id SEPARATOR '、') AS packaging_orderer
|
||||
ORDER BY id SEPARATOR '、') AS packaging_orderer,
|
||||
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(`进度`, '')
|
||||
ORDER BY id DESC SEPARATOR '|||DY_PROGRESS|||'), '|||DY_PROGRESS|||', 1) AS order_progress
|
||||
FROM packaging_orders
|
||||
WHERE BINARY `编码`=BINARY %s AND BINARY `来源产品编码`=BINARY %s AND `下单时间`=%s""",
|
||||
(code, source_product_code, source_ordered_at)
|
||||
)
|
||||
current['packaging_orderer'] = (orderer_rows[0].get('packaging_orderer')
|
||||
if orderer_rows else '') or ''
|
||||
current['order_progress'] = (orderer_rows[0].get('order_progress')
|
||||
if orderer_rows else '') or ''
|
||||
qty = int(current.get('actual_quantity') or 0)
|
||||
current.setdefault('price_audit_status', '未审核')
|
||||
current['amount'] = round(qty * float(current.get('unit_price') or 0), 2)
|
||||
@ -919,6 +958,8 @@ def purchase_backup_detail():
|
||||
row['sales_info'] = {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
row['sales_info'] = {}
|
||||
# 旧历史快照没有口径标记,按原“默认数据”解释;新快照明确标记为云仓。
|
||||
row['sales_snapshot_scope'] = row['sales_info'].pop('__scope', 'default') if row['sales_info'] else ''
|
||||
row['sales_snapshot_missing'] = not bool(row['sales_info'])
|
||||
return jsonify({"code": 0, "data": row})
|
||||
|
||||
@ -1064,9 +1105,12 @@ def purchase_add_pending():
|
||||
update_fields = {'formal_order_qty', 'audit_price', 'actual_order_qty', 'suggested_qty', 'remark', 'bom_items'}
|
||||
is_update = bool(keys & update_fields)
|
||||
|
||||
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。
|
||||
existing_pending = query("SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
|
||||
if existing_pending and not is_update:
|
||||
# 先复用当前待下单行,否则复用计划预测保存的草稿;ordered/rolled_back 历史不参与。
|
||||
existing_pending = query("""SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at, status
|
||||
FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft')
|
||||
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC
|
||||
LIMIT 1""", (pc,))
|
||||
if existing_pending and existing_pending[0].get('status') == 'pending' and not is_update:
|
||||
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
|
||||
|
||||
# 查询该产品的BOM物料清单
|
||||
@ -1191,20 +1235,44 @@ def purchase_add_pending():
|
||||
@purchase_bp.route('/api/purchase/update_pending', methods=['POST'])
|
||||
@login_required
|
||||
def purchase_update_pending():
|
||||
"""更新待下单记录的备注等字段"""
|
||||
denied = _require_purchase_operation('purchase', 'edit')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.json
|
||||
"""保存计划预测下单信息或更新待下单记录;未下单的内容保存在隐藏草稿中。"""
|
||||
if not (operation_required('purchase', 'edit') or operation_required('planning', 'edit')):
|
||||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"})
|
||||
data = request.get_json(silent=True) or {}
|
||||
product_code = data.get('product_code', '')
|
||||
if not product_code:
|
||||
return jsonify({"code": 400, "msg": "参数错误"})
|
||||
|
||||
current_rows = query(
|
||||
"SELECT remark, bom_items FROM pending_orders WHERE product_code=%s AND status='pending' LIMIT 1",
|
||||
"SELECT id, remark, bom_items, formal_order_qty, status FROM pending_orders "
|
||||
"WHERE product_code=%s AND status IN ('pending','draft') "
|
||||
"ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC LIMIT 1",
|
||||
(product_code,)
|
||||
)
|
||||
current = current_rows[0] if current_rows else {}
|
||||
if not current:
|
||||
product_rows = query("SELECT product_name, product_type FROM products WHERE product_code=%s AND deleted_at IS NULL LIMIT 1",
|
||||
(product_code,))
|
||||
if not product_rows:
|
||||
return jsonify({"code": 404, "msg": "未找到该产品,无法保存下单草稿"})
|
||||
requested_bom = data.get('bom_items')
|
||||
normalized_for_insert = _normalize_pending_bom_items(requested_bom) if requested_bom is not None else []
|
||||
product_actual_qty = _extract_product_actual_qty(normalized_for_insert, product_code)
|
||||
if product_actual_qty is None:
|
||||
try:
|
||||
product_actual_qty = int(data.get('formal_order_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
product_actual_qty = 0
|
||||
bom_json = json.dumps(normalized_for_insert, ensure_ascii=False) if requested_bom is not None else None
|
||||
draft_id = query("""INSERT INTO pending_orders
|
||||
(product_code, product_name, product_type, formal_order_qty, status, created_by, bom_items, remark)
|
||||
VALUES (%s, %s, %s, %s, 'draft', %s, %s, %s)""",
|
||||
(product_code, product_rows[0].get('product_name') or '',
|
||||
product_rows[0].get('product_type') or '', product_actual_qty,
|
||||
session.get('real_name', '') or session.get('username', ''), bom_json,
|
||||
data.get('remark') or ''))
|
||||
current = {'id': draft_id, 'remark': '', 'bom_items': None,
|
||||
'formal_order_qty': 0, 'status': 'draft'}
|
||||
changes = []
|
||||
|
||||
# 支持更新的字段
|
||||
@ -1213,11 +1281,10 @@ def purchase_update_pending():
|
||||
old_remark = current.get('remark') or ''
|
||||
if old_remark != remark:
|
||||
changes.append(f'备注:{old_remark or "(空)"} → {remark or "(空)"}')
|
||||
query("UPDATE pending_orders SET remark=%s WHERE product_code=%s AND status='pending'", (remark, product_code))
|
||||
query("UPDATE pending_orders SET remark=%s, updated_at=NOW() WHERE id=%s", (remark, current['id']))
|
||||
|
||||
bom_items = data.get('bom_items')
|
||||
if bom_items is not None:
|
||||
import json
|
||||
normalized_bom_items = _normalize_pending_bom_items(bom_items)
|
||||
product_actual_qty = _extract_product_actual_qty(normalized_bom_items, product_code)
|
||||
bom_str = json.dumps(normalized_bom_items, ensure_ascii=False)
|
||||
@ -1286,12 +1353,13 @@ def purchase_update_pending():
|
||||
f'{display_code} {label}:{old_display} → {new_display}'
|
||||
)
|
||||
if product_actual_qty is None:
|
||||
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='pending'", (bom_str, product_code))
|
||||
query("UPDATE pending_orders SET bom_items=%s, updated_at=NOW() WHERE id=%s", (bom_str, current['id']))
|
||||
else:
|
||||
old_formal_qty = int((current.get('formal_order_qty') or 0))
|
||||
if old_formal_qty != product_actual_qty:
|
||||
changes.append(f'成品实际下单数量:{old_formal_qty} → {product_actual_qty}')
|
||||
query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s WHERE product_code=%s AND status='pending'", (product_actual_qty, bom_str, product_code))
|
||||
query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s, updated_at=NOW() WHERE id=%s",
|
||||
(product_actual_qty, bom_str, current['id']))
|
||||
# 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。
|
||||
_sync_pending_pre_packaging(product_code, normalized_bom_items)
|
||||
|
||||
@ -1776,6 +1844,9 @@ def purchase_batch_add_pending():
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
saved_draft = query("""SELECT id, new_old_product, sales_snapshot, sales_snapshot_at
|
||||
FROM pending_orders WHERE product_code=%s AND status='draft'
|
||||
ORDER BY updated_at DESC, id DESC LIMIT 1""", (pc,))
|
||||
p = prod_map.get(pc, {})
|
||||
# 查询BOM物料清单
|
||||
bom_rows = query("SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s", (pc,))
|
||||
@ -1785,13 +1856,30 @@ def purchase_batch_add_pending():
|
||||
'material_name': r['material_name'],
|
||||
'quantity': float(r['quantity'])
|
||||
} for r in bom_rows], ensure_ascii=False) if bom_rows else None
|
||||
try:
|
||||
sales_snapshot = _build_planning_sales_snapshot(
|
||||
pc, p.get('product_name', ''), p.get('product_type', ''))
|
||||
except Exception as exc:
|
||||
return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"})
|
||||
sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str)
|
||||
sales_snapshot_at = datetime.now()
|
||||
if saved_draft and saved_draft[0].get('sales_snapshot'):
|
||||
sales_snapshot_json = saved_draft[0]['sales_snapshot']
|
||||
sales_snapshot_at = saved_draft[0].get('sales_snapshot_at') or datetime.now()
|
||||
else:
|
||||
try:
|
||||
sales_snapshot = _build_planning_sales_snapshot(
|
||||
pc, p.get('product_name', ''), p.get('product_type', ''))
|
||||
except Exception as exc:
|
||||
return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"})
|
||||
sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str)
|
||||
sales_snapshot_at = datetime.now()
|
||||
if saved_draft:
|
||||
new_old_product = normalize_new_old(saved_draft[0].get('new_old_product') or p.get('planning_new_old'))
|
||||
query("""UPDATE pending_orders SET product_name=%s, product_type=%s, supplier_name=%s,
|
||||
lead_time=%s, logistics_time=%s, procurement_follower=%s, procurement_planner=%s,
|
||||
platform=%s, operator=%s, new_old_product=%s, sales_snapshot=%s,
|
||||
sales_snapshot_at=%s, status='pending', updated_at=NOW() WHERE id=%s""",
|
||||
(p.get('product_name', ''), p.get('product_type', ''), p.get('supplier_name', ''),
|
||||
int(p.get('lead_time', 0) or 0), int(p.get('logistics_time', 0) or 0),
|
||||
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '',
|
||||
p.get('platform', '') or '', p.get('operator', '') or '', new_old_product,
|
||||
sales_snapshot_json, sales_snapshot_at, saved_draft[0]['id']))
|
||||
added += 1
|
||||
continue
|
||||
query("""
|
||||
INSERT INTO pending_orders
|
||||
(product_code, product_name, product_type, product_grade, supplier_name,
|
||||
@ -2470,7 +2558,7 @@ def _arrival_operator_name():
|
||||
|
||||
|
||||
def _ensure_arrival_batches_table():
|
||||
"""Ensure arrival batch storage includes the defective quantity field."""
|
||||
"""Ensure arrival batch storage records which immutable fields were actually filled."""
|
||||
global _ARRIVAL_BATCHES_SCHEMA_READY
|
||||
if _ARRIVAL_BATCHES_SCHEMA_READY:
|
||||
return
|
||||
@ -2483,6 +2571,9 @@ def _ensure_arrival_batches_table():
|
||||
defective_qty INT NOT NULL DEFAULT 0,
|
||||
operator_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
operated_at DATETIME NULL,
|
||||
arrival_date_filled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
arrival_qty_filled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
defective_qty_filled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
remark TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
@ -2500,21 +2591,41 @@ def _ensure_arrival_batches_table():
|
||||
query("""ALTER TABLE arrival_batches
|
||||
ADD COLUMN operated_at DATETIME NULL AFTER operator_name""")
|
||||
query("UPDATE arrival_batches SET operated_at=created_at WHERE operated_at IS NULL")
|
||||
# 已保存字段一旦填写即锁定;空字段允许后续补填一次。历史 0 按“未填写”迁移,
|
||||
# 因为旧前端会把空输入强制保存为 0,无法再可靠区分显式 0 与空值。
|
||||
if 'arrival_date_filled' not in column_names:
|
||||
query("""ALTER TABLE arrival_batches
|
||||
ADD COLUMN arrival_date_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER operated_at""")
|
||||
query("UPDATE arrival_batches SET arrival_date_filled=IF(arrival_date IS NULL,0,1)")
|
||||
if 'arrival_qty_filled' not in column_names:
|
||||
query("""ALTER TABLE arrival_batches
|
||||
ADD COLUMN arrival_qty_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER arrival_date_filled""")
|
||||
query("UPDATE arrival_batches SET arrival_qty_filled=IF(arrival_qty IS NULL OR arrival_qty=0,0,1)")
|
||||
if 'defective_qty_filled' not in column_names:
|
||||
query("""ALTER TABLE arrival_batches
|
||||
ADD COLUMN defective_qty_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER arrival_qty_filled""")
|
||||
query("UPDATE arrival_batches SET defective_qty_filled=IF(defective_qty IS NULL OR defective_qty=0,0,1)")
|
||||
_ARRIVAL_BATCHES_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _arrival_batch_quantities(data):
|
||||
"""Parse and validate gross/defective arrival quantities."""
|
||||
def _optional_int(data, key, label):
|
||||
"""Return (value, filled), preserving the distinction between blank and zero."""
|
||||
raw = data.get(key)
|
||||
if raw in (None, ''):
|
||||
return 0, False
|
||||
try:
|
||||
arrival_qty = int(data.get('arrival_qty') or 0)
|
||||
defective_qty = int(data.get('defective_qty') or 0)
|
||||
return int(raw), True
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('到货数量和不良品数量必须为整数')
|
||||
if arrival_qty < 0 or defective_qty < 0:
|
||||
raise ValueError('到货数量和不良品数量不能为负数')
|
||||
if defective_qty > arrival_qty:
|
||||
raise ValueError('不良品数量不能大于到货数量')
|
||||
return arrival_qty, defective_qty
|
||||
raise ValueError(f'{label}必须为整数')
|
||||
|
||||
|
||||
def _arrival_batch_quantities(data, order_type='domestic'):
|
||||
"""Parse quantities; defective quantity may be any integer for all order types."""
|
||||
arrival_qty, arrival_qty_filled = _optional_int(data, 'arrival_qty', '到货数量')
|
||||
defective_qty, defective_qty_filled = _optional_int(data, 'defective_qty', '不良品数量')
|
||||
if arrival_qty_filled and arrival_qty < 0:
|
||||
raise ValueError('到货数量不能为负数')
|
||||
return arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled
|
||||
|
||||
|
||||
def _domestic_order_pre_packaging(order_id):
|
||||
@ -2617,7 +2728,8 @@ def domestic_orders_detail_extras():
|
||||
_ensure_arrival_batches_table()
|
||||
|
||||
arrival_batches = list(query("""
|
||||
SELECT id, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark
|
||||
SELECT id, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark,
|
||||
arrival_date_filled, arrival_qty_filled, defective_qty_filled
|
||||
FROM arrival_batches
|
||||
WHERE order_id=%s AND order_type=%s
|
||||
ORDER BY arrival_date DESC, id DESC
|
||||
@ -2658,22 +2770,28 @@ def domestic_orders_add_batch():
|
||||
return jsonify({"code": 400, "msg": "订单类型错误"})
|
||||
_ensure_arrival_batches_table()
|
||||
try:
|
||||
arrival_qty, defective_qty = _arrival_batch_quantities(data)
|
||||
arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled = _arrival_batch_quantities(data, order_type)
|
||||
except ValueError as exc:
|
||||
return jsonify({"code": 400, "msg": str(exc)})
|
||||
operator_name = _arrival_operator_name()
|
||||
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
row_id = query("""INSERT INTO arrival_batches
|
||||
(order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", (
|
||||
(order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at,
|
||||
arrival_date_filled, arrival_qty_filled, defective_qty_filled, remark)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (
|
||||
order_id, order_type, data.get('arrival_date') or None,
|
||||
arrival_qty, defective_qty, operator_name, operated_at, data.get('remark') or ''
|
||||
arrival_qty, defective_qty, operator_name, operated_at,
|
||||
1 if data.get('arrival_date') not in (None, '') else 0,
|
||||
int(arrival_qty_filled), int(defective_qty_filled), data.get('remark') or ''
|
||||
))
|
||||
sync_order_arrival_totals(order_id, order_type)
|
||||
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
|
||||
add_operation_log('到货批次及数量', f"新增到货批次:到货日期={data.get('arrival_date') or '空'},数量={arrival_qty},不良品数量={defective_qty},备注={data.get('remark') or '空'}(订单ID={order_id})", page=page_name, field='到货批次及数量')
|
||||
return jsonify({"code": 0, "msg": "添加到货批次成功", "data": {
|
||||
"id": row_id, "operator_name": operator_name, "operated_at": operated_at
|
||||
"id": row_id, "operator_name": operator_name, "operated_at": operated_at,
|
||||
"arrival_date_filled": 1 if data.get('arrival_date') not in (None, '') else 0,
|
||||
"arrival_qty_filled": int(arrival_qty_filled),
|
||||
"defective_qty_filled": int(defective_qty_filled),
|
||||
}})
|
||||
|
||||
|
||||
@ -2691,26 +2809,56 @@ def domestic_orders_update_batch():
|
||||
return jsonify({"code": 400, "msg": "缺少批次ID"})
|
||||
_ensure_arrival_batches_table()
|
||||
batch_rows = query(
|
||||
"SELECT order_id, arrival_date, arrival_qty, defective_qty, remark, operator_name, operated_at "
|
||||
"SELECT order_id, arrival_date, arrival_qty, defective_qty, remark, operator_name, operated_at, "
|
||||
"arrival_date_filled, arrival_qty_filled, defective_qty_filled "
|
||||
"FROM arrival_batches WHERE id=%s AND order_type=%s",
|
||||
(batch_id, order_type),
|
||||
)
|
||||
if not batch_rows:
|
||||
return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"})
|
||||
current = batch_rows[0]
|
||||
# 已保存批次的核心字段不可修改,只允许在备注末尾追加留痕。
|
||||
# 已填写字段不可修改;首次保存时留空的字段允许补填一次,补填后立即锁定。
|
||||
update_fields = []
|
||||
update_params = []
|
||||
supplemented = []
|
||||
if data.get('arrival_date') not in (None, ''):
|
||||
current_date = current.get('arrival_date')
|
||||
if hasattr(current_date, 'isoformat'):
|
||||
current_date = current_date.isoformat()
|
||||
if str(current_date or '')[:10] != str(data.get('arrival_date') or '')[:10]:
|
||||
requested_date = str(data.get('arrival_date') or '')[:10]
|
||||
date_locked = order_type != 'packaging' or current.get('arrival_date_filled')
|
||||
if date_locked and str(current_date or '')[:10] != requested_date:
|
||||
return jsonify({"code": 400, "msg": "到货日期保存后不允许修改"}), 400
|
||||
if order_type == 'packaging' and not current.get('arrival_date_filled'):
|
||||
update_fields.extend(['arrival_date=%s', 'arrival_date_filled=1'])
|
||||
update_params.append(requested_date)
|
||||
supplemented.append(f'到货日期={requested_date}')
|
||||
if data.get('arrival_qty') not in (None, ''):
|
||||
if int(data.get('arrival_qty') or 0) != int(current.get('arrival_qty') or 0):
|
||||
try:
|
||||
requested_qty = int(data.get('arrival_qty'))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"code": 400, "msg": "到货数量必须为整数"}), 400
|
||||
if requested_qty < 0:
|
||||
return jsonify({"code": 400, "msg": "到货数量不能为负数"}), 400
|
||||
qty_locked = order_type != 'packaging' or current.get('arrival_qty_filled')
|
||||
if qty_locked and requested_qty != int(current.get('arrival_qty') or 0):
|
||||
return jsonify({"code": 400, "msg": "到货数量保存后不允许修改"}), 400
|
||||
if order_type == 'packaging' and not current.get('arrival_qty_filled'):
|
||||
update_fields.extend(['arrival_qty=%s', 'arrival_qty_filled=1'])
|
||||
update_params.append(requested_qty)
|
||||
supplemented.append(f'到货数量={requested_qty}')
|
||||
if data.get('defective_qty') not in (None, ''):
|
||||
if int(data.get('defective_qty') or 0) != int(current.get('defective_qty') or 0):
|
||||
try:
|
||||
requested_defective = int(data.get('defective_qty'))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"code": 400, "msg": "不良品数量必须为整数"}), 400
|
||||
defective_locked = current.get('defective_qty_filled')
|
||||
if defective_locked and requested_defective != int(current.get('defective_qty') or 0):
|
||||
return jsonify({"code": 400, "msg": "不良品数量保存后不允许修改"}), 400
|
||||
if not current.get('defective_qty_filled'):
|
||||
update_fields.extend(['defective_qty=%s', 'defective_qty_filled=1'])
|
||||
update_params.append(requested_defective)
|
||||
supplemented.append(f'不良品数量={requested_defective}')
|
||||
operator_name = _arrival_operator_name()
|
||||
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
old_remark = str(current.get('remark') or '')
|
||||
@ -2723,10 +2871,14 @@ def domestic_orders_update_batch():
|
||||
if added:
|
||||
trace = f"[{operated_at} {operator_name or '未知账号'}] {added}"
|
||||
final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace
|
||||
query(
|
||||
"UPDATE arrival_batches SET remark=%s WHERE id=%s AND order_type=%s",
|
||||
(final_remark, batch_id, order_type),
|
||||
)
|
||||
update_fields.append('remark=%s')
|
||||
update_params.append(final_remark)
|
||||
if update_fields:
|
||||
query(
|
||||
"UPDATE arrival_batches SET " + ', '.join(update_fields) + " WHERE id=%s AND order_type=%s",
|
||||
tuple(update_params) + (batch_id, order_type),
|
||||
)
|
||||
sync_order_arrival_totals(current['order_id'], order_type)
|
||||
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
|
||||
if final_remark != old_remark:
|
||||
add_operation_log(
|
||||
@ -2735,11 +2887,21 @@ def domestic_orders_update_batch():
|
||||
page=page_name,
|
||||
field='到货批次及数量',
|
||||
)
|
||||
if supplemented:
|
||||
add_operation_log(
|
||||
'到货批次及数量',
|
||||
f"补填到货批次字段:{';'.join(supplemented)}(批次ID={batch_id},订单ID={current['order_id']})",
|
||||
page=page_name,
|
||||
field='到货批次及数量',
|
||||
)
|
||||
return jsonify({"code": 0, "msg": "更新成功", "data": {
|
||||
"remark": final_remark,
|
||||
"operator_name": current.get('operator_name') or '',
|
||||
"operated_at": current.get('operated_at').strftime('%Y-%m-%d %H:%M:%S')
|
||||
if hasattr(current.get('operated_at'), 'strftime') else str(current.get('operated_at') or '')[:19]
|
||||
if hasattr(current.get('operated_at'), 'strftime') else str(current.get('operated_at') or '')[:19],
|
||||
"arrival_date_filled": 1 if current.get('arrival_date_filled') or data.get('arrival_date') not in (None, '') else 0,
|
||||
"arrival_qty_filled": 1 if current.get('arrival_qty_filled') or data.get('arrival_qty') not in (None, '') else 0,
|
||||
"defective_qty_filled": 1 if current.get('defective_qty_filled') or data.get('defective_qty') not in (None, '') else 0,
|
||||
}})
|
||||
|
||||
|
||||
@ -3503,44 +3665,7 @@ def packaging_orders_delete():
|
||||
# ─── 成品提货表 API ───
|
||||
def _finished_order_progress_map():
|
||||
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
|
||||
rows = []
|
||||
for source, sql in (
|
||||
('domestic', """
|
||||
SELECT `货品编号` AS product_code, `进度`,
|
||||
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
|
||||
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
|
||||
id
|
||||
FROM domestic_orders
|
||||
WHERE `状态`='未完结'
|
||||
"""),
|
||||
('overseas', """
|
||||
SELECT `产品编码` AS product_code, `进度`,
|
||||
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
|
||||
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
|
||||
id
|
||||
FROM overseas_orders
|
||||
WHERE `状态`='未完结'
|
||||
"""),
|
||||
):
|
||||
for row in query(sql):
|
||||
row['_source_order'] = 0 if source == 'domestic' else 1
|
||||
rows.append(row)
|
||||
|
||||
def sort_key(row):
|
||||
order_date = row.get('order_date')
|
||||
if order_date and hasattr(order_date, 'strftime'):
|
||||
date_text = order_date.strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
date_text = str(order_date or '').strip()
|
||||
return (0 if date_text else 1, date_text, row.get('_source_order', 0), row.get('id') or 0)
|
||||
|
||||
rows.sort(key=sort_key)
|
||||
progress_map = {}
|
||||
for row in rows:
|
||||
product_code = str(row.get('product_code') or '').strip()
|
||||
if product_code and product_code not in progress_map:
|
||||
progress_map[product_code] = row.get('进度') or ''
|
||||
return progress_map
|
||||
return unfinished_order_progress_map()
|
||||
|
||||
|
||||
def _finished_inventory_maps():
|
||||
|
||||
@ -80,18 +80,19 @@ def warehouse_save():
|
||||
if not code or not name:
|
||||
return jsonify({"code": 1, "msg": "仓库编号和名称不能为空"})
|
||||
|
||||
exists = query("SELECT id, warehouse_property FROM warehouses WHERE warehouse_code=%s", (code,))
|
||||
exists = query("SELECT id, warehouse_property, disabled FROM warehouses WHERE warehouse_code=%s", (code,))
|
||||
property_changed = not exists or (exists[0].get('warehouse_property') or '').strip() != warehouse_property
|
||||
disabled_changed = not exists or (exists[0].get('disabled') or '否').strip() != disabled
|
||||
if exists:
|
||||
query("UPDATE warehouses SET warehouse_name=%s, category=%s, remark=%s, warehouse_property=%s, disabled=%s WHERE warehouse_code=%s",
|
||||
(name, category, remark, warehouse_property, disabled, code))
|
||||
else:
|
||||
query("INSERT INTO warehouses (warehouse_code, warehouse_name, category, remark, warehouse_property, disabled) VALUES (%s,%s,%s,%s,%s,%s)",
|
||||
(code, name, category, remark, warehouse_property, disabled))
|
||||
if property_changed:
|
||||
if property_changed or disabled_changed:
|
||||
from services.cache import refresh_all_caches
|
||||
refresh_all_caches()
|
||||
return jsonify({"code": 0, "msg": "保存成功", "cache_refreshed": property_changed})
|
||||
return jsonify({"code": 0, "msg": "保存成功", "cache_refreshed": property_changed or disabled_changed})
|
||||
|
||||
|
||||
@warehouse_bp.route('/api/warehouse/delete', methods=['POST'])
|
||||
@ -125,16 +126,17 @@ def warehouse_update_field():
|
||||
if field not in allowed:
|
||||
return jsonify({"code": 400, "msg": "不允许的字段"})
|
||||
property_changed = False
|
||||
disabled_changed = field == 'disabled'
|
||||
if field == 'warehouse_property':
|
||||
old_rows = query("SELECT warehouse_property FROM warehouses WHERE warehouse_code=%s", (code,))
|
||||
old_value = (old_rows[0].get('warehouse_property') or '').strip() if old_rows else ''
|
||||
property_changed = old_value != str(value or '').strip()
|
||||
try:
|
||||
query(f"UPDATE warehouses SET {field}=%s WHERE warehouse_code=%s", (value, code))
|
||||
if property_changed:
|
||||
if property_changed or disabled_changed:
|
||||
from services.cache import refresh_all_caches
|
||||
refresh_all_caches()
|
||||
return jsonify({"code": 0, "msg": "更新成功", "cache_refreshed": property_changed})
|
||||
return jsonify({"code": 0, "msg": "更新成功", "cache_refreshed": property_changed or disabled_changed})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)})
|
||||
|
||||
|
||||
29
scripts/import_department_matching.py
Normal file
29
scripts/import_department_matching.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""首次初始化部门规则,默认只检查,--apply 才写入。"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('workbook')
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
parser.add_argument('--local', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.local:
|
||||
import config
|
||||
config.DB_CONFIG.update(user='dy_test', password=Path('.local-test-db-password').read_text().strip())
|
||||
from services.department_matching import workbook_records, import_workbook, check_samples
|
||||
rows = workbook_records(args.workbook)
|
||||
for i, row in enumerate(rows, 1):
|
||||
row['id'] = i
|
||||
print(json.dumps({'records': len(rows), 'validation': check_samples(rows)}, ensure_ascii=False))
|
||||
if args.apply:
|
||||
print(json.dumps({'imported': import_workbook(args.workbook)}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
29
scripts/sync_dingtalk_organization.py
Executable file
29
scripts/sync_dingtalk_organization.py
Executable file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""每日同步钉钉组织架构。"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import config
|
||||
|
||||
local_password = ROOT / '.local-test-db-password'
|
||||
if local_password.exists():
|
||||
config.DB_CONFIG.update({
|
||||
'user': 'dy_test',
|
||||
'password': local_password.read_text(encoding='ascii').strip(),
|
||||
'unix_socket': '/run/mysqld/mysqld.sock',
|
||||
})
|
||||
|
||||
from services.dingtalk_organization import sync_organization
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
print(json.dumps(sync_organization('scheduled'), ensure_ascii=False))
|
||||
except Exception as exc:
|
||||
print(json.dumps({'status': 'failed', 'message': str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@ -769,6 +769,256 @@ def _h_supplier_delivery(params):
|
||||
rows = _apply_filters(rows, params)
|
||||
return {'columns': fields, 'rows': rows}
|
||||
|
||||
|
||||
def _h_operation_history(params):
|
||||
"""按板块及业务对象检索结构化审计日志,旧记录允许文本兼容匹配。"""
|
||||
import json
|
||||
import re
|
||||
code = str(params.get('product_code') or '').strip()
|
||||
product_name = str(params.get('product_name') or '').strip()
|
||||
identity = code or product_name
|
||||
module = str(params.get('module') or '').strip()
|
||||
field = str(params.get('field') or '').strip()
|
||||
operator = str(params.get('operator') or '').strip()
|
||||
start_date = str(params.get('start_date') or '').strip()
|
||||
end_date = str(params.get('end_date') or '').strip()
|
||||
try:
|
||||
limit = max(1, min(int(params.get('limit') or 100), 200))
|
||||
except (TypeError, ValueError):
|
||||
limit = 100
|
||||
|
||||
where = ["l.action <> '登录'",
|
||||
"COALESCE(l.event_type,'') NOT IN ('import','export','ai_analysis','ai_report','ai_simulation','login')",
|
||||
"COALESCE(l.action,'') NOT LIKE '%%导入%%'"]
|
||||
values = []
|
||||
allowed_pages = params.get('_allowed_pages')
|
||||
if allowed_pages is not None:
|
||||
allowed_pages = [str(page) for page in allowed_pages if str(page).strip()]
|
||||
if not allowed_pages:
|
||||
return {'columns': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
|
||||
'rows': [], 'summary': {'row_count': 0, 'returned_rows': 0,
|
||||
'exact_object_matches': 0, 'text_fallback_matches': 0, 'limit': limit}}
|
||||
where.append("COALESCE(NULLIF(l.page,''),l.action) IN (" + ','.join(['%s'] * len(allowed_pages)) + ")")
|
||||
values.extend(allowed_pages)
|
||||
if code:
|
||||
boundary_pattern = r'(^|[^A-Za-z0-9-])' + re.escape(code) + r'($|[^A-Za-z0-9-])'
|
||||
where.append("(l.object_id=%s OR l.new_values REGEXP %s OR l.old_values REGEXP %s OR l.detail REGEXP %s)")
|
||||
values.extend((code, boundary_pattern, boundary_pattern, boundary_pattern))
|
||||
elif product_name:
|
||||
where.append("(l.new_values LIKE %s OR l.old_values LIKE %s OR l.detail LIKE %s)")
|
||||
values.extend(['%' + product_name + '%'] * 3)
|
||||
if module:
|
||||
where.append("COALESCE(NULLIF(l.page,''),l.action) LIKE %s")
|
||||
values.append('%' + module + '%')
|
||||
if field:
|
||||
where.append("(COALESCE(l.field,'') LIKE %s OR l.new_values LIKE %s OR l.old_values LIKE %s)")
|
||||
values.extend(['%' + field + '%'] * 3)
|
||||
if operator:
|
||||
where.append("(u.real_name LIKE %s OR u.username LIKE %s)")
|
||||
values.extend(['%' + operator + '%'] * 2)
|
||||
if start_date:
|
||||
where.append('l.created_at >= %s')
|
||||
values.append(start_date + ' 00:00:00')
|
||||
if end_date:
|
||||
where.append('l.created_at < DATE_ADD(%s, INTERVAL 1 DAY)')
|
||||
values.append(end_date)
|
||||
|
||||
where_sql = ' AND '.join(where)
|
||||
total_rows = query('SELECT COUNT(*) AS total FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id WHERE ' + where_sql,
|
||||
values)[0].get('total', 0)
|
||||
rows = query("""SELECT l.id,l.created_at,l.user_id,l.page,l.action,l.field,l.object_type,l.object_id,
|
||||
l.old_values,l.new_values,l.detail,l.result_status,l.request_id,
|
||||
u.real_name,u.username
|
||||
FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id
|
||||
WHERE """ + where_sql + ' ORDER BY l.created_at DESC,l.id DESC LIMIT %s',
|
||||
values + [limit])
|
||||
|
||||
def decode(value):
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(value) if isinstance(value, str) else value
|
||||
return scrub(parsed) if isinstance(parsed, (dict, list)) else {'内容': parsed}
|
||||
except (TypeError, ValueError):
|
||||
return {'历史原文': str(value)[:1000]}
|
||||
|
||||
def scrub(value):
|
||||
sensitive = ('password', 'token', 'secret', 'authorization', 'cookie', 'api_key',
|
||||
'密码', '令牌', '密钥', '授权信息')
|
||||
if isinstance(value, dict):
|
||||
return {key: ('[已隐藏]' if any(mark in str(key).lower() for mark in sensitive)
|
||||
else scrub(item)) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [scrub(item) for item in value]
|
||||
return value
|
||||
|
||||
field_labels = {
|
||||
'product_code': '货品编号', 'product_name': '货品名称', 'product_type': '货品类型',
|
||||
'brand': '品牌', 'supplier': '供应商', 'supplier_code': '供应商编码', 'supplier_name': '供应商名称',
|
||||
'planner': '采购计划', 'purchase_planner': '采购计划', 'follower': '采购跟单',
|
||||
'purchase_follower': '采购跟单', 'operator': '运营', 'developer': '开发专员',
|
||||
'barcode': '条形码', 'category': '类目', 'platform': '平台', 'department': '部门',
|
||||
'group_name': '组别', 'status': '状态', 'progress': '进度', 'remark': '备注',
|
||||
'order_remark': '下单备注', 'purchase_plan': '采购计划', 'lead_time': '生产货期',
|
||||
'logistics_time': '物流时间', 'moq': '最低起订量', 'cost_price': '成本价',
|
||||
'price': '单价', 'quantity': '数量', 'order_quantity': '下单数量',
|
||||
'inventory': '库存', 'stock': '库存', 'arrival_date': '到货日期',
|
||||
'completion_date': '完结日期', 'new_old': '新旧品', 'is_new': '新旧品',
|
||||
'id': '记录编号', 'product_codes': '货品编号列表', 'materials': '物料列表',
|
||||
'bom_data': 'BOM数据', 'material_code': '物料编号', 'material_name': '物料名称',
|
||||
'use_quantity': '用量', 'unit_quantity': '用量', 'old_values': '修改前内容',
|
||||
'new_values': '修改后内容', 'value': '字段值', 'field': '字段', 'data': '提交数据',
|
||||
'warehouse': '仓库', 'warehouse_code': '仓库编号', 'warehouse_name': '仓库名称',
|
||||
'supplier_contract': '供应商合同', 'contract_code': '合同编号', 'purchase_order': '采购单',
|
||||
'purchase_plan_name': '采购计划', 'purchase_group': '跟单小组',
|
||||
'view': '视图', 'columns': '字段列', 'sort_by': '排序字段', 'sort_order': '排序顺序',
|
||||
'width': '列宽', 'visible': '是否显示', 'default_show': '默认显示', 'order': '顺序',
|
||||
'settings': '设置', 'config': '配置', 'filters': '筛选条件', 'page': '页码',
|
||||
'size': '每页条数', 'limit': '数量上限', 'enabled': '是否启用', 'deleted': '是否删除',
|
||||
}
|
||||
page_labels = {
|
||||
'planning': '计划预测', 'products': '产品信息管理', 'product': '产品信息管理',
|
||||
'order_domestic': '国内订单管理', 'order_overseas': '海外订单管理',
|
||||
'order_packaging': '包材订单管理', 'purchase_pending': '待下单管理',
|
||||
'purchase_backup': '下单备份管理', 'bom': '物料清单管理', 'suppliers': '供应商管理',
|
||||
'spot_application': '现货采样申请', 'spot_confirmation': '现货采样确认',
|
||||
'spot_sales': '现货销量管理', 'success': '成功', 'failed': '失败', 'denied': '已拒绝',
|
||||
}
|
||||
|
||||
def label_for(key):
|
||||
key = str(key)
|
||||
if key in field_labels:
|
||||
return field_labels[key]
|
||||
if key in ('货品编号', '货品名称', '产品编码', '产品名称'):
|
||||
return key
|
||||
# 常见 camelCase key 先拆分,再保留语义清晰的中文前缀。
|
||||
readable = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', key).replace('-', '_')
|
||||
parts = readable.split('_')
|
||||
if len(parts) > 1 and parts[0] in ('product', 'material', 'supplier', 'order'):
|
||||
roots = {'product': '产品', 'material': '物料', 'supplier': '供应商', 'order': '订单'}
|
||||
tails = {'code': '编号', 'name': '名称', 'type': '类型', 'id': '编号', 'quantity': '数量',
|
||||
'status': '状态', 'remark': '备注', 'price': '单价', 'date': '日期'}
|
||||
return roots[parts[0]] + ''.join(tails.get(part, part) for part in parts[1:])
|
||||
return key
|
||||
|
||||
def format_value(value, field_key=''):
|
||||
if field_key in ('field', 'sort_by') and isinstance(value, str):
|
||||
return label_for(value)
|
||||
if field_key == 'sort_order' and value in ('asc', 'desc'):
|
||||
return '升序' if value == 'asc' else '降序'
|
||||
if field_key == 'view' and value == 'default':
|
||||
return '默认视图'
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(translate_data(value), ensure_ascii=False, default=str)
|
||||
return str(value)
|
||||
|
||||
def translate_data(value):
|
||||
if isinstance(value, dict):
|
||||
translated = {}
|
||||
for key, item in value.items():
|
||||
if key in ('field', 'sort_by') and isinstance(item, str):
|
||||
translated[label_for(key)] = label_for(item)
|
||||
else:
|
||||
translated[label_for(key)] = translate_data(item)
|
||||
return translated
|
||||
if isinstance(value, list):
|
||||
return [translate_data(item) for item in value]
|
||||
return value
|
||||
|
||||
def diff_values(old_value, new_value, path):
|
||||
"""Return leaf-level changes so large submitted objects do not expose unchanged siblings."""
|
||||
if isinstance(old_value, dict) and isinstance(new_value, dict):
|
||||
changes = []
|
||||
for key in dict.fromkeys(list(old_value) + list(new_value)):
|
||||
child_path = path + [str(key)]
|
||||
if key not in old_value:
|
||||
changes.append((child_path, None, new_value[key]))
|
||||
elif key not in new_value:
|
||||
changes.append((child_path, old_value[key], None))
|
||||
else:
|
||||
changes.extend(diff_values(old_value[key], new_value[key], child_path))
|
||||
return changes
|
||||
if isinstance(old_value, list) and isinstance(new_value, list):
|
||||
identity_keys = ('product_code', 'material_code', 'product_id', 'id', 'code',
|
||||
'货品编号', '物料编号', '产品编码')
|
||||
if all(isinstance(item, dict) for item in old_value + new_value):
|
||||
def item_key(item, index):
|
||||
for identity_key in identity_keys:
|
||||
if item.get(identity_key) not in (None, ''):
|
||||
return str(item[identity_key])
|
||||
return '#%s' % index
|
||||
old_items = {item_key(item, index): item for index, item in enumerate(old_value)}
|
||||
new_items = {item_key(item, index): item for index, item in enumerate(new_value)}
|
||||
changes = []
|
||||
for item_id in dict.fromkeys(list(old_items) + list(new_items)):
|
||||
item_path = path + [item_id]
|
||||
if item_id not in old_items:
|
||||
changes.append((item_path, None, new_items[item_id]))
|
||||
elif item_id not in new_items:
|
||||
changes.append((item_path, old_items[item_id], None))
|
||||
else:
|
||||
changes.extend(diff_values(old_items[item_id], new_items[item_id], item_path))
|
||||
return changes
|
||||
if old_value == new_value:
|
||||
return []
|
||||
return [(path, old_value, new_value)]
|
||||
return [] if old_value == new_value else [(path, old_value, new_value)]
|
||||
|
||||
def display_value(value, field_key=''):
|
||||
if value is None:
|
||||
return '(空)'
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(translate_data(value), ensure_ascii=False, default=str)
|
||||
return format_value(value, field_key)
|
||||
|
||||
result = []
|
||||
exact_count = 0
|
||||
text_fallback_count = 0
|
||||
for row in rows:
|
||||
exact = bool(code and str(row.get('object_id') or '').casefold() == code.casefold())
|
||||
if identity and not exact:
|
||||
pattern = (re.compile(r'(?<![A-Za-z0-9-])' + re.escape(code) + r'(?![A-Za-z0-9-])', re.I)
|
||||
if code else re.compile(re.escape(product_name), re.I))
|
||||
if not any(pattern.search(str(row.get(key) or '')) for key in ('new_values', 'old_values', 'detail')):
|
||||
continue
|
||||
exact_count += int(exact)
|
||||
text_fallback_count += int(bool(identity and not exact))
|
||||
old_values = decode(row.get('old_values'))
|
||||
new_values = decode(row.get('new_values'))
|
||||
changed = []
|
||||
changed_fields = []
|
||||
if isinstance(new_values, dict):
|
||||
old_map = old_values if isinstance(old_values, dict) else {}
|
||||
for key, new_value in new_values.items():
|
||||
# A new-only request payload is not evidence that every submitted field changed.
|
||||
if key not in old_map:
|
||||
continue
|
||||
for path, old_value, current_value in diff_values(old_map[key], new_value, [str(key)]):
|
||||
label = ' · '.join(label_for(part) for part in path)
|
||||
changed_fields.append(label)
|
||||
changed.append('%s:%s → %s' % (
|
||||
label, display_value(old_value, path[-1]), display_value(current_value, path[-1])))
|
||||
created = row.get('created_at')
|
||||
if hasattr(created, 'strftime'):
|
||||
created = created.strftime('%Y-%m-%d %H:%M:%S')
|
||||
result.append({
|
||||
'操作时间': str(created or ''),
|
||||
'操作人': row.get('real_name') or row.get('username') or ('用户#%s' % row.get('user_id')),
|
||||
'板块': page_labels.get(str(row.get('page') or ''), row.get('page') or ''),
|
||||
'操作': page_labels.get(str(row.get('action') or ''), row.get('action') or ''),
|
||||
'字段': '、'.join(dict.fromkeys(changed_fields)) or label_for(row.get('field') or '请求字段(旧日志未结构化)'),
|
||||
'修改内容': ';'.join(changed) if changed else '该记录未保存可比较的字段前后值',
|
||||
'匹配方式': '精确对象匹配' if exact else ('历史文本匹配' if identity else '条件匹配'),
|
||||
'结果': page_labels.get(str(row.get('result_status') or ''), row.get('result_status') or ''),
|
||||
'请求编号': row.get('request_id') or '',
|
||||
'日志ID': row.get('id'),
|
||||
})
|
||||
return {'columns': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
|
||||
'rows': result,
|
||||
'summary': {'row_count': int(total_rows or 0), 'returned_rows': len(result),
|
||||
'exact_object_matches': exact_count,
|
||||
'text_fallback_matches': text_fallback_count, 'limit': limit}}
|
||||
|
||||
def _latest_outbound_date():
|
||||
"""最新出库日期(销量/出库类数据源的核对日期)"""
|
||||
rows = query("SELECT MAX(日期) AS d FROM daily_outbound")
|
||||
@ -935,6 +1185,15 @@ AI_DATASOURCES = {
|
||||
'date_kind': '',
|
||||
'keywords': ['供应商表现', '供应商交付', '供应商排名', '交付率', '不良品率', '供应商风险'],
|
||||
},
|
||||
'operation_history': {
|
||||
'name': '手动修改记录',
|
||||
'desc': '只读检索结构化操作日志,按板块、货品编号、字段、操作人和日期查询;精确对象匹配与旧日志文本匹配会分别标注。需要操作日志菜单查看权限。可回答某商品谁在何时修改了哪些内容。',
|
||||
'fields': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
|
||||
'handler': _h_operation_history,
|
||||
'permission_pages': ['logs'],
|
||||
'date_kind': '',
|
||||
'keywords': ['操作日志','修改记录','编辑记录','手动修改','修改历史','变更历史','谁修改','修改过','修改前','修改后'],
|
||||
},
|
||||
'brand_overview': {
|
||||
'name': '品牌汇总',
|
||||
'desc': '按品牌汇总的 SKU数、库存数量、库存金额、昨日销量、30天平均销量与近30/100天动销SKU数(默认按库存金额倒序)。可回答:哪个品牌库存金额最高、某品牌有多少个动销SKU、各品牌销量对比。',
|
||||
|
||||
@ -20,6 +20,8 @@ from services.audit_schema import ensure_audit_schema, infer_page, infer_action,
|
||||
|
||||
# 明确非数据变更的接口(登录/退出/导出/知识库自检/合同生成/查询),不做审计
|
||||
EXCLUDED_PATHS = frozenset({
|
||||
'/api/department-matching/test', # 只读匹配试算
|
||||
'/api/department-matching/check', # 只读对照表核验
|
||||
'/api/login', # 登录(无会话上下文,且登录日志单独直接写入)
|
||||
'/api/logout', # 退出(仅清理会话,不产生数据变更)
|
||||
'/api/inventory/export', # 库存导出(生成文件,不修改业务数据)
|
||||
|
||||
@ -59,9 +59,36 @@ def classify_risk(action='',field='',path=''):
|
||||
return 'normal'
|
||||
|
||||
def infer_object(data,path=''):
|
||||
if isinstance(data,list):
|
||||
for item in data:
|
||||
nested_type,nested_id=infer_object(item,path)
|
||||
if nested_id:return nested_type,nested_id
|
||||
return ('','')
|
||||
if not isinstance(data,dict):return ('','')
|
||||
for key in ('product_code','material_code','supplier_code','record_id','order_id','subject_id','target_id','id','user_id'):
|
||||
# 中文字段名用于兼容前端表单及嵌套业务对象的常见编码字段。
|
||||
for key in ('product_code','货品编号','产品编码','货品编码','material_code','物料编号','supplier_code','供应商编码',
|
||||
'order_id','订单编号','record_id','subject_id','target_id'):
|
||||
value=data.get(key)
|
||||
if isinstance(value,dict):
|
||||
nested_type,nested_id=infer_object(value,path)
|
||||
if nested_id:return nested_type,nested_id
|
||||
if isinstance(value,list):
|
||||
for item in value:
|
||||
nested_type,nested_id=infer_object(item,path)
|
||||
if nested_id:return nested_type,nested_id
|
||||
if value not in (None,''):
|
||||
object_type=key.replace('_code','').replace('_id','')
|
||||
if key in ('货品编号','产品编码','货品编码'):object_type='product'
|
||||
elif key=='物料编号':object_type='material'
|
||||
elif key=='供应商编码':object_type='supplier'
|
||||
elif key=='订单编号':object_type='order'
|
||||
return (object_type,str(value))
|
||||
for value in data.values():
|
||||
if isinstance(value,(dict,list)):
|
||||
nested_type,nested_id=infer_object(value,path)
|
||||
if nested_id:return nested_type,nested_id
|
||||
for key in ('id','user_id'):
|
||||
value=data.get(key)
|
||||
if value not in (None,''):
|
||||
return (key.replace('_code','').replace('_id',''),str(value))
|
||||
return (key.replace('_id',''),str(value))
|
||||
return ('','')
|
||||
|
||||
208
services/department_matching.py
Normal file
208
services/department_matching.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""独立的部门归属规则库;不写入出库、人员或组织架构数据。"""
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from services.db import get_db, query
|
||||
|
||||
|
||||
KINDS = ('platform', 'brand', 'nonbrand', 'shop')
|
||||
FIELDS = {
|
||||
'platform': ('department', 'shop_keywords', 'source_keywords'),
|
||||
'brand': ('keyword', 'department', 'mode', 'pdd_department', 'overseas_department', 'notes', 'reference_count'),
|
||||
'nonbrand': ('name', 'reason', 'guidance'),
|
||||
'shop': ('shop', 'row_count', 'product_count', 'department', 'layer'),
|
||||
}
|
||||
MODES = ('fixed', 'split', 'pdd_only')
|
||||
|
||||
|
||||
def ensure_schema():
|
||||
query("""CREATE TABLE IF NOT EXISTS department_matching_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
payload JSON NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
source_file VARCHAR(255) NOT NULL DEFAULT '',
|
||||
source_row INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_kind (kind, priority, id)
|
||||
) CHARACTER SET utf8mb4""")
|
||||
|
||||
|
||||
def records():
|
||||
rows = query('SELECT * FROM department_matching_records ORDER BY priority,id')
|
||||
for row in rows:
|
||||
if isinstance(row['payload'], str):
|
||||
row['payload'] = json.loads(row['payload'])
|
||||
return rows
|
||||
|
||||
|
||||
def normalized(text):
|
||||
return unicodedata.normalize('NFKC', str(text or '')).strip().casefold()
|
||||
|
||||
|
||||
def tokens(text):
|
||||
return [normalized(v) for v in re.split(r'[,,、\n]+', str(text or '')) if normalized(v)]
|
||||
|
||||
|
||||
def validate_record(data):
|
||||
kind = data.get('kind')
|
||||
if kind not in KINDS:
|
||||
raise ValueError('记录类型不正确')
|
||||
raw = data.get('payload')
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('请填写规则内容')
|
||||
payload = {k: str(raw.get(k, '') if raw.get(k) is not None else '').strip() for k in FIELDS[kind]}
|
||||
if any(len(v) > 4000 for v in payload.values()):
|
||||
raise ValueError('单字段内容不能超过4000字')
|
||||
required = {'platform': ('department',), 'brand': ('keyword', 'mode'),
|
||||
'nonbrand': ('name',), 'shop': ('shop', 'department')}[kind]
|
||||
if any(not payload[k] for k in required):
|
||||
raise ValueError('请填写必填字段')
|
||||
if kind == 'platform' and not (tokens(payload['shop_keywords']) or tokens(payload['source_keywords'])):
|
||||
raise ValueError('店铺关键词和来源关键词至少填写一项')
|
||||
if kind == 'brand':
|
||||
if payload['mode'] not in MODES:
|
||||
raise ValueError('品牌匹配方式不正确')
|
||||
if payload['mode'] in ('fixed', 'split') and not payload['department']:
|
||||
raise ValueError('请填写默认归属')
|
||||
if payload['mode'] in ('split', 'pdd_only') and not payload['pdd_department']:
|
||||
raise ValueError('请填写拼多多归属')
|
||||
try:
|
||||
priority = int(data.get('priority', 100))
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError('优先级必须为整数')
|
||||
if not 0 <= priority <= 100000:
|
||||
raise ValueError('优先级范围为0至100000,数字越小越优先')
|
||||
enabled = data.get('enabled', True)
|
||||
if enabled not in (True, False, 0, 1):
|
||||
raise ValueError('启用状态不正确')
|
||||
return kind, payload, priority, int(bool(enabled))
|
||||
|
||||
|
||||
def match_department(shop='', source='', rows=None):
|
||||
"""品牌条件匹配后回落平台层;来源仅在店铺为空时精确匹配。"""
|
||||
shop, source = normalized(shop), normalized(source)
|
||||
rows = sorted((r for r in (records() if rows is None else rows) if r['enabled']),
|
||||
key=lambda r: (r['priority'], r['id']))
|
||||
|
||||
def result(row, department, layer, reason):
|
||||
return {'department': department, 'layer': layer, 'reason': reason, 'rule_id': row['id']}
|
||||
|
||||
if shop:
|
||||
for row in rows:
|
||||
if row['kind'] != 'brand':
|
||||
continue
|
||||
p = row['payload']
|
||||
keyword = normalized(p['keyword'])
|
||||
compact = lambda value: re.sub(r'\s+', '', value)
|
||||
if keyword in ('dr jeveital', 'drjeveital', 'jeveital'):
|
||||
hit = compact(keyword) in compact(shop)
|
||||
elif keyword == 'aromat':
|
||||
hit = bool(re.search(r'aromat(?![a-z])', shop))
|
||||
else:
|
||||
hit = bool(keyword and keyword in shop)
|
||||
if not hit:
|
||||
continue
|
||||
is_pdd = '拼多多' in shop
|
||||
mode = p['mode']
|
||||
if mode == 'pdd_only' and not is_pdd:
|
||||
continue
|
||||
department = p['pdd_department'] if is_pdd and mode != 'fixed' else p['department']
|
||||
if mode == 'split' and p.get('overseas_department') and any(
|
||||
word in shop for word in ('京东海外', '京东国际')):
|
||||
department = p['overseas_department']
|
||||
return result(row, department, '品牌层', '店铺命中品牌关键词:' + p['keyword'])
|
||||
|
||||
candidates = []
|
||||
for row in rows:
|
||||
if row['kind'] == 'platform':
|
||||
for keyword in tokens(row['payload']['shop_keywords']):
|
||||
if keyword in shop:
|
||||
candidates.append((-len(keyword), row['priority'], row['id'], keyword, row))
|
||||
if candidates:
|
||||
_, _, _, keyword, row = min(candidates, key=lambda item: item[:3])
|
||||
return result(row, row['payload']['department'], '平台层', '店铺命中平台关键词:' + keyword)
|
||||
elif source:
|
||||
for row in rows:
|
||||
if row['kind'] == 'platform' and source in tokens(row['payload']['source_keywords']):
|
||||
return result(row, row['payload']['department'], '来源兜底', '店铺为空,数据来源精确命中:' + source)
|
||||
return {'department': '', 'layer': '未匹配', 'reason': '未命中规则,请维护对应规则', 'rule_id': None}
|
||||
|
||||
|
||||
def check_samples(rows=None):
|
||||
rows = records() if rows is None else rows
|
||||
samples = []
|
||||
for row in rows:
|
||||
if row['kind'] == 'shop':
|
||||
p = row['payload']
|
||||
matched = match_department(p['shop'], rows=rows)
|
||||
samples.append({'id': row['id'], 'shop': p['shop'], 'expected': p['department'],
|
||||
'actual': matched['department'], 'passed': matched['department'] == p['department'],
|
||||
'reason': matched['reason']})
|
||||
return {'total': len(samples), 'passed': sum(r['passed'] for r in samples),
|
||||
'mismatches': [r for r in samples if not r['passed']]}
|
||||
|
||||
|
||||
def workbook_records(path):
|
||||
from openpyxl import load_workbook
|
||||
workbook = load_workbook(path, data_only=True, read_only=True)
|
||||
result = []
|
||||
try:
|
||||
for sheet, kind in (('平台层规则', 'platform'), ('品牌层规则', 'brand'),
|
||||
('非品牌清单', 'nonbrand'), ('店铺归属对照表', 'shop')):
|
||||
for number, values in enumerate(workbook[sheet].iter_rows(min_row=2, values_only=True), 2):
|
||||
if not any(v is not None for v in values):
|
||||
continue
|
||||
v = [str(value).strip() if value is not None else '' for value in values]
|
||||
if kind == 'platform':
|
||||
payload = dict(zip(FIELDS[kind], v))
|
||||
elif kind == 'brand':
|
||||
keyword, department, match_type, notes, count = v
|
||||
mode, pdd, overseas = 'fixed', '', ''
|
||||
if keyword in ('QSIMELR', 'MUTO'):
|
||||
mode, department, pdd = 'split', '灵动', '雪狼'
|
||||
overseas = '雪狼' if keyword == 'MUTO' else ''
|
||||
elif keyword in ('UOUONE', 'WOPUKA', 'FOCOOL', 'AVAA'):
|
||||
mode, department, pdd = 'pdd_only', '', '春跃'
|
||||
payload = dict(zip(FIELDS[kind], (keyword, department, mode, pdd, overseas, notes, count)))
|
||||
payload['notes'] = match_type + ';' + notes
|
||||
else:
|
||||
payload = dict(zip(FIELDS[kind], v))
|
||||
k, payload, priority, enabled = validate_record({'kind': kind, 'payload': payload, 'priority': number * 10})
|
||||
result.append({'kind': k, 'payload': payload, 'priority': priority, 'enabled': enabled, 'source_row': number})
|
||||
finally:
|
||||
workbook.close()
|
||||
return result
|
||||
|
||||
|
||||
def import_workbook(path):
|
||||
"""首次导入;已有记录则拒绝覆盖,重复运行安全。"""
|
||||
from pathlib import Path
|
||||
rows = workbook_records(path)
|
||||
ensure_schema()
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.begin()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT GET_LOCK('department_matching_import', 10) AS acquired")
|
||||
if not cur.fetchone()['acquired']:
|
||||
raise ValueError('另一个导入正在进行')
|
||||
cur.execute('SELECT COUNT(*) AS n FROM department_matching_records')
|
||||
if cur.fetchone()['n']:
|
||||
raise ValueError('规则库已有数据,未覆盖;请在页面维护')
|
||||
cur.executemany('''INSERT INTO department_matching_records
|
||||
(kind,payload,priority,enabled,source_file,source_row) VALUES (%s,%s,%s,%s,%s,%s)''',
|
||||
[(r['kind'], json.dumps(r['payload'], ensure_ascii=False), r['priority'], r['enabled'],
|
||||
Path(path).name, r['source_row']) for r in rows])
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT RELEASE_LOCK('department_matching_import')")
|
||||
conn.close()
|
||||
return {kind: sum(r['kind'] == kind for r in rows) for kind in KINDS}
|
||||
340
services/dingtalk_organization.py
Normal file
340
services/dingtalk_organization.py
Normal file
@ -0,0 +1,340 @@
|
||||
"""钉钉组织架构只读同步服务。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from services.db import get_db, query
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SECRET_FILE = PROJECT_ROOT / '.secrets' / 'dingtalk_organization.json'
|
||||
|
||||
|
||||
def load_dingtalk_config():
|
||||
config = {
|
||||
'app_key': os.environ.get('DINGTALK_ORG_APP_KEY', '').strip(),
|
||||
'app_secret': os.environ.get('DINGTALK_ORG_APP_SECRET', '').strip(),
|
||||
'corp_id': os.environ.get('DINGTALK_ORG_CORP_ID', '').strip(),
|
||||
}
|
||||
if SECRET_FILE.exists():
|
||||
with SECRET_FILE.open(encoding='utf-8') as handle:
|
||||
saved = json.load(handle)
|
||||
for key in config:
|
||||
config[key] = config[key] or str(saved.get(key) or '').strip()
|
||||
missing = [key for key, value in config.items() if not value]
|
||||
if missing:
|
||||
raise RuntimeError('钉钉组织架构配置不完整:' + ', '.join(missing))
|
||||
return config
|
||||
|
||||
|
||||
def ensure_organization_schema():
|
||||
statements = [
|
||||
"""CREATE TABLE IF NOT EXISTS dingtalk_org_departments (
|
||||
dept_id BIGINT NOT NULL PRIMARY KEY,
|
||||
parent_id BIGINT NOT NULL DEFAULT 0,
|
||||
name VARCHAR(200) NOT NULL DEFAULT '',
|
||||
sort_order BIGINT NOT NULL DEFAULT 0,
|
||||
active TINYINT NOT NULL DEFAULT 1,
|
||||
synced_at DATETIME NOT NULL,
|
||||
KEY idx_dingtalk_dept_parent (parent_id, active, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
"""CREATE TABLE IF NOT EXISTS dingtalk_org_users (
|
||||
userid VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
unionid VARCHAR(128) NOT NULL DEFAULT '',
|
||||
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
mobile VARCHAR(32) NOT NULL DEFAULT '',
|
||||
title VARCHAR(200) NOT NULL DEFAULT '',
|
||||
job_number VARCHAR(100) NOT NULL DEFAULT '',
|
||||
avatar VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
active TINYINT NOT NULL DEFAULT 1,
|
||||
is_admin TINYINT NOT NULL DEFAULT 0,
|
||||
is_boss TINYINT NOT NULL DEFAULT 0,
|
||||
hired_date BIGINT NULL,
|
||||
matched_dashboard_user_id INT NULL,
|
||||
synced_at DATETIME NOT NULL,
|
||||
KEY idx_dingtalk_user_name (name),
|
||||
KEY idx_dingtalk_user_mobile (mobile),
|
||||
KEY idx_dingtalk_user_match (matched_dashboard_user_id),
|
||||
CONSTRAINT fk_dingtalk_user_match FOREIGN KEY (matched_dashboard_user_id)
|
||||
REFERENCES dashboard_users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
"""CREATE TABLE IF NOT EXISTS dingtalk_org_memberships (
|
||||
dept_id BIGINT NOT NULL,
|
||||
userid VARCHAR(128) NOT NULL,
|
||||
is_leader TINYINT NOT NULL DEFAULT 0,
|
||||
dept_order BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dept_id, userid),
|
||||
KEY idx_dingtalk_membership_user (userid),
|
||||
CONSTRAINT fk_dingtalk_membership_dept FOREIGN KEY (dept_id)
|
||||
REFERENCES dingtalk_org_departments(dept_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_dingtalk_membership_user FOREIGN KEY (userid)
|
||||
REFERENCES dingtalk_org_users(userid) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
"""CREATE TABLE IF NOT EXISTS dingtalk_org_sync_runs (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||
department_count INT NOT NULL DEFAULT 0,
|
||||
user_count INT NOT NULL DEFAULT 0,
|
||||
matched_count INT NOT NULL DEFAULT 0,
|
||||
message VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
started_at DATETIME NOT NULL,
|
||||
finished_at DATETIME NULL,
|
||||
KEY idx_dingtalk_sync_started (started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
for statement in statements:
|
||||
query(statement)
|
||||
|
||||
|
||||
def _request_json(url, payload=None):
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
request = Request(url, data=body, headers={'Content-Type': 'application/json'})
|
||||
with urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
if int(result.get('errcode') or 0) != 0:
|
||||
raise RuntimeError(f"钉钉接口错误 {result.get('errcode')}:{result.get('errmsg') or '未知错误'}")
|
||||
return result
|
||||
|
||||
|
||||
def _access_token(config):
|
||||
result = _request_json(
|
||||
'https://oapi.dingtalk.com/gettoken?' + urlencode({
|
||||
'appkey': config['app_key'],
|
||||
'appsecret': config['app_secret'],
|
||||
})
|
||||
)
|
||||
token = str(result.get('access_token') or '')
|
||||
if not token:
|
||||
raise RuntimeError('钉钉未返回 access_token')
|
||||
return token
|
||||
|
||||
|
||||
def _post(token, path, payload):
|
||||
return _request_json(f'https://oapi.dingtalk.com/{path}?access_token={token}', payload)
|
||||
|
||||
|
||||
def _fetch_departments(token):
|
||||
departments = {}
|
||||
root = _post(token, 'topapi/v2/department/get', {'dept_id': 1, 'language': 'zh_CN'}).get('result') or {}
|
||||
departments[1] = {
|
||||
'dept_id': 1,
|
||||
'parent_id': 0,
|
||||
'name': str(root.get('name') or '企业组织架构'),
|
||||
'sort_order': int(root.get('order') or 0),
|
||||
}
|
||||
stack = [1]
|
||||
while stack:
|
||||
parent_id = stack.pop()
|
||||
result = _post(token, 'topapi/v2/department/listsub', {'dept_id': parent_id, 'language': 'zh_CN'})
|
||||
for row in result.get('result') or []:
|
||||
dept_id = int(row.get('dept_id') or 0)
|
||||
if not dept_id or dept_id in departments:
|
||||
continue
|
||||
departments[dept_id] = {
|
||||
'dept_id': dept_id,
|
||||
'parent_id': int(row.get('parent_id') or parent_id),
|
||||
'name': str(row.get('name') or ''),
|
||||
'sort_order': int(row.get('order') or 0),
|
||||
}
|
||||
stack.append(dept_id)
|
||||
return departments
|
||||
|
||||
|
||||
def _fetch_userids(token, department_ids):
|
||||
userids = set()
|
||||
for dept_id in department_ids:
|
||||
cursor = 0
|
||||
while True:
|
||||
result = _post(token, 'topapi/user/listsimple', {
|
||||
'dept_id': dept_id,
|
||||
'cursor': cursor,
|
||||
'size': 100,
|
||||
'contain_access_limit': False,
|
||||
'language': 'zh_CN',
|
||||
}).get('result') or {}
|
||||
for row in result.get('list') or []:
|
||||
userid = str(row.get('userid') or '').strip()
|
||||
if userid:
|
||||
userids.add(userid)
|
||||
if not result.get('has_more'):
|
||||
break
|
||||
cursor = int(result.get('next_cursor') or 0)
|
||||
return userids
|
||||
|
||||
|
||||
def _fetch_users(token, userids):
|
||||
users = {}
|
||||
for userid in sorted(userids):
|
||||
detail = _post(token, 'topapi/v2/user/get', {'userid': userid, 'language': 'zh_CN'}).get('result') or {}
|
||||
detail['userid'] = userid
|
||||
users[userid] = detail
|
||||
return users
|
||||
|
||||
|
||||
def _dashboard_user_matches(conn):
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT id, real_name, phone FROM dashboard_users")
|
||||
rows = cursor.fetchall()
|
||||
by_phone = {}
|
||||
by_name = {}
|
||||
for row in rows:
|
||||
phone = str(row.get('phone') or '').strip()
|
||||
name = str(row.get('real_name') or '').strip()
|
||||
if phone:
|
||||
by_phone.setdefault(phone, []).append(int(row['id']))
|
||||
if name:
|
||||
by_name.setdefault(name, []).append(int(row['id']))
|
||||
return by_phone, by_name
|
||||
|
||||
|
||||
def _match_dashboard_user(detail, by_phone, by_name):
|
||||
mobile = str(detail.get('mobile') or '').strip()
|
||||
name = str(detail.get('name') or '').strip()
|
||||
if mobile and len(by_phone.get(mobile, [])) == 1:
|
||||
return by_phone[mobile][0]
|
||||
if name and len(by_name.get(name, [])) == 1:
|
||||
return by_name[name][0]
|
||||
return None
|
||||
|
||||
|
||||
def sync_organization(trigger_type='manual'):
|
||||
"""全量同步钉钉部门和人员;不自动修改 dashboard_users。"""
|
||||
ensure_organization_schema()
|
||||
lock_conn = get_db()
|
||||
run_id = None
|
||||
try:
|
||||
with lock_conn.cursor() as cursor:
|
||||
cursor.execute("SELECT GET_LOCK('dy_dingtalk_org_sync', 0) AS acquired")
|
||||
if not int((cursor.fetchone() or {}).get('acquired') or 0):
|
||||
raise RuntimeError('组织架构同步正在执行,请稍后再试')
|
||||
started_at = datetime.now()
|
||||
run_id = query(
|
||||
"INSERT INTO dingtalk_org_sync_runs(trigger_type,status,started_at) VALUES (%s,'running',%s)",
|
||||
(trigger_type, started_at),
|
||||
)
|
||||
config = load_dingtalk_config()
|
||||
token = _access_token(config)
|
||||
departments = _fetch_departments(token)
|
||||
userids = _fetch_userids(token, departments.keys())
|
||||
users = _fetch_users(token, userids)
|
||||
synced_at = datetime.now()
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
by_phone, by_name = _dashboard_user_matches(conn)
|
||||
matched_count = 0
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("UPDATE dingtalk_org_departments SET active=0")
|
||||
cursor.execute("UPDATE dingtalk_org_users SET active=0")
|
||||
for department in departments.values():
|
||||
cursor.execute(
|
||||
"""INSERT INTO dingtalk_org_departments
|
||||
(dept_id,parent_id,name,sort_order,active,synced_at)
|
||||
VALUES (%s,%s,%s,%s,1,%s)
|
||||
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id),name=VALUES(name),
|
||||
sort_order=VALUES(sort_order),active=1,synced_at=VALUES(synced_at)""",
|
||||
(department['dept_id'], department['parent_id'], department['name'],
|
||||
department['sort_order'], synced_at),
|
||||
)
|
||||
for userid, detail in users.items():
|
||||
matched_id = _match_dashboard_user(detail, by_phone, by_name)
|
||||
if matched_id:
|
||||
matched_count += 1
|
||||
cursor.execute(
|
||||
"""INSERT INTO dingtalk_org_users
|
||||
(userid,unionid,name,mobile,title,job_number,avatar,active,is_admin,is_boss,
|
||||
hired_date,matched_dashboard_user_id,synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON DUPLICATE KEY UPDATE unionid=VALUES(unionid),name=VALUES(name),
|
||||
mobile=VALUES(mobile),title=VALUES(title),job_number=VALUES(job_number),
|
||||
avatar=VALUES(avatar),active=VALUES(active),is_admin=VALUES(is_admin),
|
||||
is_boss=VALUES(is_boss),hired_date=VALUES(hired_date),
|
||||
matched_dashboard_user_id=VALUES(matched_dashboard_user_id),synced_at=VALUES(synced_at)""",
|
||||
(userid, str(detail.get('unionid') or ''), str(detail.get('name') or ''),
|
||||
str(detail.get('mobile') or ''), str(detail.get('title') or ''),
|
||||
str(detail.get('job_number') or ''), str(detail.get('avatar') or ''),
|
||||
1 if detail.get('active', True) else 0, 1 if detail.get('admin') else 0,
|
||||
1 if detail.get('boss') else 0, detail.get('hired_date'), matched_id, synced_at),
|
||||
)
|
||||
cursor.execute("DELETE FROM dingtalk_org_memberships")
|
||||
for userid, detail in users.items():
|
||||
leader_map = {int(x.get('dept_id') or 0): bool(x.get('leader'))
|
||||
for x in detail.get('leader_in_dept') or []}
|
||||
order_map = {int(x.get('dept_id') or 0): int(x.get('order') or 0)
|
||||
for x in detail.get('dept_order_list') or []}
|
||||
for dept_id in detail.get('dept_id_list') or []:
|
||||
dept_id = int(dept_id or 0)
|
||||
if dept_id not in departments:
|
||||
continue
|
||||
cursor.execute(
|
||||
"INSERT INTO dingtalk_org_memberships(dept_id,userid,is_leader,dept_order) VALUES (%s,%s,%s,%s)",
|
||||
(dept_id, userid, 1 if leader_map.get(dept_id) else 0, order_map.get(dept_id, 0)),
|
||||
)
|
||||
cursor.execute(
|
||||
"""UPDATE dingtalk_org_sync_runs SET status='success',department_count=%s,
|
||||
user_count=%s,matched_count=%s,message=%s,finished_at=%s WHERE id=%s""",
|
||||
(len(departments), len(users), matched_count, '同步成功', synced_at, run_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
return {
|
||||
'run_id': run_id,
|
||||
'department_count': len(departments),
|
||||
'user_count': len(users),
|
||||
'matched_count': matched_count,
|
||||
'synced_at': synced_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
}
|
||||
except Exception as exc:
|
||||
if run_id:
|
||||
try:
|
||||
query(
|
||||
"UPDATE dingtalk_org_sync_runs SET status='failed',message=%s,finished_at=NOW() WHERE id=%s",
|
||||
(str(exc)[:1000], run_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
with lock_conn.cursor() as cursor:
|
||||
cursor.execute("SELECT RELEASE_LOCK('dy_dingtalk_org_sync')")
|
||||
finally:
|
||||
lock_conn.close()
|
||||
|
||||
|
||||
def organization_snapshot():
|
||||
ensure_organization_schema()
|
||||
departments = query(
|
||||
"""SELECT dept_id,parent_id,name,sort_order,synced_at
|
||||
FROM dingtalk_org_departments WHERE active=1 ORDER BY parent_id,sort_order,dept_id"""
|
||||
)
|
||||
members = query(
|
||||
"""SELECT m.dept_id,u.userid,u.name,u.mobile,u.title,u.job_number,u.avatar,
|
||||
u.active,m.is_leader,m.dept_order,u.matched_dashboard_user_id,
|
||||
du.username AS matched_username,du.real_name AS matched_real_name
|
||||
FROM dingtalk_org_memberships m
|
||||
JOIN dingtalk_org_users u ON u.userid=m.userid AND u.active=1
|
||||
LEFT JOIN dashboard_users du ON du.id=u.matched_dashboard_user_id
|
||||
JOIN dingtalk_org_departments d ON d.dept_id=m.dept_id AND d.active=1
|
||||
ORDER BY m.dept_id,m.is_leader DESC,m.dept_order,u.name"""
|
||||
)
|
||||
last_runs = query(
|
||||
"""SELECT id,trigger_type,status,department_count,user_count,matched_count,message,
|
||||
started_at,finished_at FROM dingtalk_org_sync_runs ORDER BY id DESC LIMIT 1"""
|
||||
)
|
||||
for collection in (departments, members, last_runs):
|
||||
for row in collection:
|
||||
for key, value in list(row.items()):
|
||||
if isinstance(value, datetime):
|
||||
row[key] = value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
return {'departments': departments, 'members': members, 'last_sync': last_runs[0] if last_runs else None}
|
||||
@ -28,6 +28,7 @@ FIELD_REGISTRY = {
|
||||
{'key': '新旧品', 'sensitive': False},
|
||||
{'key': '下单清单', 'sensitive': False},
|
||||
{'key': '产品分级', 'sensitive': False},
|
||||
{'key': '在途进度', 'sensitive': False},
|
||||
{'key': '昨日销量', 'sensitive': False},
|
||||
{'key': '云仓昨日销量', 'sensitive': False},
|
||||
{'key': '外仓昨日销量', 'sensitive': False},
|
||||
|
||||
@ -36,6 +36,28 @@ FORCE_FINISHED_PRODUCTS_SQL = ', '.join(
|
||||
)
|
||||
|
||||
|
||||
def active_warehouse_condition(inventory_alias='inventory_detail'):
|
||||
"""SQL condition that excludes warehouses marked disabled in warehouse management.
|
||||
|
||||
Inventory rows whose warehouse is not maintained in ``warehouses`` remain eligible;
|
||||
only an explicit ``disabled='是'`` removes a warehouse from calculations. Numeric
|
||||
warehouse codes are compared by value so 29/029 and 35/035 behave consistently.
|
||||
"""
|
||||
alias = inventory_alias
|
||||
return f"""NOT EXISTS (
|
||||
SELECT 1 FROM warehouses disabled_wh
|
||||
WHERE COALESCE(disabled_wh.disabled, '否') = '是'
|
||||
AND (
|
||||
disabled_wh.warehouse_code = {alias}.warehouse_code
|
||||
OR (
|
||||
disabled_wh.warehouse_code REGEXP '^[0-9]+$'
|
||||
AND {alias}.warehouse_code REGEXP '^[0-9]+$'
|
||||
AND CAST(disabled_wh.warehouse_code AS UNSIGNED) = CAST({alias}.warehouse_code AS UNSIGNED)
|
||||
)
|
||||
)
|
||||
)"""
|
||||
|
||||
|
||||
def resolve_cost_price(product_code, inventory_price):
|
||||
"""解析产品成本价:命中特殊规则的用单独指定的正确成本价,否则用库存表成本价。"""
|
||||
if product_code in SPECIAL_COST_PRICES:
|
||||
@ -54,10 +76,11 @@ def _get_latest_inv_date():
|
||||
def _compute_product_inventory():
|
||||
"""计算每个产品的最新库存(排除15号仓库)"""
|
||||
latest_str = _get_latest_inv_date()
|
||||
rows = query("""
|
||||
rows = query(f"""
|
||||
SELECT product_code, SUM(available_qty) AS total_qty
|
||||
FROM inventory_detail
|
||||
WHERE data_date = %s AND warehouse_code != '15'
|
||||
AND {active_warehouse_condition('inventory_detail')}
|
||||
AND NOT (product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')
|
||||
AND LPAD(warehouse_code, 2, '0') = '20')
|
||||
GROUP BY product_code
|
||||
@ -76,6 +99,7 @@ def _compute_raw_inventory_15():
|
||||
"SELECT product_code, SUM(available_qty) AS total_qty "
|
||||
"FROM inventory_detail "
|
||||
"WHERE data_date = %s AND warehouse_code = '15' "
|
||||
f"AND {active_warehouse_condition('inventory_detail')} "
|
||||
"GROUP BY product_code",
|
||||
(latest_str,))
|
||||
inv15_map = {}
|
||||
@ -97,7 +121,8 @@ def compute_product_inventory_15():
|
||||
def compute_product_inventory_by_property(property_keyword):
|
||||
"""根据仓库属性标签计算每个产品的库存(如云仓、外仓)"""
|
||||
wh_codes = query(
|
||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET(%s, warehouse_property) > 0",
|
||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET(%s, warehouse_property) > 0 "
|
||||
"AND COALESCE(disabled, '否') != '是'",
|
||||
(property_keyword,)
|
||||
)
|
||||
# 仓库管理和库存明细的编号格式可能不同(例如管理表为035、明细为35)。
|
||||
@ -171,7 +196,7 @@ def compute_product_in_transit():
|
||||
"SELECT product_code, SUM(production_in_transit) AS prod_in_transit, "
|
||||
"SUM(purchase_in_transit) AS purch_in_transit "
|
||||
"FROM inventory_detail "
|
||||
"WHERE data_date = %s "
|
||||
f"WHERE data_date = %s AND {active_warehouse_condition('inventory_detail')} "
|
||||
"GROUP BY product_code",
|
||||
(latest_str,))
|
||||
transit_map = {}
|
||||
@ -272,7 +297,7 @@ def compute_inventory_cost_summary(latest_date=None):
|
||||
_cost_map = {r['product_code']: resolve_cost_price(r['product_code'], float(r['pr']) if r['pr'] else 0) for r in _latest_cost}
|
||||
|
||||
# 成品/包材分类汇总
|
||||
_inventory_rows = query("""
|
||||
_inventory_rows = query(f"""
|
||||
SELECT id.product_code,
|
||||
SUM(CASE WHEN
|
||||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||||
@ -302,6 +327,7 @@ def compute_inventory_cost_summary(latest_date=None):
|
||||
FROM inventory_detail id
|
||||
LEFT JOIN products p ON id.product_code = p.product_code
|
||||
WHERE id.data_date = %s
|
||||
AND {active_warehouse_condition('id')}
|
||||
AND id.product_code NOT LIKE %s
|
||||
AND id.product_name NOT LIKE %s
|
||||
AND NOT (id.product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')
|
||||
|
||||
85
services/order_progress.py
Normal file
85
services/order_progress.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""国内/海外订单统一在途进度口径。"""
|
||||
from datetime import datetime
|
||||
|
||||
from services.db import query
|
||||
|
||||
|
||||
def _order_time_value(value):
|
||||
"""把历史订单中的 datetime、YYYY-MM-DD 和斜杠日期转成可比较值。"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
text = text.replace('/', '-').replace('T', ' ')
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d', '%Y-%m-%d %H:%M'):
|
||||
try:
|
||||
return datetime.strptime(text[:19], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def unfinished_order_progress_map(product_codes=None):
|
||||
"""按货品编号返回国内/海外最早一笔未完结订单的详情进度。
|
||||
|
||||
下单时间沿用订单管理口径:订单下达供应商时间优先,其次订单日期,
|
||||
最后使用计划下单时间;无日期的历史记录排在有日期记录之后。
|
||||
"""
|
||||
source_codes = list(product_codes) if product_codes is not None else None
|
||||
requested = {
|
||||
str(code or '').strip() for code in (source_codes or []) if str(code or '').strip()
|
||||
}
|
||||
if source_codes is not None and not requested:
|
||||
return {}
|
||||
rows = []
|
||||
for source_order, (table, code_column) in enumerate((
|
||||
('domestic_orders', '货品编号'),
|
||||
('overseas_orders', '产品编码'),
|
||||
)):
|
||||
params = []
|
||||
code_where = ''
|
||||
if requested:
|
||||
code_where = f" AND `{code_column}` IN ({','.join(['%s'] * len(requested))})"
|
||||
params.extend(sorted(requested))
|
||||
sql = f"""
|
||||
SELECT `{code_column}` AS product_code, `进度`,
|
||||
`订单下达供应商时间` AS supplier_ordered_at,
|
||||
`日期` AS order_date, `计划下单时间` AS planned_ordered_at, id
|
||||
FROM {table}
|
||||
WHERE `状态`='未完结'{code_where}
|
||||
"""
|
||||
for row in query(sql, tuple(params)):
|
||||
row['_source_order'] = source_order
|
||||
rows.append(row)
|
||||
|
||||
def sort_key(row):
|
||||
ordered_at = (
|
||||
_order_time_value(row.get('supplier_ordered_at'))
|
||||
or _order_time_value(row.get('order_date'))
|
||||
or _order_time_value(row.get('planned_ordered_at'))
|
||||
)
|
||||
return (
|
||||
0 if ordered_at else 1,
|
||||
ordered_at or datetime.max,
|
||||
row.get('_source_order', 0),
|
||||
row.get('id') or 0,
|
||||
)
|
||||
|
||||
rows.sort(key=sort_key)
|
||||
result = {}
|
||||
for row in rows:
|
||||
code = str(row.get('product_code') or '').strip()
|
||||
if code and code not in result:
|
||||
result[code] = row.get('进度') or ''
|
||||
return result
|
||||
|
||||
|
||||
def attach_unfinished_order_progress(rows):
|
||||
"""为计划预测行批量附加“在途进度”,避免逐 SKU 查询。"""
|
||||
progress = unfinished_order_progress_map(row.get('product_code') for row in rows)
|
||||
for row in rows:
|
||||
row['在途进度'] = progress.get(str(row.get('product_code') or '').strip(), '')
|
||||
return rows
|
||||
568
static/js/app.js
568
static/js/app.js
@ -1,7 +1,7 @@
|
||||
// 蝶雅采购智能体平台 - 前端逻辑
|
||||
let charts = {};
|
||||
// 页面缓存:切换菜单时不销毁页面状态
|
||||
var CACHE_VERSION = 94;
|
||||
var CACHE_VERSION = 102;
|
||||
if (!window._pageCache) window._pageCache = {};
|
||||
if (!window._pageDataCache) window._pageDataCache = {};
|
||||
if (!window._pageReloaders) window._pageReloaders = {};
|
||||
@ -33,6 +33,8 @@ const PAGE_LOADERS = {
|
||||
'data_outbound': loadDataPage,
|
||||
'products': loadProductsPage,
|
||||
'brand_management': loadBrandManagementPage,
|
||||
'organization': loadOrganizationPage,
|
||||
'department_matching': loadDepartmentMatchingPage,
|
||||
'users': loadUsersPage,
|
||||
'logs': loadLogsPage,
|
||||
'login_records': loadLoginRecordsPage,
|
||||
@ -2442,19 +2444,30 @@ function aiAppendBubble(role, html) {
|
||||
function aiRenderTable(columns, rows, sql, totalRows, canExport) {
|
||||
if (!rows || !rows.length) return '<div style="color:#999">查询无数据</div>';
|
||||
if (canExport === undefined || canExport === null) canExport = true;
|
||||
var thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px">' + escHtml(c) + '</th>'; }).join('');
|
||||
var tbody = rows.slice(0, 100).map(function(r) {
|
||||
var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1);
|
||||
window._aiResults = window._aiResults || {};
|
||||
window._aiResults[rid] = {columns: columns, rows: rows};
|
||||
var operationHistoryTable = columns.indexOf('修改内容') >= 0 && columns.indexOf('日志ID') >= 0;
|
||||
var historyWidths = {'操作时间':150,'操作人':100,'板块':140,'操作':120,'字段':170,'修改内容':420,'匹配方式':130,'结果':80,'请求编号':180,'日志ID':80};
|
||||
var colgroup = operationHistoryTable ? '<colgroup>' + columns.map(function(c) {
|
||||
return '<col style="width:' + (historyWidths[c] || 120) + 'px">';
|
||||
}).join('') + '</colgroup>' : '';
|
||||
var thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px;overflow:hidden;text-overflow:ellipsis">' + escHtml(c) + '</th>'; }).join('');
|
||||
var tbody = rows.slice(0, 100).map(function(r, rowIndex) {
|
||||
return '<tr>' + columns.map(function(c) {
|
||||
var v = r[c];
|
||||
if (v === null || v === undefined) v = '';
|
||||
var s = String(v);
|
||||
if (c === '修改内容') {
|
||||
var preview = s.length > 72 ? s.slice(0, 72) + '…' : s;
|
||||
return '<td style="text-align:left;padding:6px 8px;font-size:12px;overflow:hidden" title="' + escHtml(s) + '">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;min-width:0;max-width:100%"><span style="display:block;flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(preview) + '</span>' +
|
||||
(s.length > 72 ? '<button type="button" class="btn btn-sm" style="flex:0 0 auto;padding:0 5px;font-size:11px" onclick="aiShowResultCell(\'' + rid + '\',' + rowIndex + ',\'' + escJs(c) + '\')">查看全部</button>' : '') + '</div></td>';
|
||||
}
|
||||
if (s.length > 60) s = s.slice(0, 60) + '…';
|
||||
return '<td style="text-align:center;padding:6px 10px;font-size:12px;white-space:nowrap">' + escHtml(s) + '</td>';
|
||||
return '<td style="text-align:center;padding:6px 8px;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(String(v)) + '"><span style="display:block;overflow:hidden;text-overflow:ellipsis">' + escHtml(s) + '</span></td>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1);
|
||||
window._aiResults = window._aiResults || {};
|
||||
window._aiResults[rid] = {columns: columns, rows: rows};
|
||||
var total = totalRows || rows.length;
|
||||
var hint = total > rows.slice(0, 100).length
|
||||
? '<div style="margin-top:6px;font-size:11px;color:#fa8c16">共 ' + total + ' 行,当前仅展示前 ' + rows.slice(0, 100).length + ' 行,可点「导出CSV」查看全部</div>'
|
||||
@ -2466,11 +2479,22 @@ function aiRenderTable(columns, rows, sql, totalRows, canExport) {
|
||||
: '<span style="font-size:11px;color:#bbb">(无导出权限)</span>') +
|
||||
'<span style="font-size:11px;color:#bbb">共 ' + total + ' 行</span>' +
|
||||
'</div>';
|
||||
return toolbar + '<div style="overflow-x:auto;max-height:320px;overflow-y:auto"><table class="data-table" style="font-size:12px;min-width:100%">' +
|
||||
'<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint +
|
||||
var tableStyle = operationHistoryTable
|
||||
? 'font-size:12px;table-layout:fixed;width:1570px;min-width:1570px;border-collapse:collapse'
|
||||
: 'font-size:12px;min-width:100%';
|
||||
return toolbar + '<div style="width:100%;overflow:auto;max-height:320px"><table class="data-table" style="' + tableStyle + '">' +
|
||||
colgroup + '<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint +
|
||||
(sql ? '<div style="margin-top:6px;font-size:11px;color:#bbb;word-break:break-all">SQL:' + escHtml(sql) + '</div>' : '');
|
||||
}
|
||||
|
||||
window.aiShowResultCell = function(rid, rowIndex, column) {
|
||||
var result = (window._aiResults || {})[rid];
|
||||
var row = result && result.rows && result.rows[rowIndex];
|
||||
if (!row) return;
|
||||
var id = 'aiResultCell-' + Date.now();
|
||||
document.body.insertAdjacentHTML('beforeend', '<div class="modal-overlay" id="' + id + '" style="display:flex;z-index:100100"><div class="modal" style="width:min(900px,92vw);max-height:86vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + escHtml(column || '完整内容') + '</h3><button class="modal-close" onclick="document.getElementById(\'' + id + '\').remove()">×</button></div><div class="modal-body" style="overflow:auto"><div style="font-size:12px;color:#777;margin-bottom:8px">' + escHtml(row['操作时间'] || '') + ' · ' + escHtml(row['操作人'] || '') + ' · ' + escHtml(row['板块'] || '') + '</div><pre style="margin:0;padding:14px;background:#f7f8fa;border:1px solid #e5e7eb;border-radius:4px;white-space:pre-wrap;word-break:break-word;font:12px/1.7 inherit">' + escHtml(row[column] || '') + '</pre></div><div class="modal-footer"><button class="btn btn-primary" onclick="document.getElementById(\'' + id + '\').remove()">关闭</button></div></div></div>');
|
||||
};
|
||||
|
||||
// 导出 CSV(带 BOM,Excel 直接打开不乱码)
|
||||
window.aiExportCsv = function(rid) {
|
||||
var res = (window._aiResults || {})[rid];
|
||||
@ -6036,6 +6060,8 @@ var PAGE_MAP = {
|
||||
'/orders/overseas': 'orders_overseas',
|
||||
'/orders/packaging': 'orders_packaging',
|
||||
'/orders/finished': 'orders_finished',
|
||||
'/organization': 'organization',
|
||||
'/department-matching': 'department_matching',
|
||||
'/users': 'users',
|
||||
'/login-records': 'login_records',
|
||||
'/logs': 'logs',
|
||||
@ -6175,6 +6201,8 @@ window._hideNoPermNavItems = function() {
|
||||
{page:'personnel', sel:'a.nav-sub-item[href="/personnel"]', sub:true},
|
||||
{page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false},
|
||||
{page:'contract_templates', sel:'a.nav-sub-item[href="/contract-templates"]', sub:true},
|
||||
{page:'organization', sel:'a.nav-sub-item[href="/organization"]', sub:true},
|
||||
{page:'department_matching', sel:'a.nav-sub-item[href="/department-matching"]', sub:true},
|
||||
{page:'users', sel:'a.nav-sub-item[href="/users"]', sub:true},
|
||||
{page:'permissions', sel:'a.nav-sub-item[href="/permissions"]', sub:true},
|
||||
{page:'login_records', sel:'a.nav-sub-item[href="/login-records"]', sub:true},
|
||||
@ -7627,12 +7655,7 @@ function loadInventoryBoardPage(container) {
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<h1 style="margin:0">库存数据看板</h1>
|
||||
<label style="font-size:13px;color:#666;margin-left:auto">月份
|
||||
<select id="invBoardMonth" autocomplete="off" aria-label="选择月份" style="width:150px;height:36px;box-sizing:border-box;padding:6px 10px;border:1px solid var(--border,#d9d9d9);border-radius:6px;font-size:15px;font-weight:600;color:#333;background:#fff">
|
||||
<option value="2026-08">2026-08</option>
|
||||
<option value="2026-07">2026-07</option>
|
||||
<option value="2026-06">2026-06</option>
|
||||
<option value="2026-05">2026-05</option>
|
||||
</select>
|
||||
<select id="invBoardMonth" autocomplete="off" aria-label="选择月份" style="width:150px;height:36px;box-sizing:border-box;padding:6px 10px;border:1px solid var(--border,#d9d9d9);border-radius:6px;font-size:15px;font-weight:600;color:#333;background:#fff"></select>
|
||||
</label>
|
||||
<button class="btn btn-sm" onclick="reloadInventoryBoard(true)">重新计算</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="openInventoryManualModal()">人工新建</button>
|
||||
@ -7647,8 +7670,13 @@ function loadInventoryBoardPage(container) {
|
||||
|
||||
var monthInput = document.getElementById('invBoardMonth');
|
||||
if (monthInput) {
|
||||
monthInput.value = '2026-08';
|
||||
monthInput.onchange = function() { reloadInventoryBoard(false); };
|
||||
var initialMonth = window._inventoryBoardSelectedMonth || inventoryBoardCurrentMonth();
|
||||
monthInput.innerHTML = inventoryBoardMonthOptions(initialMonth, false);
|
||||
monthInput.value = initialMonth;
|
||||
monthInput.onchange = function() {
|
||||
window._inventoryBoardSelectedMonth = this.value;
|
||||
reloadInventoryBoard(false);
|
||||
};
|
||||
}
|
||||
fetch('/api/dashboard/inventory-board?month=' + encodeURIComponent(monthInput ? monthInput.value : ''), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(r => r.json())
|
||||
@ -7664,9 +7692,36 @@ function loadInventoryBoardPage(container) {
|
||||
});
|
||||
}
|
||||
|
||||
function inventoryBoardCurrentMonth() {
|
||||
var parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit'
|
||||
}).formatToParts(new Date());
|
||||
var year = '', month = '';
|
||||
parts.forEach(function(part) {
|
||||
if (part.type === 'year') year = part.value;
|
||||
if (part.type === 'month') month = part.value;
|
||||
});
|
||||
return year + '-' + month;
|
||||
}
|
||||
function inventoryBoardMonthOptions(selected, includePlaceholder) {
|
||||
var current = inventoryBoardCurrentMonth();
|
||||
var cursor = new Date(Number(current.slice(0, 4)), Number(current.slice(5, 7)) - 1, 1);
|
||||
var earliest = new Date(2025, 0, 1);
|
||||
var options = includePlaceholder ? '<option value="">请选择月份</option>' : '';
|
||||
var values = [];
|
||||
while (cursor >= earliest) {
|
||||
values.push(cursor.getFullYear() + '-' + String(cursor.getMonth() + 1).padStart(2, '0'));
|
||||
cursor.setMonth(cursor.getMonth() - 1);
|
||||
}
|
||||
if (selected && values.indexOf(selected) < 0) values.unshift(selected);
|
||||
values.forEach(function(value) {
|
||||
options += '<option value="' + value + '"' + (value === selected ? ' selected' : '') + '>' + value + '</option>';
|
||||
});
|
||||
return options;
|
||||
}
|
||||
function inventoryBoardMonth() {
|
||||
var el = document.getElementById('invBoardMonth');
|
||||
return (el && el.value) || new Date().toISOString().slice(0, 7);
|
||||
return (el && el.value) || inventoryBoardCurrentMonth();
|
||||
}
|
||||
function reloadInventoryBoard(force) {
|
||||
var month = inventoryBoardMonth();
|
||||
@ -7707,21 +7762,12 @@ function scheduleInventoryBoardFreshPoll(d) {
|
||||
}, 1500);
|
||||
}
|
||||
function inventoryManualMonthOptions() {
|
||||
var options = '<option value="">请选择月份</option>';
|
||||
for (var year = 2025; year <= 2026; year++) {
|
||||
var startMonth = year === 2025 ? 1 : 1;
|
||||
var endMonth = year === 2026 ? 5 : 12;
|
||||
for (var month = startMonth; month <= endMonth; month++) {
|
||||
var value = year + '-' + String(month).padStart(2, '0');
|
||||
options += '<option value="' + value + '">' + value + '</option>';
|
||||
}
|
||||
}
|
||||
return options;
|
||||
return inventoryBoardMonthOptions(inventoryBoardMonth(), true);
|
||||
}
|
||||
function openInventoryManualModal() {
|
||||
var groups = [
|
||||
['库存金额',[['all_amount','全部仓库成品'],['wh01_amount','蝶雅仓成品'],['wh15_amount','加工厂仓成品'],['cloud_amount','云仓成品'],['overseas_amount','海外仓成品'],['domestic_self_amount','国内平台自营仓成品']]],
|
||||
['经营效率',[['inventory_total_amount','库存总金额'],['monthly_ship_cost','月销货成本'],['monthly_return_cost','月退货成本'],['monthly_refund_amount','月退款金额'],['return_rate','退货率'],['monthly_purchase_inbound','月采购入库'],['turnover_rate','月库存周转率'],['turnover_days','库存周转天数'],['active_sku','动销SKU数量(大于30)'],['active_sku_100','动销SKU数量(>100天)'],['total_sku','总SKU数量(库存>=1)'],['total_sku_200','总SKU数量(库存>=200)'],['active_rate','动销率(>30)'],['active_rate_100','动销率(>100)']]],
|
||||
['经营效率',[['inventory_total_amount','库存总金额'],['monthly_ship_cost','月销货成本'],['monthly_return_cost','月退货成本'],['monthly_refund_amount','月退款金额'],['return_rate','退货率'],['monthly_purchase_inbound','月采购入库'],['turnover_rate','月库存周转率'],['turnover_days','库存周转天数'],['active_sku','动销SKU数量(≥30件)'],['active_sku_100','动销SKU数量(≥100件)'],['total_sku','总SKU数量(库存≥1)'],['total_sku_200','总SKU数量(库存≥200)'],['active_rate','动销率(≥30件)'],['active_rate_100','动销率(≥100件)']]],
|
||||
['呆滞治理',[['slow_amount','呆滞库存金额'],['slow_ratio','呆滞库存比例']]],
|
||||
['缺货监控',[['stockout_sku_domestic','缺货SKU次数(国内)'],['stockout_sku_snowwolf','缺货SKU次数(国际-雪狼)'],['stockout_sku_lingdong','缺货SKU次数(国际-灵动)'],['stockout_sku_chunyue','缺货SKU次数(国际-春跃)'],['stockout_rate','缺货率'],['top100_stockout_rate','销量TOP100缺货率'],['new_stockout_rate','新品缺货率'],['old_stockout_rate','老品缺货率']]]
|
||||
];
|
||||
@ -7800,10 +7846,10 @@ function renderInventoryBoard(d) {
|
||||
'monthly_return_cost': {label:'月退货成本', unit:'万元', icon:'↩️', color:'#fa8c16'},
|
||||
'monthly_refund_amount': {label:'月退款金额', unit:'万元', icon:'💸', color:'#eb2f96'},
|
||||
'monthly_purchase_inbound': {label:'月采购入库', unit:'件', icon:'🚚', color:'#13c2c2'},
|
||||
'active_sku': {label:'动销SKU数量(大于30)', unit:'个', icon:'📊', color:'#52c41a'},
|
||||
'active_sku_100': {label:'动销SKU数量(>100天)', unit:'个', icon:'📈', color:'#a0d911'},
|
||||
'total_sku': {label:'总SKU数量(库存>=1)', unit:'个', icon:'🗃️', color:'#1890ff'},
|
||||
'total_sku_200': {label:'总SKU数量(库存>=200)', unit:'个', icon:'📚', color:'#2f54eb'},
|
||||
'active_sku': {label:'动销SKU数量(≥30件)', unit:'个', icon:'📊', color:'#52c41a'},
|
||||
'active_sku_100': {label:'动销SKU数量(≥100件)', unit:'个', icon:'📈', color:'#a0d911'},
|
||||
'total_sku': {label:'总SKU数量(库存≥1)', unit:'个', icon:'🗃️', color:'#1890ff'},
|
||||
'total_sku_200': {label:'总SKU数量(库存≥200)', unit:'个', icon:'📚', color:'#2f54eb'},
|
||||
'slow_amount': {label:'呆滞库存金额', unit:'万元', icon:'🕸️', color:'#faad14'},
|
||||
'stockout_sku_domestic': {label:'缺货SKU次数(国内)', unit:'次', icon:'🇨🇳', color:'#f5222d'},
|
||||
'stockout_sku_snowwolf': {label:'缺货SKU次数(国际-雪狼)', unit:'次', icon:'🐺', color:'#722ed1'},
|
||||
@ -7911,12 +7957,12 @@ function renderInventoryBoard(d) {
|
||||
{key:'monthly_purchase_inbound', label:'月采购入库', value:num(d.monthly_purchase_inbound), unit:'件', icon:'🚚', color:'#13c2c2'},
|
||||
{key:'turnover_rate', label:'月库存周转率', value:has(d.turnover_rate) ? pct(d.turnover_rate * 100) : '—', unit:'', icon:'🔄', color:'#722ed1'},
|
||||
{key:'turnover_days', label:'库存周转天数', value:has(d.turnover_days) ? d.turnover_days.toFixed(1) : '—', unit:'天', icon:'⏱️', color:'#2f54eb'},
|
||||
{key:'active_sku', label:'动销SKU数量(大于30)', value:num(d.active_sku_30), unit:'个', icon:'📊', color:'#52c41a'},
|
||||
{key:'active_sku_100', label:'动销SKU数量(>100天)', value:num(d.active_sku_100), unit:'个', icon:'📈', color:'#a0d911'},
|
||||
{key:'total_sku', label:'总SKU数量(库存>=1)', value:num(d.total_sku), unit:'个', icon:'🗃️', color:'#1890ff'},
|
||||
{key:'total_sku_200', label:'总SKU数量(库存>=200)', value:num(d.total_sku_200), unit:'个', icon:'📚', color:'#2f54eb'},
|
||||
{key:'active_rate', label:'动销率(>30)', value:pct(d.active_rate), unit:'', icon:'📈', color:'#a0d911'},
|
||||
{key:'active_rate_100', label:'动销率(>100)', value:pct(d.active_rate_100), unit:'', icon:'📊', color:'#13c2c2'}
|
||||
{key:'active_sku', label:'动销SKU数量(≥30件)', value:num(d.active_sku_30), unit:'个', icon:'📊', color:'#52c41a'},
|
||||
{key:'active_sku_100', label:'动销SKU数量(≥100件)', value:num(d.active_sku_100), unit:'个', icon:'📈', color:'#a0d911'},
|
||||
{key:'total_sku', label:'总SKU数量(库存≥1)', value:num(d.total_sku), unit:'个', icon:'🗃️', color:'#1890ff'},
|
||||
{key:'total_sku_200', label:'总SKU数量(库存≥200)', value:num(d.total_sku_200), unit:'个', icon:'📚', color:'#2f54eb'},
|
||||
{key:'active_rate', label:'动销率(≥30件)', value:pct(d.active_rate), unit:'', icon:'📈', color:'#a0d911'},
|
||||
{key:'active_rate_100', label:'动销率(≥100件)', value:pct(d.active_rate_100), unit:'', icon:'📊', color:'#13c2c2'}
|
||||
];
|
||||
var sec2Html = '';
|
||||
cards2.forEach(function(s) {
|
||||
@ -7974,8 +8020,8 @@ function saveInventoryExternal(key) {
|
||||
var val = (input.value || '').trim();
|
||||
var label = {'monthly_ship_cost':'月销货成本','monthly_return_cost':'月退货成本',
|
||||
'monthly_refund_amount':'月退款金额','monthly_purchase_inbound':'月采购入库',
|
||||
'active_sku':'动销SKU数量(大于30)','active_sku_100':'动销SKU数量(>100天)',
|
||||
'total_sku':'总SKU数量(库存>=1)','total_sku_200':'总SKU数量(库存>=200)',
|
||||
'active_sku':'动销SKU数量(≥30件)','active_sku_100':'动销SKU数量(≥100件)',
|
||||
'total_sku':'总SKU数量(库存≥1)','total_sku_200':'总SKU数量(库存≥200)',
|
||||
'slow_amount':'呆滞库存金额'}[key] || key;
|
||||
// 空值 = 清空外部值回退内部计算
|
||||
if (val !== '' && isNaN(parseFloat(val))) {
|
||||
@ -8016,14 +8062,13 @@ function saveInventoryExternal(key) {
|
||||
var WH_INFO = {
|
||||
'all': {
|
||||
title: '全部仓库成品',
|
||||
desc: '代表全部序号仓库数据之和',
|
||||
whs: '1、2、3、4、5、6、7、8、9、10、11、12、13、14、15、16、20、24、25、26、27、29号仓'
|
||||
desc: '仓库信息管理中全部启用仓库的成品库存之和(排除20号原材料仓)'
|
||||
},
|
||||
'wh01': {title: '蝶雅仓成品', desc: '代表 1 号仓数据', whs: '1号仓(蝶雅广州新仓)'},
|
||||
'wh15': {title: '加工厂仓成品', desc: '代表 15 号仓数据', whs: '15号仓(加工厂储存仓)'},
|
||||
'cloud': {title: '云仓成品', desc: '代表以下仓库数据之和', whs: '1、2、3、4、5、6、7、8、16、24、27、29号仓'},
|
||||
'overseas': {title: '海外仓成品', desc: '代表以下仓库数据之和', whs: '9、10、11、12、25、26号仓'},
|
||||
'domestic_self': {title: '国内平台自营仓成品', desc: '代表以下仓库数据之和', whs: '13、14号仓'}
|
||||
'cloud': {title: '云仓成品', desc: '仓库信息管理中已启用且勾选“云仓”属性的仓库之和(排除1号仓)'},
|
||||
'overseas': {title: '海外仓成品', desc: '仓库信息管理中已启用且勾选“海外仓”属性的仓库之和'},
|
||||
'domestic_self': {title: '国内平台自营仓成品', desc: '已启用的国内平台自营仓之和(13、14号仓及名称含“自营”的非海外仓,排除1号仓)'}
|
||||
};
|
||||
|
||||
// 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示)
|
||||
@ -8046,12 +8091,12 @@ var CALC_INFO = {
|
||||
'monthly_purchase_inbound': {title: '月采购入库', desc: '取自财务数据报表'},
|
||||
'turnover_rate': {title: '月库存周转率', desc: '月销货成本 ÷ 库存总金额'},
|
||||
'turnover_days': {title: '库存周转天数', desc: '当月天数 ÷ 月库存周转率'},
|
||||
'active_sku': {title: '动销SKU数量(大于30)', desc: '当月至少销售发货 30 件的 SKU 数量'},
|
||||
'active_sku_100': {title: '动销SKU数量(>100天)', desc: '当月至少销售发货 100 件的 SKU 数量'},
|
||||
'total_sku': {title: '总SKU数量(库存>=1)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 1)'},
|
||||
'total_sku_200': {title: '总SKU数量(库存>=200)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 200)'},
|
||||
'active_rate': {title: '动销率(>30)', desc: '动销SKU数量(>30)÷ 总SKU数量(库存>=1)× 100%'},
|
||||
'active_rate_100': {title: '动销率(>100)', desc: '动销SKU数量(>100)÷ 总SKU数量(库存>=1)× 100%'}
|
||||
'active_sku': {title: '动销SKU数量(≥30件)', desc: '所选自然月累计销售发货达到 30 件的 SKU 数量'},
|
||||
'active_sku_100': {title: '动销SKU数量(≥100件)', desc: '所选自然月累计销售发货达到 100 件的 SKU 数量'},
|
||||
'total_sku': {title: '总SKU数量(库存≥1)', desc: '月末启用仓库中成品库存合计达到 1 件的去重 SKU 数'},
|
||||
'total_sku_200': {title: '总SKU数量(库存≥200)', desc: '月末启用仓库中成品库存合计达到 200 件的去重 SKU 数'},
|
||||
'active_rate': {title: '动销率(≥30件)', desc: '动销SKU数量(≥30件)÷ 总SKU数量(库存≥1)× 100%'},
|
||||
'active_rate_100': {title: '动销率(≥100件)', desc: '动销SKU数量(≥100件)÷ 总SKU数量(库存≥1)× 100%'}
|
||||
};
|
||||
|
||||
function showWhTip(key, el) {
|
||||
@ -12010,6 +12055,258 @@ function savePersonnelGroup(payload) {
|
||||
}).catch(function(e) { alert('保存失败:' + e.message); });
|
||||
}
|
||||
|
||||
// ─── 组织架构 ───
|
||||
function loadOrganizationPage(container) {
|
||||
container.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;overflow:auto';
|
||||
container.innerHTML = `
|
||||
<div class="page-header" style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
|
||||
<div><h1>组织架构</h1><p class="page-desc">同步并查看钉钉部门与人员信息</p></div>
|
||||
<button class="btn btn-primary" id="organizationSyncBtn" onclick="syncDingtalkOrganization()" style="display:none">同步钉钉组织架构</button>
|
||||
</div>
|
||||
<div id="organizationSummary" style="display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;margin-bottom:14px"></div>
|
||||
<div class="card" style="flex:none;min-height:0;overflow:hidden">
|
||||
<div class="card-body" style="height:auto;min-height:0;padding:0;display:grid;grid-template-columns:300px minmax(0,1fr);align-items:start">
|
||||
<aside style="border-right:1px solid var(--border);height:calc(100vh - 245px);min-height:520px;max-height:760px;overflow:auto;padding:14px 12px;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin:0 6px 10px">
|
||||
<div style="font-size:14px;font-weight:600">部门架构</div>
|
||||
<button class="btn btn-sm" style="margin-left:auto;padding:2px 7px;font-size:11px" onclick="expandAllOrganizationDepartments()">全部展开</button>
|
||||
<button class="btn btn-sm" style="padding:2px 7px;font-size:11px" onclick="collapseAllOrganizationDepartments()">全部收起</button>
|
||||
</div>
|
||||
<div id="organizationDeptTree"><div class="page-loading"><div class="loading-spinner"></div></div></div>
|
||||
</aside>
|
||||
<section style="min-width:0;height:clamp(620px,calc(100vh - 225px),820px);display:flex;flex-direction:column;align-self:start">
|
||||
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong id="organizationMemberTitle">全部人员</strong>
|
||||
<input id="organizationSearch" class="form-input" style="width:240px;margin-left:auto" placeholder="姓名/手机号/职位/工号" autocomplete="off" onkeydown="if(event.key==='Enter')searchOrganizationMembers()">
|
||||
<button class="btn btn-primary" onclick="searchOrganizationMembers()">搜索</button>
|
||||
<button class="btn" onclick="resetOrganizationSearch()">重置</button>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;overflow:auto">
|
||||
<table class="data-table" style="width:100%;min-width:820px">
|
||||
<thead style="position:sticky;top:0;z-index:2"><tr><th>姓名</th><th>部门</th><th>职位</th><th>手机号</th><th>工号</th><th>系统账号关联</th><th>状态</th></tr></thead>
|
||||
<tbody id="organizationMemberBody"><tr><td colspan="7" style="text-align:center;padding:50px;color:var(--text-light)">正在加载...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="organizationPagination" style="padding:10px 16px;border-top:1px solid var(--border);text-align:center"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>`;
|
||||
var syncBtn = document.getElementById('organizationSyncBtn');
|
||||
if (syncBtn && window._hasPagePerm('organization', 'sync')) syncBtn.style.display = '';
|
||||
fetchOrganizationData();
|
||||
}
|
||||
|
||||
window._organizationSelectedDept = 0;
|
||||
window._organizationData = null;
|
||||
window._organizationExpandedDepts = {'1': true};
|
||||
window._organizationMemberPage = 1;
|
||||
window._organizationMemberPageSize = 20;
|
||||
|
||||
function fetchOrganizationData() {
|
||||
fetch('/api/organization/tree', {credentials:'same-origin'})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(res) {
|
||||
if (res.code !== 0) throw new Error(res.msg || '组织架构加载失败');
|
||||
window._organizationData = res.data || {departments:[], members:[], last_sync:null};
|
||||
renderOrganizationSummary();
|
||||
renderOrganizationDepartments();
|
||||
renderOrganizationMembers();
|
||||
})
|
||||
.catch(function(error) {
|
||||
var tree = document.getElementById('organizationDeptTree');
|
||||
var body = document.getElementById('organizationMemberBody');
|
||||
if (tree) tree.innerHTML = '<div style="padding:25px 8px;text-align:center;color:#f5222d">' + escHtml(error.message) + '</div>';
|
||||
if (body) body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:50px;color:#f5222d">' + escHtml(error.message) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrganizationSummary() {
|
||||
var data = window._organizationData || {};
|
||||
var departments = data.departments || [], members = data.members || [], last = data.last_sync || {};
|
||||
var uniqueUsers = {};
|
||||
members.forEach(function(member) { uniqueUsers[member.userid] = member; });
|
||||
var matched = Object.values(uniqueUsers).filter(function(member) { return !!member.matched_dashboard_user_id; }).length;
|
||||
var cards = [
|
||||
['部门数量', Math.max(0, departments.length - 1), '#1677ff'],
|
||||
['人员数量', Object.keys(uniqueUsers).length, '#52c41a'],
|
||||
['已关联系统账号', matched, '#722ed1'],
|
||||
['最近同步', last.finished_at || '尚未同步', last.status === 'failed' ? '#f5222d' : '#fa8c16']
|
||||
];
|
||||
var summary = document.getElementById('organizationSummary');
|
||||
if (!summary) return;
|
||||
summary.innerHTML = cards.map(function(card) {
|
||||
return '<div class="card" style="margin:0;border-top:3px solid ' + card[2] + '"><div class="card-body" style="padding:14px 16px"><div style="font-size:12px;color:var(--text-light);margin-bottom:7px">' + card[0] + '</div><div style="font-size:' + (card[0] === '最近同步' ? '15px' : '24px') + ';font-weight:700;color:#333">' + escHtml(String(card[1])) + '</div></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function organizationDepartmentMap() {
|
||||
var map = {};
|
||||
((window._organizationData || {}).departments || []).forEach(function(dept) { map[String(dept.dept_id)] = dept; });
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderOrganizationDepartments() {
|
||||
var data = window._organizationData || {}, departments = data.departments || [], members = data.members || [];
|
||||
var tree = document.getElementById('organizationDeptTree');
|
||||
if (!tree) return;
|
||||
if (!departments.length) {
|
||||
tree.innerHTML = '<div style="padding:35px 8px;text-align:center;color:var(--text-light)">暂无组织架构数据<br><span style="font-size:12px">请点击右上角同步按钮</span></div>';
|
||||
return;
|
||||
}
|
||||
var children = {}, directCount = {};
|
||||
departments.forEach(function(dept) { (children[String(dept.parent_id)] = children[String(dept.parent_id)] || []).push(dept); });
|
||||
members.forEach(function(member) { directCount[String(member.dept_id)] = (directCount[String(member.dept_id)] || 0) + 1; });
|
||||
Object.keys(children).forEach(function(key) { children[key].sort(function(a,b) { return (a.sort_order || 0) - (b.sort_order || 0) || Number(a.dept_id) - Number(b.dept_id); }); });
|
||||
function branch(parentId, depth) {
|
||||
return (children[String(parentId)] || []).map(function(dept) {
|
||||
var selected = Number(window._organizationSelectedDept) === Number(dept.dept_id);
|
||||
var deptKey = String(dept.dept_id), hasChildren = !!(children[deptKey] && children[deptKey].length);
|
||||
var expanded = !!window._organizationExpandedDepts[deptKey];
|
||||
var toggle = hasChildren
|
||||
? '<span onclick="event.stopPropagation();toggleOrganizationDepartment(' + Number(dept.dept_id) + ')" title="' + (expanded ? '收起' : '展开') + '" style="width:18px;text-align:center;cursor:pointer;font-size:14px;user-select:none">' + (expanded ? '▾' : '▸') + '</span>'
|
||||
: '<span style="width:18px;text-align:center;color:#bbb;font-size:10px">●</span>';
|
||||
var childHtml = hasChildren && expanded ? branch(dept.dept_id, depth + 1) : '';
|
||||
return '<div class="organization-dept-node' + (selected ? ' selected' : '') + '" onclick="selectOrganizationDepartment(' + Number(dept.dept_id) + ')" style="padding:8px 8px 8px ' + (8 + depth * 18) + 'px;border-radius:6px;cursor:pointer;display:flex;align-items:center;gap:4px;margin:2px 0;background:' + (selected ? '#e6f4ff' : 'transparent') + ';color:' + (selected ? '#1677ff' : '#333') + '">' + toggle + '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(dept.name) + '</span><span style="font-size:11px;color:#999">' + (directCount[deptKey] || 0) + '</span></div>' + childHtml;
|
||||
}).join('');
|
||||
}
|
||||
var root = departments.find(function(dept) { return Number(dept.dept_id) === 1; });
|
||||
var rootExpanded = window._organizationExpandedDepts['1'] !== false;
|
||||
tree.innerHTML = '<div class="organization-dept-node" onclick="selectOrganizationDepartment(0)" style="padding:8px;border-radius:6px;cursor:pointer;display:flex;gap:4px;background:' + (Number(window._organizationSelectedDept) === 0 ? '#e6f4ff' : 'transparent') + ';color:' + (Number(window._organizationSelectedDept) === 0 ? '#1677ff' : '#333') + '"><span onclick="event.stopPropagation();toggleOrganizationDepartment(1)" title="' + (rootExpanded ? '收起' : '展开') + '" style="width:18px;text-align:center;cursor:pointer;user-select:none">' + (rootExpanded ? '▾' : '▸') + '</span><strong style="flex:1">' + escHtml(root ? root.name : '全部部门') + '</strong></div>' + (rootExpanded ? branch(1, 1) : '');
|
||||
}
|
||||
|
||||
function toggleOrganizationDepartment(deptId) {
|
||||
var key = String(Number(deptId));
|
||||
window._organizationExpandedDepts[key] = !window._organizationExpandedDepts[key];
|
||||
renderOrganizationDepartments();
|
||||
}
|
||||
|
||||
function expandAllOrganizationDepartments() {
|
||||
(((window._organizationData || {}).departments) || []).forEach(function(dept) {
|
||||
window._organizationExpandedDepts[String(dept.dept_id)] = true;
|
||||
});
|
||||
renderOrganizationDepartments();
|
||||
}
|
||||
|
||||
function collapseAllOrganizationDepartments() {
|
||||
window._organizationExpandedDepts = {'1': false};
|
||||
renderOrganizationDepartments();
|
||||
}
|
||||
|
||||
function organizationDescendantIds(deptId) {
|
||||
var departments = ((window._organizationData || {}).departments || []), result = {}, queue = [Number(deptId)];
|
||||
while (queue.length) {
|
||||
var current = queue.shift(); result[current] = true;
|
||||
departments.forEach(function(dept) { if (Number(dept.parent_id) === current && !result[Number(dept.dept_id)]) queue.push(Number(dept.dept_id)); });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function selectOrganizationDepartment(deptId) {
|
||||
window._organizationSelectedDept = Number(deptId) || 0;
|
||||
window._organizationMemberPage = 1;
|
||||
renderOrganizationDepartments();
|
||||
renderOrganizationMembers();
|
||||
}
|
||||
|
||||
function renderOrganizationMembers() {
|
||||
var data = window._organizationData || {}, members = data.members || [], deptMap = organizationDepartmentMap();
|
||||
var selected = Number(window._organizationSelectedDept) || 0;
|
||||
var allowed = selected ? organizationDescendantIds(selected) : null;
|
||||
var keyword = (document.getElementById('organizationSearch')?.value || '').trim().toLowerCase();
|
||||
var users = {};
|
||||
members.forEach(function(member) {
|
||||
if (allowed && !allowed[Number(member.dept_id)]) return;
|
||||
var item = users[member.userid] || Object.assign({}, member, {department_names:[]});
|
||||
var dept = deptMap[String(member.dept_id)];
|
||||
if (dept && item.department_names.indexOf(dept.name) < 0) item.department_names.push(dept.name);
|
||||
users[member.userid] = item;
|
||||
});
|
||||
var rows = Object.values(users).filter(function(member) {
|
||||
if (!keyword) return true;
|
||||
return [member.name, member.mobile, member.title, member.job_number, (member.department_names || []).join(' '), member.matched_real_name, member.matched_username].join(' ').toLowerCase().indexOf(keyword) >= 0;
|
||||
}).sort(function(a,b) { return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); });
|
||||
var pageSize = window._organizationMemberPageSize || 20;
|
||||
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
var currentPage = Math.min(Math.max(1, Number(window._organizationMemberPage) || 1), totalPages);
|
||||
window._organizationMemberPage = currentPage;
|
||||
var pageRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
var title = document.getElementById('organizationMemberTitle');
|
||||
if (title) title.textContent = (selected && deptMap[String(selected)] ? deptMap[String(selected)].name : '全部人员') + '(' + rows.length + '人)';
|
||||
var body = document.getElementById('organizationMemberBody');
|
||||
if (!body) return;
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:50px;color:var(--text-light)">暂无匹配人员</td></tr>';
|
||||
renderOrganizationPagination(0, 1, 1);
|
||||
return;
|
||||
}
|
||||
body.innerHTML = pageRows.map(function(member) {
|
||||
var matched = member.matched_dashboard_user_id ? escHtml((member.matched_real_name || member.matched_username || '') + (member.matched_username ? '(' + member.matched_username + ')' : '')) : '<span style="color:#fa8c16">未关联</span>';
|
||||
return '<tr><td style="font-weight:600">' + escHtml(member.name || '-') + '</td><td>' + escHtml((member.department_names || []).join(' / ') || '-') + '</td><td>' + escHtml(member.title || '-') + '</td><td>' + escHtml(member.mobile || '-') + '</td><td>' + escHtml(member.job_number || '-') + '</td><td>' + matched + '</td><td><span class="tag tag-green">在职</span></td></tr>';
|
||||
}).join('');
|
||||
renderOrganizationPagination(rows.length, currentPage, totalPages);
|
||||
}
|
||||
|
||||
function renderOrganizationPagination(total, currentPage, totalPages) {
|
||||
var container = document.getElementById('organizationPagination');
|
||||
if (!container) return;
|
||||
if (!total) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var start = (currentPage - 1) * 20 + 1;
|
||||
var end = Math.min(total, currentPage * 20);
|
||||
var buttons = '';
|
||||
var first = Math.max(1, currentPage - 2), last = Math.min(totalPages, first + 4);
|
||||
first = Math.max(1, last - 4);
|
||||
for (var page = first; page <= last; page++) {
|
||||
buttons += '<button class="btn btn-sm' + (page === currentPage ? ' btn-primary' : '') + '" style="min-width:32px" onclick="goOrganizationMemberPage(' + page + ')">' + page + '</button>';
|
||||
}
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap">' +
|
||||
'<span style="font-size:12px;color:var(--text-light);margin-right:8px">共 ' + total + ' 人,当前 ' + start + '-' + end + '</span>' +
|
||||
'<button class="btn btn-sm" onclick="goOrganizationMemberPage(' + (currentPage - 1) + ')"' + (currentPage <= 1 ? ' disabled' : '') + '>上一页</button>' +
|
||||
buttons +
|
||||
'<button class="btn btn-sm" onclick="goOrganizationMemberPage(' + (currentPage + 1) + ')"' + (currentPage >= totalPages ? ' disabled' : '') + '>下一页</button>' +
|
||||
'<span style="font-size:12px;color:var(--text-light);margin-left:8px">第 ' + currentPage + '/' + totalPages + ' 页</span>' +
|
||||
'<input id="organizationPageJump" type="number" min="1" max="' + totalPages + '" class="form-input" style="width:64px;height:30px" placeholder="页码" onkeydown="if(event.key===\'Enter\')jumpOrganizationMemberPage()">' +
|
||||
'<button class="btn btn-sm" onclick="jumpOrganizationMemberPage()">跳转</button></div>';
|
||||
}
|
||||
|
||||
function goOrganizationMemberPage(page) {
|
||||
window._organizationMemberPage = Math.max(1, Number(page) || 1);
|
||||
renderOrganizationMembers();
|
||||
}
|
||||
|
||||
function jumpOrganizationMemberPage() {
|
||||
var input = document.getElementById('organizationPageJump');
|
||||
goOrganizationMemberPage(input ? input.value : 1);
|
||||
}
|
||||
|
||||
function searchOrganizationMembers() {
|
||||
window._organizationMemberPage = 1;
|
||||
renderOrganizationMembers();
|
||||
}
|
||||
|
||||
function resetOrganizationSearch() {
|
||||
var input = document.getElementById('organizationSearch');
|
||||
if (input) input.value = '';
|
||||
window._organizationMemberPage = 1;
|
||||
renderOrganizationMembers();
|
||||
}
|
||||
|
||||
function syncDingtalkOrganization() {
|
||||
var button = document.getElementById('organizationSyncBtn');
|
||||
if (button) { button.disabled = true; button.textContent = '同步中...'; }
|
||||
fetch('/api/organization/sync', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:'{}'})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(res) {
|
||||
if (res.code !== 0) throw new Error(res.msg || '同步失败');
|
||||
alert('同步成功:部门 ' + res.data.department_count + ' 个,人员 ' + res.data.user_count + ' 人,已关联系统账号 ' + res.data.matched_count + ' 人');
|
||||
fetchOrganizationData();
|
||||
})
|
||||
.catch(function(error) { alert('同步失败:' + error.message); })
|
||||
.finally(function() { if (button) { button.disabled = false; button.textContent = '同步钉钉组织架构'; } });
|
||||
}
|
||||
|
||||
// ─── 用户管理 ───
|
||||
function downloadUserTemplate() {
|
||||
fetch('/api/users/template', {credentials: 'same-origin'})
|
||||
@ -14101,6 +14398,7 @@ window._permPageFields = {
|
||||
// 知识库
|
||||
'knowledge': ['标题','主题分类','创建人','创建时间','更新时间','状态'],
|
||||
// 系统设置
|
||||
'organization': [],
|
||||
'users': ['用户名','姓名','角色','岗位','用户级别','手机号','邮箱','状态','最后登录'],
|
||||
'permissions': ['用户名','姓名','角色','岗位'],
|
||||
'login_records': ['用户名','登录时间','IP地址'],
|
||||
@ -14329,6 +14627,7 @@ const PLAN_COLUMNS = [
|
||||
{field: '新旧品', label: '新旧品', defaultShow: true},
|
||||
{field: 'bom', label: '下单清单', defaultShow: true, filterable: false},
|
||||
{field: '产品分级', label: '产品分级', defaultShow: true},
|
||||
{field: '在途进度', label: '在途进度', defaultShow: true},
|
||||
{field: '昨日销量', label: '昨日销量', defaultShow: true},
|
||||
{field: '云仓昨日销量', label: '云仓昨日销量', defaultShow: false},
|
||||
{field: '外仓昨日销量', label: '外仓昨日销量', defaultShow: false},
|
||||
@ -14425,7 +14724,7 @@ const PLAN_FILTER_OPTIONS = {
|
||||
|
||||
// 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询)
|
||||
const PLAN_FILTER_EXCLUDE_FIELDS = {
|
||||
'产品分级':1, '昨日销量':1, '云仓昨日销量':1, '外仓昨日销量':1, '销售趋势':1, '库存':1, '云仓库存':1, '外仓库存':1, '加工厂库存':1,
|
||||
'产品分级':1, '在途进度':1, '昨日销量':1, '云仓昨日销量':1, '外仓昨日销量':1, '销售趋势':1, '库存':1, '云仓库存':1, '外仓库存':1, '加工厂库存':1,
|
||||
'云仓可分配库存':1, '外仓可分配库存':1, '是否调拨':1,
|
||||
'生产在途':1, '采购在途':1, '日销量预测':1, '云仓日销量预测':1, '外仓日销量预测':1, '库存健康指数':1, '风险把控':1,
|
||||
'库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1,
|
||||
@ -15185,7 +15484,7 @@ function renderPlanTable(rows) {
|
||||
var pcode = (r.product_code || '');
|
||||
var pname = r.product_name || '';
|
||||
var suggQty = r['建议下单数量'] || 0;
|
||||
var actQty = r['实际下单数量'] || 0;
|
||||
var actQty = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : (r['实际下单数量'] || 0);
|
||||
var remark = r['下单备注'] || '';
|
||||
return '<button class="btn btn-sm btn-outline" onclick="planToggleBom(' + inlineJsString(pcode) + ',' + inlineJsString(pname) + ',' + suggQty + ',' + actQty + ',' + inlineJsString(remark) + ')" title="查看下单信息" style="font-size:12px;padding:2px 10px">📋 下单信息</button>';
|
||||
}
|
||||
@ -15314,7 +15613,8 @@ function renderPlanTable(rows) {
|
||||
return '<span style="font-weight:600;color:#d4380d">' + val + '</span>';
|
||||
}
|
||||
if (c.field === '正式下单数量') {
|
||||
var currentVal = parseInt(r['实际下单数量']) || 0;
|
||||
var savedFormalQty = r['正式下单数量'];
|
||||
var currentVal = parseInt(savedFormalQty !== undefined && savedFormalQty !== null ? savedFormalQty : r['实际下单数量']) || 0;
|
||||
var code = r.product_code || '';
|
||||
var codeHtml = escHtml(code);
|
||||
var canEditFormal = window._canEditField && window._canEditField('planning', '正式下单数量');
|
||||
@ -15361,7 +15661,8 @@ function renderPlanTable(rows) {
|
||||
}).join('');
|
||||
var code = r.product_code || '';
|
||||
// 序列化下单所需数据
|
||||
var formalQty = parseInt(r['实际下单数量']) || 0;
|
||||
var formalQtyValue = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : r['实际下单数量'];
|
||||
var formalQty = parseInt(formalQtyValue) || 0;
|
||||
var suggestedQty = parseInt(r['建议下单数量']) || 0;
|
||||
var orderData = JSON.stringify({
|
||||
product_code: r.product_code || '',
|
||||
@ -18953,7 +19254,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
|
||||
if (m.material_code === '成品_' + productCode) {
|
||||
// 修复:允许恢复为0(用户手动设0表示不下单)
|
||||
if (m.actual_quantity >= 0) actualQty = m.actual_quantity;
|
||||
if (m.remark) orderRemark = m.remark;
|
||||
if (m.remark !== undefined && m.remark !== null) orderRemark = m.remark;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -18965,6 +19266,23 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
|
||||
alert('❌ 获取物料清单失败');
|
||||
return;
|
||||
}
|
||||
var savedOrder = res.data.pending_order;
|
||||
if (savedOrder && (!window._pendingBomData || !window._pendingBomData[productCode])) {
|
||||
var savedItems = savedOrder.bom_items;
|
||||
if (typeof savedItems === 'string') {
|
||||
try { savedItems = JSON.parse(savedItems); } catch (e) { savedItems = []; }
|
||||
}
|
||||
if (Array.isArray(savedItems)) {
|
||||
if (!window._pendingBomData) window._pendingBomData = {};
|
||||
window._pendingBomData[productCode] = savedItems;
|
||||
}
|
||||
if (savedOrder.formal_order_qty !== undefined && savedOrder.formal_order_qty !== null) {
|
||||
actualQty = parseInt(savedOrder.formal_order_qty, 10) || 0;
|
||||
}
|
||||
if (savedOrder.remark !== undefined && savedOrder.remark !== null) {
|
||||
orderRemark = savedOrder.remark;
|
||||
}
|
||||
}
|
||||
// 没有绑定包材时仍显示成品下单信息,包材区域由下方统一展示暂无数据。
|
||||
var bom = res.data.bom_materials || [];
|
||||
// 获取正式下单数量(从计划预测页面元素获取)
|
||||
@ -20786,7 +21104,10 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
});
|
||||
|
||||
var html = '<div style="padding:24px 24px 8px">';
|
||||
html += '<h3 style="margin-bottom:16px;font-size:17px">📋 下单详情 - ' + escHtml(code) + '</h3>';
|
||||
html += '<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px">';
|
||||
html += '<h3 style="margin:0;font-size:17px">📋 下单详情 - ' + escHtml(code) + '</h3>';
|
||||
html += '<button type="button" aria-label="关闭下单详情" title="关闭" onclick="this.closest(\'.modal-overlay\').remove()" style="width:32px;height:32px;flex:0 0 32px;display:flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:transparent;color:#8c8c8c;font-size:24px;line-height:1;cursor:pointer" onmouseenter="this.style.background=\'#f5f5f5\';this.style.color=\'#262626\'" onmouseleave="this.style.background=\'transparent\';this.style.color=\'#8c8c8c\'">×</button>';
|
||||
html += '</div>';
|
||||
|
||||
// ─── 板块1:成品信息 ───
|
||||
html += '<h4 style="font-size:14px;color:#333;margin:0 0 10px 0;padding:8px 12px;background:#e6f7ff;border-radius:6px">📦 成品信息</h4>';
|
||||
@ -20796,6 +21117,7 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
['货品编号', item.product_code],
|
||||
['货品名称', item.product_name],
|
||||
['供应商名称', item.supplier_name],
|
||||
['货期', (item.lead_time === null || item.lead_time === undefined || item.lead_time === '') ? '-' : item.lead_time + '天'],
|
||||
['采购计划', item.procurement_planner],
|
||||
['采购跟单', item.procurement_follower],
|
||||
['成品下单人', item.product_orderer]
|
||||
@ -20832,15 +21154,17 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
}
|
||||
html += '</table></div>';
|
||||
|
||||
// ─── 板块2:销量信息(与计划预测详情默认数据完全同源)───
|
||||
// ─── 板块2:销量信息(新订单与计划预测详情云仓数据完全同源)───
|
||||
var salesInfo = item.sales_info || {};
|
||||
var salesSnapshotScope = item.sales_snapshot_scope || 'default';
|
||||
var salesLabelPrefix = salesSnapshotScope === 'cloud' ? '云仓' : '';
|
||||
var salesInfoFields = [
|
||||
['产品分级', '产品分级'], ['昨日销量', '昨日销量'], ['销售趋势', '销售趋势'],
|
||||
['库存', '库存'], ['加工厂库存', '加工厂库存'], ['生产在途', '生产在途'], ['采购在途', '采购在途'],
|
||||
['日销量预测', '日销量预测'], ['3天平均销量', '3天平均销量'], ['7天平均销量', '7天平均销量'],
|
||||
['14天平均销量', '14天平均销量'], ['库存可支撑天数', '库存可支撑天数'],
|
||||
['加工厂库存支撑天数', '加工厂库存支撑天数'], ['总支撑天数', '总支撑天数'],
|
||||
['30天平均销量', '30天平均销量'], ['极值判断', '极值判断']
|
||||
['产品分级', salesLabelPrefix + '产品分级'], ['昨日销量', salesLabelPrefix + '昨日销量'], ['销售趋势', salesLabelPrefix + '销售趋势'],
|
||||
['库存', salesLabelPrefix + '库存'], ['加工厂库存', salesLabelPrefix + '加工厂库存'], ['生产在途', salesLabelPrefix + '生产在途'], ['采购在途', salesLabelPrefix + '采购在途'],
|
||||
['日销量预测', salesLabelPrefix + '日销量预测'], ['3天平均销量', salesLabelPrefix + '3天平均销量'], ['7天平均销量', salesLabelPrefix + '7天平均销量'],
|
||||
['14天平均销量', salesLabelPrefix + '14天平均销量'], ['库存可支撑天数', salesLabelPrefix + '库存可支撑天数'],
|
||||
['加工厂库存支撑天数', salesLabelPrefix + '加工厂库存支撑天数'], ['总支撑天数', salesLabelPrefix + '总支撑天数'],
|
||||
['30天平均销量', salesLabelPrefix + '30天平均销量'], ['极值判断', salesLabelPrefix + '极值判断']
|
||||
];
|
||||
html += '<div class="pd-card" style="margin-bottom:16px">';
|
||||
html += '<div class="pd-card-header">' +
|
||||
@ -20849,7 +21173,7 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
'</svg><span>销量信息</span></div>';
|
||||
if (Object.keys(salesInfo).length) {
|
||||
html += '<div class="pd-card-body">';
|
||||
html += '<div style="font-size:12px;color:#8c8c8c;background:#fafafa;border:1px solid #f0f0f0;border-radius:6px;padding:7px 10px;margin-bottom:10px">计划预测点击下单时的默认销量数据快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。</div>';
|
||||
html += '<div style="font-size:12px;color:#8c8c8c;background:#fafafa;border:1px solid #f0f0f0;border-radius:6px;padding:7px 10px;margin-bottom:10px">计划预测点击下单时的' + (salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)') + '快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。</div>';
|
||||
html += '<div class="pd-fields pd-fields-4col">';
|
||||
salesInfoFields.forEach(function(field) {
|
||||
var value = salesInfo[field[0]];
|
||||
@ -20869,12 +21193,13 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
html += '<h4 style="font-size:14px;color:#333;margin:12px 0 10px 0;padding:8px 12px;background:#fff7e6;border-radius:6px">📦 包材信息</h4>';
|
||||
if (bomList.length > 0) {
|
||||
html += '<div style="border:1px solid var(--border);border-radius:8px;overflow-x:auto">';
|
||||
html += '<table style="width:100%;min-width:1600px;border-collapse:collapse;font-size:13px;white-space:nowrap">';
|
||||
html += '<table style="width:100%;min-width:1740px;border-collapse:collapse;font-size:13px;white-space:nowrap">';
|
||||
html += '<thead><tr style="background:#555;color:#fff">';
|
||||
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料编号</th>';
|
||||
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料名称</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:76px;min-width:76px;white-space:nowrap">下单数量</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:88px;min-width:88px;white-space:nowrap">剩余包材</th>';
|
||||
html += '<th style="padding:8px 8px;border:1px solid #666;text-align:left;width:140px;min-width:140px;white-space:nowrap">下单进度</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">包材下单人</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">供应商</th>';
|
||||
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">单价</th>';
|
||||
@ -20897,6 +21222,7 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
html += '<td style="padding:8px 12px;border:1px solid #eee">' + escHtml(b.material_name || '-') + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:76px;min-width:76px;text-align:center;white-space:nowrap">' + qty + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:88px;min-width:88px;text-align:center;white-space:nowrap;font-weight:600;color:' + (remainingPack < 0 ? '#cf1322' : '#333') + '">' + remainingPack.toLocaleString('zh-CN', {maximumFractionDigits: 2}) + '</td>';
|
||||
html += '<td style="padding:8px 8px;border:1px solid #eee;width:140px;min-width:140px;text-align:left;white-space:pre-wrap;word-break:break-word">' + escHtml(b.order_progress || '-') + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(b.packaging_orderer || '-') + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(supplierShortName) + '</td>';
|
||||
html += '<td style="padding:8px 12px;border:1px solid #eee">' + unitPrice.toFixed(4) + '</td>';
|
||||
@ -21499,7 +21825,6 @@ function showOrderDetail(encodedStr, title) {
|
||||
{label:'货品名称', field:'名称', editable:false},
|
||||
{label:'到货仓库', field:'仓库到货', editable:true, type:'supplier'},
|
||||
{label:'实际下单数量', field:'数量', editable:true},
|
||||
{label:'下单备注', field:'下单备注', editable:true},
|
||||
{label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'},
|
||||
{label:'跟单下单日期', field:'日期', editable:true, type:'date'},
|
||||
{label:'是否更换稿件', field:'有无新稿件', editable:true, type:'select', options:['是','否']},
|
||||
@ -21658,6 +21983,15 @@ function showOrderDetail(encodedStr, title) {
|
||||
html += '</div>';
|
||||
html += '</div></div>';
|
||||
|
||||
// 三类订单的下单备注取自外层列表同一行,整行展示并放在进度上方。
|
||||
// 使用与进度相同的文本域样式,保存时仍走各订单现有更新接口。
|
||||
if (!isFinishedDetail) {
|
||||
var orderRemarkVal = row['下单备注'];
|
||||
if (orderRemarkVal === null || orderRemarkVal === undefined) orderRemarkVal = '';
|
||||
var remarkEditable = window._hasPagePerm(detailPermissionPage, 'edit');
|
||||
html += '<div style="margin-top:6px"><div class="detail-pair" style="min-height:70px"><span class="detail-lbl" style="min-height:68px">下单备注</span><div class="detail-val-wrap" style="padding:4px 12px"><textarea class="detail-textarea" data-orig="' + escHtml(String(orderRemarkVal)) + '" data-id="' + row.id + '" data-field="下单备注"' + (remarkEditable ? '' : ' readonly') + ' style="width:100%;height:60px;box-sizing:border-box;font-size:13px;border:1px solid #d9d9d9;border-radius:4px;padding:6px 8px;color:#333;background:' + (remarkEditable ? '#fff' : '#f5f5f5') + ';resize:vertical;font-family:inherit">' + escHtml(String(orderRemarkVal)) + '</textarea></div></div></div>';
|
||||
}
|
||||
|
||||
// 成品提货的进度字段整行显示,数据来自国内订单中最早的未完结订单,只读。
|
||||
if (isFinishedDetail) {
|
||||
var finishedProgress = row['产品进度'] || row['进度'] || '';
|
||||
@ -21685,7 +22019,7 @@ function showOrderDetail(encodedStr, title) {
|
||||
html += '<div style="display:flex;align-items:center;margin:0 0 8px 0">';
|
||||
html += '<div style="margin-left:auto;display:flex;align-items:center;gap:6px"><button id="addMultiBatchBtn" class="btn btn-sm btn-outline" onclick="promptAddArrivalBatchRows(this)" data-order-id="' + row.id + '" data-order-type="' + orderType + '" style="font-size:12px">➕ 添加多行</button><button id="addBatchBtn" class="btn btn-sm btn-outline" onclick="addArrivalBatchRow(this)" data-order-id="' + row.id + '" data-order-type="' + orderType + '" style="font-size:12px">➕ 添加一行</button></div>';
|
||||
html += '</div>';
|
||||
var showDefectiveQty = orderType === 'packaging';
|
||||
var showDefectiveQty = true;
|
||||
html += '<table id="arrivalBatchTable" style="width:100%;border-collapse:collapse;font-size:13px;table-layout:auto" data-order-qty="' + (parseInt(row['数量']) || 0) + '"><thead><tr>';
|
||||
html += '<th style="border:1.5px solid #b0b0b0;padding:6px 8px;background:#f5f5f5;color:#555;font-weight:600;text-align:center;white-space:nowrap;width:1%">批次编号</th>';
|
||||
html += '<th style="border:1.5px solid #b0b0b0;padding:6px 8px;background:#f5f5f5;color:#555;font-weight:600;text-align:center;white-space:nowrap;width:1%">操作人</th>';
|
||||
@ -21789,7 +22123,7 @@ function showOrderDetail(encodedStr, title) {
|
||||
.then(function(res) {
|
||||
if (res.code !== 0) {
|
||||
var abBody = overlay.querySelector('#arrivalBatchBody');
|
||||
if (abBody) abBody.innerHTML = '<tr><td colspan="' + (orderType === 'packaging' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>';
|
||||
if (abBody) abBody.innerHTML = '<tr><td colspan="8" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>';
|
||||
var pmBody = overlay.querySelector('#packagingMaterialBody');
|
||||
if (pmBody) pmBody.innerHTML = '<tr><td colspan="7" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>';
|
||||
return;
|
||||
@ -21802,12 +22136,16 @@ function showOrderDetail(encodedStr, title) {
|
||||
var abHtml = '';
|
||||
var totalQty = 0;
|
||||
var totalDefectiveQty = 0;
|
||||
var showDefectiveQty = orderType === 'packaging';
|
||||
var showDefectiveQty = true;
|
||||
var batchCount = data.arrival_batches ? data.arrival_batches.length : 0;
|
||||
// 数据行
|
||||
data.arrival_batches.forEach(function(b, idx) {
|
||||
var qty = parseInt(b.arrival_qty) || 0;
|
||||
var defectiveQty = parseInt(b.defective_qty) || 0;
|
||||
var dateFilled = Number(b.arrival_date_filled) === 1;
|
||||
var qtyFilled = Number(b.arrival_qty_filled) === 1;
|
||||
var defectiveFilled = Number(b.defective_qty_filled) === 1;
|
||||
var allowBlankFill = orderType === 'packaging';
|
||||
totalQty += qty - defectiveQty;
|
||||
totalDefectiveQty += defectiveQty;
|
||||
var dateVal = (b.arrival_date || '').substring(0, 10);
|
||||
@ -21816,9 +22154,9 @@ function showOrderDetail(encodedStr, title) {
|
||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + (idx + 1) + '</td>';
|
||||
abHtml += '<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operator_name || '-') + '</td>';
|
||||
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operated_at || '-') + '</td>';
|
||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-date" value="' + dateVal + '" data-orig="' + dateVal + '"><span>' + escHtml(dateVal || '-') + '</span></td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' +
|
||||
(showDefectiveQty ? '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-defective-qty" value="' + defectiveQty + '" data-orig="' + defectiveQty + '"><span>' + defectiveQty + '</span></td>' : '') +
|
||||
abHtml += renderArrivalBatchStoredCell('date', 'batch-date', dateVal, dateFilled, allowBlankFill, '到货日期') +
|
||||
renderArrivalBatchStoredCell('number', 'batch-qty', qtyFilled ? qty : '', qtyFilled, allowBlankFill, '到货数量', true) +
|
||||
renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveFilled ? defectiveQty : '', defectiveFilled, true, '不良品数量', false) +
|
||||
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
|
||||
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(b.remark || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(b.remark || '') + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
|
||||
@ -21835,7 +22173,7 @@ function showOrderDetail(encodedStr, title) {
|
||||
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>';
|
||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;color:#999;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" placeholder="数量" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||
if (showDefectiveQty) abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-defective-qty" placeholder="不良品" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" class="batch-defective-qty" placeholder="不良品" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||
abHtml += '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;color:#ccc">-</td>';
|
||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" placeholder="备注" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveNewArrivalBatch(this,' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button></div></td>';
|
||||
abHtml += '</tr>';
|
||||
@ -21872,13 +22210,23 @@ function showOrderDetail(encodedStr, title) {
|
||||
.catch(function(err) {
|
||||
console.error('加载详情额外数据失败:', err);
|
||||
var abBody = overlay.querySelector('#arrivalBatchBody');
|
||||
if (abBody) abBody.innerHTML = '<tr><td colspan="' + (orderType === 'packaging' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>';
|
||||
if (abBody) abBody.innerHTML = '<tr><td colspan="8" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>';
|
||||
var pmBody = overlay.querySelector('#packagingMaterialBody');
|
||||
if (pmBody) pmBody.innerHTML = '<tr><td colspan="' + (orderType === 'domestic' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>';
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function renderArrivalBatchStoredCell(type, className, value, filled, allowBlankFill, placeholder, nonNegative) {
|
||||
var style = 'border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap';
|
||||
var safeValue = value === null || value === undefined ? '' : String(value);
|
||||
if (allowBlankFill && !filled) {
|
||||
return '<td style="' + style + '"><input type="' + type + '" class="' + className + '" value="" data-orig="" placeholder="' + placeholder + '"' +
|
||||
(nonNegative ? ' min="0"' : '') + ' oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:80%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #1890ff;border-radius:3px;padding:4px 6px;background:#fffbe6"></td>';
|
||||
}
|
||||
return '<td style="' + style + '"><input type="hidden" class="' + className + '" value="' + escHtml(safeValue) + '" data-orig="' + escHtml(safeValue) + '"><span>' + escHtml(safeValue || '-') + '</span></td>';
|
||||
}
|
||||
|
||||
function appendArrivalBatchEditingRow(btn) {
|
||||
var overlay = btn.closest('.modal-overlay');
|
||||
if (!overlay) {
|
||||
@ -21912,7 +22260,7 @@ function appendArrivalBatchEditingRow(btn) {
|
||||
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="数量"></td>' +
|
||||
(orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-defective-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="不良品"></td>' : '') +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" class="batch-defective-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="不良品"></td>' +
|
||||
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:4px 6px"></td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
|
||||
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical" placeholder="备注"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
|
||||
@ -22010,11 +22358,14 @@ function saveNewArrivalBatch(btn, orderId, orderType) {
|
||||
var tr = btn.closest('tr');
|
||||
if (!tr) return;
|
||||
var date = tr.querySelector('.batch-date')?.value || '';
|
||||
var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
|
||||
var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
|
||||
var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
|
||||
var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
|
||||
var qty = qtyRaw === '' ? '' : parseInt(qtyRaw, 10);
|
||||
var defectiveQty = defectiveRaw === '' ? '' : parseInt(defectiveRaw, 10);
|
||||
var remark = tr.querySelector('.batch-remark')?.value || '';
|
||||
if (!date && !qty) { alert('请输入到货日期或数量'); return; }
|
||||
if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; }
|
||||
if (!date && qtyRaw === '') { alert('请输入到货日期或数量'); return; }
|
||||
if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; }
|
||||
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⋯';
|
||||
fetch('/api/domestic/orders/add_batch', {
|
||||
@ -22033,9 +22384,9 @@ function saveNewArrivalBatch(btn, orderId, orderType) {
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + batchNum + '</td>' +
|
||||
'<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operator_name || '-') + '</td>' +
|
||||
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operated_at || '-') + '</td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-date" value="' + date + '" data-orig="' + date + '"><span>' + escHtml(date || '-') + '</span></td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' +
|
||||
(orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-defective-qty" value="' + defectiveQty + '" data-orig="' + defectiveQty + '"><span>' + defectiveQty + '</span></td>' : '') +
|
||||
renderArrivalBatchStoredCell('date', 'batch-date', date, !!(res.data && res.data.arrival_date_filled), orderType === 'packaging', '到货日期') +
|
||||
renderArrivalBatchStoredCell('number', 'batch-qty', qty, !!(res.data && res.data.arrival_qty_filled), orderType === 'packaging', '到货数量', true) +
|
||||
renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, !!(res.data && res.data.defective_qty_filled), true, '不良品数量', false) +
|
||||
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
|
||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(remark) + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(remark) + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveArrivalBatch(this,' + batchId + ',' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button><button onclick="deleteArrivalBatch(this,' + batchId + ',\'' + orderType + '\')" title="删除" style="padding:2px 6px;font-size:11px;border:1px solid #ff4d4f;background:#fff1f0;color:#ff4d4f;border-radius:3px;cursor:pointer;margin-left:2px;white-space:nowrap">✕</button></div></td>';
|
||||
recalcArrivalUnfinished();
|
||||
@ -22058,10 +22409,13 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) {
|
||||
var tr = btn.closest('tr');
|
||||
if (!tr) return;
|
||||
var date = tr.querySelector('.batch-date')?.value || '';
|
||||
var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
|
||||
var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
|
||||
var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
|
||||
var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
|
||||
var qty = qtyRaw === '' ? '' : parseInt(qtyRaw, 10);
|
||||
var defectiveQty = defectiveRaw === '' ? '' : parseInt(defectiveRaw, 10);
|
||||
var remark = tr.querySelector('.batch-remark')?.value || '';
|
||||
if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; }
|
||||
if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; }
|
||||
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⋯';
|
||||
fetch('/api/domestic/orders/update_batch', {
|
||||
@ -22087,6 +22441,14 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) {
|
||||
var opt = tr.querySelector('.batch-operated-at');
|
||||
if (op) op.textContent = (res.data && res.data.operator_name) || '-';
|
||||
if (opt) opt.textContent = (res.data && res.data.operated_at) || '-';
|
||||
if (orderType === 'packaging') {
|
||||
var dateCell = dd && dd.closest('td');
|
||||
var qtyCell = dq && dq.closest('td');
|
||||
if (dateCell && res.data && res.data.arrival_date_filled) dateCell.outerHTML = renderArrivalBatchStoredCell('date', 'batch-date', date, true, false, '到货日期');
|
||||
if (qtyCell && res.data && res.data.arrival_qty_filled) qtyCell.outerHTML = renderArrivalBatchStoredCell('number', 'batch-qty', qty, true, false, '到货数量', true);
|
||||
}
|
||||
var defectiveCell = df && df.closest('td');
|
||||
if (defectiveCell && res.data && res.data.defective_qty_filled) defectiveCell.outerHTML = renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, true, false, '不良品数量', false);
|
||||
recalcArrivalUnfinished();
|
||||
refreshOrderListFromArrivalModal(btn);
|
||||
setTimeout(function() { btn.textContent = '✅'; }, 500);
|
||||
@ -22242,10 +22604,12 @@ function saveDetailChanges(btn) {
|
||||
if (tr.classList.contains('batch-placeholder') || tr.classList.contains('batch-editing')) {
|
||||
// 新增行:有日期或数量才保存
|
||||
var d = tr.querySelector('.batch-date')?.value || '';
|
||||
var q = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
|
||||
var f = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
|
||||
var qRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
|
||||
var fRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
|
||||
var q = qRaw === '' ? '' : parseInt(qRaw, 10);
|
||||
var f = fRaw === '' ? '' : parseInt(fRaw, 10);
|
||||
var r = tr.querySelector('.batch-remark')?.value || '';
|
||||
if (d || q || f) {
|
||||
if (d || qRaw !== '' || fRaw !== '') {
|
||||
batchAdds.push({arrival_date: d, arrival_qty: q, defective_qty: f, remark: r});
|
||||
}
|
||||
} else {
|
||||
@ -22260,16 +22624,18 @@ function saveDetailChanges(btn) {
|
||||
var of = tr.querySelector('.batch-defective-qty')?.getAttribute('data-orig') || '';
|
||||
var or_ = tr.querySelector('.batch-remark')?.getAttribute('data-orig') || '';
|
||||
if (String(d) !== String(od) || String(q) !== String(oq) || String(f) !== String(of) || String(r) !== String(or_)) {
|
||||
batchUpdates.push({id: bid, arrival_date: d, arrival_qty: parseInt(q) || 0, defective_qty: parseInt(f) || 0, remark: r});
|
||||
batchUpdates.push({id: bid, arrival_date: d, arrival_qty: q === '' ? '' : parseInt(q, 10), defective_qty: f === '' ? '' : parseInt(f, 10), remark: r});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) {
|
||||
return b.defective_qty < 0 || b.defective_qty > b.arrival_qty;
|
||||
if (b.arrival_qty !== '' && (!Number.isInteger(b.arrival_qty) || b.arrival_qty < 0)) return true;
|
||||
if (b.defective_qty !== '' && !Number.isInteger(b.defective_qty)) return true;
|
||||
return false;
|
||||
});
|
||||
if (invalidBatch) {
|
||||
showCenteredAlert('提示', '不良品数量不能为负数或大于到货数量');
|
||||
showCenteredAlert('提示', '到货数量必须为非负整数,不良品数量必须为整数');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -28701,7 +29067,7 @@ window.exportBom = function() {
|
||||
|
||||
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
|
||||
var POPUP_VISIBLE_FIELDS = [
|
||||
'product_type', '产品种类', 'product_code', 'product_name', '新旧品', '产品分级',
|
||||
'product_type', '产品种类', 'product_code', 'product_name', '新旧品', '产品分级', '在途进度',
|
||||
'昨日销量', '销售趋势',
|
||||
'库存', '加工厂库存', '生产在途', '采购在途',
|
||||
'日销量预测',
|
||||
@ -28722,6 +29088,7 @@ var POPUP_FIELD_LABELS = {
|
||||
'product_code': '货品编号',
|
||||
'product_name': '货品名称',
|
||||
'产品分级': '产品分级',
|
||||
'在途进度': '在途进度',
|
||||
'brand': '品牌',
|
||||
'specification': '规格',
|
||||
'cost_price': '成本价',
|
||||
@ -29196,7 +29563,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) {
|
||||
window._pendingBomData[data.product_code].forEach(function(m) {
|
||||
if (m.material_code === '成品_' + data.product_code) {
|
||||
if (m.actual_quantity >= 0) savedProductQty = m.actual_quantity;
|
||||
if (m.remark) savedRemark = m.remark;
|
||||
if (m.remark !== undefined && m.remark !== null) savedRemark = m.remark;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -29397,10 +29764,11 @@ window.showProductDetail = function(productCode) {
|
||||
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
|
||||
'新旧品',
|
||||
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8'];
|
||||
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
|
||||
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '在途进度', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
|
||||
'\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',
|
||||
'\u5e93\u5b58\u53ef\u652f\u6491\u5929\u6570', '\u52a0\u5de5\u5382\u5e93\u5b58\u652f\u6491\u5929\u6570', '\u603b\u652f\u6491\u5929\u6570',
|
||||
'30\u5929\u5e73\u5747\u9500\u91cf', '\u6781\u503c\u5224\u65ad'];
|
||||
var scopedSalesFields = salesFields.filter(function(field) { return field !== '在途进度'; });
|
||||
|
||||
// \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09
|
||||
var basicHtml = '';
|
||||
@ -29449,8 +29817,8 @@ window.showProductDetail = function(productCode) {
|
||||
};
|
||||
return labels[field] || POPUP_FIELD_LABELS[field] || field;
|
||||
};
|
||||
for (var i = 0; i < salesFields.length; i++) {
|
||||
var cloudField = salesFields[i];
|
||||
for (var i = 0; i < scopedSalesFields.length; i++) {
|
||||
var cloudField = scopedSalesFields[i];
|
||||
var cloudLabel = scopedFieldLabel(cloudField, '云仓');
|
||||
var cloudVal = cloudSales[cloudField] !== undefined ? cloudSales[cloudField] : '-';
|
||||
var cloudValHtml = formatPopupFieldValue(cloudField, cloudVal, cloudSales);
|
||||
@ -29463,8 +29831,8 @@ window.showProductDetail = function(productCode) {
|
||||
// 外仓数据与默认数据使用相同字段顺序和四列布局。
|
||||
var externalSalesHtml = '';
|
||||
var externalSales = p['外仓数据'] || {};
|
||||
for (var i = 0; i < salesFields.length; i++) {
|
||||
var externalField = salesFields[i];
|
||||
for (var i = 0; i < scopedSalesFields.length; i++) {
|
||||
var externalField = scopedSalesFields[i];
|
||||
var externalLabel = scopedFieldLabel(externalField, '外仓');
|
||||
var externalVal = externalSales[externalField] !== undefined ? externalSales[externalField] : '-';
|
||||
var externalValHtml = formatPopupFieldValue(externalField, externalVal, externalSales);
|
||||
|
||||
139
static/js/department_matching.js
Normal file
139
static/js/department_matching.js
Normal file
@ -0,0 +1,139 @@
|
||||
// 部门匹配独立规则库,不对业务数据执行回写。
|
||||
var departmentMatchingState = {rows: [], kind: 'platform', page: 1, search: ''};
|
||||
var departmentMatchingKinds = {platform: '平台层规则', brand: '品牌层规则', nonbrand: '非品牌清单', shop: '店铺归属对照表'};
|
||||
var departmentMatchingFields = {
|
||||
platform: [['department', '归属部门(必填)'], ['shop_keywords', '店铺关键词(逗号分隔)'], ['source_keywords', '空店铺来源(逗号分隔,精确匹配)']],
|
||||
brand: [['keyword', '品牌关键词(必填)'], ['department', '默认归属'], ['mode', '匹配方式(必填)'], ['pdd_department', '拼多多归属'], ['overseas_department', '京东海外/国际归属'], ['notes', '判定说明']],
|
||||
nonbrand: [['name', '名称(必填)'], ['reason', '为什么不是品牌'], ['guidance', '判定说明']],
|
||||
shop: [['shop', '店铺(必填)'], ['department', '预期归属(必填)'], ['layer', '参考命中层级']]
|
||||
};
|
||||
var departmentMatchingModes = {fixed: '固定归属(全平台)', split: '按平台分配归属', pdd_only: '拼多多专属,其他回落平台层'};
|
||||
|
||||
async function departmentMatchingRequest(path, data) {
|
||||
var response = await fetch('/api/department-matching/' + path, data === undefined ? {} : {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
|
||||
});
|
||||
var result = await response.json();
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.msg || '请求失败');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadDepartmentMatchingPage(container) {
|
||||
container.innerHTML = `<style>
|
||||
.dm-table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
.dm-table th,.dm-table td{border:1px solid var(--border,#e5e7eb);padding:10px 12px;text-align:left;vertical-align:top;overflow-wrap:anywhere;max-width:360px}
|
||||
.dm-table th{background:var(--bg-secondary,#f5f6f8);position:sticky;top:0;z-index:1;white-space:nowrap}
|
||||
.dm-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}
|
||||
.dm-panel{padding:18px;margin-bottom:16px}.dm-help{color:var(--text-light,#6b7280);font-size:12px;line-height:1.8}
|
||||
.dm-scroll{overflow:auto;max-height:calc(100vh - 470px);min-height:200px}
|
||||
.dm-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||
.dm-fields label{display:block;font-size:13px;margin-bottom:6px}.dm-fields .form-input{width:100%;box-sizing:border-box}
|
||||
@media(max-width:700px){.dm-fields{grid-template-columns:1fr}}
|
||||
</style><div class="page-header"><h1>部门匹配管理</h1></div>
|
||||
<div class="card dm-panel"><strong>匹配测试</strong><div class="dm-help">品牌层优先 → 平台关键词按具体程度匹配 → 店铺为空时按来源精确兜底。仅试算归属,不修改业务数据。</div>
|
||||
<div class="dm-toolbar"><input id="dmShop" class="form-input" style="width:360px;max-width:100%" placeholder="店铺名称" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
|
||||
<input id="dmSource" class="form-input" style="width:230px;max-width:100%" placeholder="数据来源(店铺为空时使用)" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
|
||||
<button class="btn btn-primary" onclick="testDepartmentMatching()">测试匹配</button><button class="btn" onclick="checkDepartmentMatching()">核验店铺对照表</button></div>
|
||||
<div id="dmResult" role="status" class="dm-help">请输入店铺或数据来源进行测试。</div></div>
|
||||
<div class="card dm-panel"><div id="dmTabs" class="dm-toolbar"></div>
|
||||
<div class="dm-toolbar"><input id="dmSearch" class="form-input" style="width:300px" placeholder="搜索关键词、归属、店铺或说明" onkeydown="if(event.key==='Enter')searchDepartmentMatching(false)">
|
||||
<button class="btn btn-primary" onclick="searchDepartmentMatching(false)">搜索</button><button class="btn" onclick="searchDepartmentMatching(true)">重置</button>
|
||||
<button id="dmCreate" class="btn btn-primary" style="margin-left:auto;display:none" onclick="editDepartmentMatching(0)">新增</button></div>
|
||||
<div class="dm-help" style="margin-bottom:10px">非品牌清单用于解释排除项;归属通过平台规则判定。平台层先比较关键词长度,长度相同时优先级数字越小越优先。</div>
|
||||
<div id="dmTable" class="dm-scroll">正在加载…</div><div id="dmPager" class="dm-toolbar" style="justify-content:center"></div></div>`;
|
||||
try {
|
||||
var result = await departmentMatchingRequest('records');
|
||||
departmentMatchingState.rows = result.data;
|
||||
renderDepartmentMatching();
|
||||
} catch (err) { document.getElementById('dmTable').textContent = err.message; }
|
||||
}
|
||||
|
||||
function renderDepartmentMatching() {
|
||||
var state = departmentMatchingState;
|
||||
var tabs = document.getElementById('dmTabs');
|
||||
if (!tabs) return;
|
||||
tabs.innerHTML = Object.keys(departmentMatchingKinds).map(function(kind) {
|
||||
return '<button class="btn ' + (state.kind === kind ? 'btn-primary' : '') + '" onclick="selectDepartmentMatchingTab(\'' + kind + '\')">' + departmentMatchingKinds[kind] + '(' + state.rows.filter(function(r){return r.kind === kind;}).length + ')</button>';
|
||||
}).join('');
|
||||
document.getElementById('dmCreate').style.display = window._hasPagePerm('department_matching', 'create') ? '' : 'none';
|
||||
document.getElementById('dmSearch').value = state.search;
|
||||
var rows = state.rows.filter(function(row) {
|
||||
return row.kind === state.kind && (!state.search || departmentMatchingFields[row.kind].map(function(f){return row.payload[f[0]] || '';}).join(' ').toLowerCase().includes(state.search.toLowerCase()));
|
||||
});
|
||||
var pages = Math.max(1, Math.ceil(rows.length / 20));
|
||||
state.page = Math.min(Math.max(state.page, 1), pages);
|
||||
var fields = departmentMatchingFields[state.kind];
|
||||
var canEdit = window._hasPagePerm('department_matching', 'edit');
|
||||
var canDelete = window._hasPagePerm('department_matching', 'delete');
|
||||
var rule = state.kind === 'platform' || state.kind === 'brand';
|
||||
var showPriority = state.kind === 'platform';
|
||||
var heads = '<th>序号</th>' + fields.map(function(f){return '<th>' + escHtml(f[1].replace('(必填)','')) + '</th>';}).join('') + (showPriority ? '<th>优先级</th>' : '') + (rule ? '<th>状态</th>' : '') + (canEdit || canDelete ? '<th>操作</th>' : '');
|
||||
var body = rows.slice((state.page - 1) * 20, state.page * 20).map(function(row, i) {
|
||||
return '<tr><td>' + ((state.page - 1) * 20 + i + 1) + '</td>' + fields.map(function(f) {
|
||||
var value = row.payload[f[0]] || '';
|
||||
if (f[0] === 'mode') value = departmentMatchingModes[value] || value;
|
||||
return '<td>' + escHtml(String(value)) + '</td>';
|
||||
}).join('') + (showPriority ? '<td>' + row.priority + '</td>' : '') + (rule ? '<td>' + (row.enabled ? '启用' : '停用') + '</td>' : '') + (canEdit || canDelete ? '<td style="white-space:nowrap">' +
|
||||
(canEdit ? '<button class="btn btn-sm" onclick="editDepartmentMatching(' + row.id + ')">编辑</button> ' : '') +
|
||||
(canDelete ? '<button class="btn btn-sm" style="color:#dc2626" onclick="deleteDepartmentMatching(' + row.id + ')">删除</button>' : '') + '</td>' : '') + '</tr>';
|
||||
}).join('');
|
||||
document.getElementById('dmTable').innerHTML = '<table class="dm-table"><thead><tr>' + heads + '</tr></thead><tbody>' + (body || '<tr><td colspan="15">暂无记录</td></tr>') + '</tbody></table>';
|
||||
document.getElementById('dmPager').innerHTML = '<span>共 ' + rows.length + ' 条,每页20条</span><button class="btn" onclick="pageDepartmentMatching(-1)" ' + (state.page === 1 ? 'disabled' : '') + '>上一页</button><span>' + state.page + ' / ' + pages + '</span><button class="btn" onclick="pageDepartmentMatching(1)" ' + (state.page === pages ? 'disabled' : '') + '>下一页</button><input id="dmJump" class="form-input" type="number" min="1" max="' + pages + '" style="width:70px" value="' + state.page + '" onkeydown="if(event.key===\'Enter\')pageDepartmentMatching(0)"><button class="btn" onclick="pageDepartmentMatching(0)">跳转</button>';
|
||||
}
|
||||
|
||||
function selectDepartmentMatchingTab(kind) { departmentMatchingState.kind=kind; departmentMatchingState.page=1; renderDepartmentMatching(); }
|
||||
function searchDepartmentMatching(reset) { departmentMatchingState.search=reset?'':document.getElementById('dmSearch').value.trim(); departmentMatchingState.page=1; renderDepartmentMatching(); }
|
||||
function pageDepartmentMatching(delta) {
|
||||
departmentMatchingState.page = delta ? departmentMatchingState.page + delta : (parseInt(document.getElementById('dmJump').value,10) || 1);
|
||||
renderDepartmentMatching();
|
||||
}
|
||||
|
||||
function editDepartmentMatching(id) {
|
||||
var row = departmentMatchingState.rows.find(function(r){return r.id === id;}) || {id:0, kind:departmentMatchingState.kind, payload:{}, priority:100, enabled:1};
|
||||
document.getElementById('dmEditModal')?.remove();
|
||||
var modal = document.createElement('div'); modal.id='dmEditModal'; modal.className='modal-overlay'; modal.style.cssText='display:flex;z-index:15000';
|
||||
var fields = departmentMatchingFields[row.kind].map(function(f) {
|
||||
var value = row.payload[f[0]] || '';
|
||||
var control;
|
||||
if (f[0] === 'mode') control = '<select class="form-input" data-dm-field="mode">' + Object.keys(departmentMatchingModes).map(function(mode) {return '<option value="' + mode + '" ' + (value === mode ? 'selected' : '') + '>' + departmentMatchingModes[mode] + '</option>';}).join('') + '</select>';
|
||||
else control = '<textarea class="form-input" rows="' + (['notes','guidance','reason'].includes(f[0]) ? 3 : 2) + '" maxlength="4000" data-dm-field="' + f[0] + '">' + escHtml(String(value)) + '</textarea>';
|
||||
return '<div><label>' + escHtml(f[1]) + '</label>' + control + '</div>';
|
||||
}).join('');
|
||||
var rule = row.kind === 'brand' || row.kind === 'platform';
|
||||
modal.innerHTML = '<div class="modal" style="width:min(760px,94vw);max-height:90vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + (id ? '编辑' : '新增') + departmentMatchingKinds[row.kind] + '</h3><button class="modal-close" onclick="document.getElementById(\'dmEditModal\').remove()">×</button></div><form id="dmEditForm" style="display:flex;flex-direction:column;min-height:0"><div class="modal-body" style="overflow:auto"><div class="dm-fields">' + fields + (rule ? '<div><label>优先级(0–100000,数字越小越优先)</label><input id="dmPriority" type="number" min="0" max="100000" required class="form-input" value="' + row.priority + '"></div><div><label>状态</label><select id="dmEnabled" class="form-input"><option value="1">启用</option><option value="0" ' + (!row.enabled ? 'selected' : '') + '>停用</option></select></div>' : '') + '</div><div class="dm-help" style="margin-top:12px">品牌关键词取自店铺名称。按平台分配时,拼多多优先使用拼多多归属,京东海外/国际其次,其他使用默认归属。非品牌条目用于说明,不独立覆盖归属。</div><div id="dmEditError" style="color:#dc2626;margin-top:8px" role="alert"></div></div><div class="modal-footer"><button type="button" class="btn" onclick="document.getElementById(\'dmEditModal\').remove()">取消</button><button id="dmSave" type="submit" class="btn btn-primary">保存</button></div></form></div>';
|
||||
if (row.kind === 'brand') modal.querySelector('#dmPriority').parentElement.remove();
|
||||
document.body.appendChild(modal);
|
||||
document.getElementById('dmEditForm').onsubmit = async function(event) {
|
||||
event.preventDefault();
|
||||
var payload=Object.assign({},row.payload); modal.querySelectorAll('[data-dm-field]').forEach(function(el){payload[el.dataset.dmField]=el.value;});
|
||||
var button=document.getElementById('dmSave'); button.disabled=true;
|
||||
try {
|
||||
await departmentMatchingRequest('save', {id:row.id, kind:row.kind, payload:payload, priority:row.kind==='platform'?Number(document.getElementById('dmPriority').value):row.priority, enabled:rule?Number(document.getElementById('dmEnabled').value):1});
|
||||
departmentMatchingState.rows=(await departmentMatchingRequest('records')).data;
|
||||
modal.remove(); renderDepartmentMatching();
|
||||
} catch(err) { document.getElementById('dmEditError').textContent=err.message; button.disabled=false; }
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteDepartmentMatching(id) {
|
||||
if (!confirm('确认删除此记录?删除规则会影响后续匹配测试结果。')) return;
|
||||
try { await departmentMatchingRequest('delete',{id:id}); departmentMatchingState.rows=(await departmentMatchingRequest('records')).data; renderDepartmentMatching(); }
|
||||
catch(err) { alert(err.message); }
|
||||
}
|
||||
|
||||
async function testDepartmentMatching() {
|
||||
var el=document.getElementById('dmResult');
|
||||
try {
|
||||
var data=(await departmentMatchingRequest('test',{shop:document.getElementById('dmShop').value, source:document.getElementById('dmSource').value})).data;
|
||||
el.textContent='归属:' + (data.department || '未匹配') + ' | ' + data.layer + ' | ' + data.reason;
|
||||
} catch(err) { el.textContent=err.message; }
|
||||
}
|
||||
|
||||
async function checkDepartmentMatching() {
|
||||
var el=document.getElementById('dmResult'); el.textContent='正在核验…';
|
||||
try {
|
||||
var data=(await departmentMatchingRequest('check',{})).data;
|
||||
el.textContent='核验完成:' + data.passed + ' / ' + data.total + ' 条归属一致。';
|
||||
if (data.mismatches.length) el.innerHTML += '<div style="overflow:auto;max-height:240px"><table class="dm-table"><thead><tr><th>店铺</th><th>预期归属</th><th>实际归属</th><th>原因</th></tr></thead><tbody>' + data.mismatches.map(function(r){return '<tr><td>' + escHtml(r.shop) + '</td><td>' + escHtml(r.expected) + '</td><td>' + escHtml(r.actual || '未匹配') + '</td><td>' + escHtml(r.reason) + '</td></tr>';}).join('') + '</tbody></table></div>';
|
||||
} catch(err) { el.textContent=err.message; }
|
||||
}
|
||||
@ -200,6 +200,10 @@
|
||||
<span class="nav-sub-icon">●</span>
|
||||
<span>人员信息管理</span>
|
||||
</a>
|
||||
<a href="/department-matching" class="nav-sub-item {% if page == 'department_matching' %}active{% endif %}" data-page="department_matching">
|
||||
<span class="nav-sub-icon">●</span>
|
||||
<span>部门匹配管理</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# 库存与出库(含子菜单): 所有人 #}
|
||||
@ -393,6 +397,10 @@
|
||||
<span class="nav-arrow">▾</span>
|
||||
</a>
|
||||
<div class="nav-submenu" id="submenu-sysconfig">
|
||||
<a href="/organization" class="nav-sub-item {% if page == 'organization' %}active{% endif %}" data-page="organization">
|
||||
<span class="nav-sub-icon">●</span>
|
||||
<span>组织架构</span>
|
||||
</a>
|
||||
<a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users">
|
||||
<span class="nav-sub-icon">●</span>
|
||||
<span>用户管理</span>
|
||||
@ -463,6 +471,7 @@
|
||||
</script>
|
||||
<script src="/static/js/marked.min.js"></script>
|
||||
<script src="/static/js/dompurify.min.js"></script>
|
||||
<script src="/static/js/department_matching.js?v={{ app_js_version }}"></script>
|
||||
<script src="/static/js/app.js?v={{ app_js_version }}"></script>
|
||||
<script src="/static/js/knowledge-actions.js?v=1"></script>
|
||||
<script>
|
||||
@ -650,7 +659,7 @@
|
||||
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
|
||||
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) {
|
||||
// 找第一个有权限的页面
|
||||
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
|
||||
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','organization','department_matching','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
|
||||
for (var pi = 0; pi < accessiblePages.length; pi++) {
|
||||
var ap = accessiblePages[pi];
|
||||
var apPerms = perms[ap] || {};
|
||||
|
||||
120
tests/test_ai_operation_history.py
Normal file
120
tests/test_ai_operation_history.py
Normal file
@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from routes import ai
|
||||
from services import ai_datasources, audit_schema
|
||||
|
||||
|
||||
class AiOperationHistoryTest(unittest.TestCase):
|
||||
def test_history_query_returns_exact_and_legacy_matches_distinctly(self):
|
||||
fake_logs = [
|
||||
{'id': 2, 'created_at': '2026-10-28 10:00:00', 'user_id': 3, 'page': '计划预测',
|
||||
'action': '数据变更', 'field': None, 'object_type': 'product', 'object_id': 'SKU-1',
|
||||
'old_values': '{"采购计划":"甲"}', 'new_values': '{"采购计划":"乙"}',
|
||||
'detail': '更新计划预测', 'result_status': 'success', 'request_id': 'r2',
|
||||
'real_name': '张三', 'username': 'zhang'},
|
||||
{'id': 1, 'created_at': '2026-10-27 10:00:00', 'user_id': 4, 'page': '计划预测',
|
||||
'action': '数据变更', 'field': None, 'object_type': None, 'object_id': None,
|
||||
'old_values': None, 'new_values': None, 'detail': '货品编号:SKU-1,采购计划调整',
|
||||
'result_status': 'success', 'request_id': 'r1', 'real_name': '李四', 'username': 'li'},
|
||||
]
|
||||
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 2}], fake_logs]) as query:
|
||||
result = ai_datasources._h_operation_history({'product_code': 'SKU-1', 'module': '计划预测'})
|
||||
self.assertEqual(2, result['summary']['row_count'])
|
||||
self.assertEqual('精确对象匹配', result['rows'][0]['匹配方式'])
|
||||
self.assertIn('采购计划:甲 → 乙', result['rows'][0]['修改内容'])
|
||||
self.assertEqual('历史文本匹配', result['rows'][1]['匹配方式'])
|
||||
self.assertIn('object_id=%s', query.call_args_list[0].args[0])
|
||||
self.assertIn('%计划预测%', query.call_args_list[0].args[1])
|
||||
|
||||
def test_chinese_and_nested_product_identifiers_are_inferred(self):
|
||||
self.assertEqual(('product', 'SKU-2'), audit_schema.infer_object({'货品编号': 'SKU-2'}))
|
||||
self.assertEqual(('product', 'SKU-3'), audit_schema.infer_object({'items': [{'产品编码': 'SKU-3'}]}))
|
||||
self.assertEqual(('product', 'SKU-4'), audit_schema.infer_object({'id': 22, 'product': {'product_code': 'SKU-4'}}))
|
||||
|
||||
def test_history_requests_are_isolated_from_other_ai_sources(self):
|
||||
tasks = ai._normalize_tasks([
|
||||
{'datasource': 'product_profile', 'params': {'product_code': 'SKU-1'}},
|
||||
{'datasource': 'operation_history', 'params': {'product_code': 'SKU-1'}},
|
||||
{'datasource': 'planning_metrics', 'params': {'product_code': 'SKU-1'}},
|
||||
])
|
||||
self.assertEqual(['operation_history'], [task['datasource'] for task in tasks])
|
||||
|
||||
def test_history_can_search_legacy_text_by_product_name(self):
|
||||
fake_logs = [{'id': 3, 'created_at': None, 'user_id': 1, 'page': '计划预测',
|
||||
'action': '编辑', 'object_type': None, 'object_id': None,
|
||||
'old_values': None, 'new_values': None, 'detail': '修改 Aromat 精华液',
|
||||
'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
|
||||
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]) as query:
|
||||
result = ai_datasources._h_operation_history({'product_name': 'Aromat 精华液'})
|
||||
self.assertEqual('历史文本匹配', result['rows'][0]['匹配方式'])
|
||||
self.assertIn('%Aromat 精华液%', query.call_args_list[0].args[1])
|
||||
|
||||
def test_history_query_is_scoped_to_permitted_pages(self):
|
||||
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 0}], []]) as query:
|
||||
ai_datasources._h_operation_history({'_allowed_pages': ['planning', '计划预测']})
|
||||
self.assertIn('COALESCE(NULLIF(l.page', query.call_args_list[0].args[0])
|
||||
self.assertIn('planning', query.call_args_list[0].args[1])
|
||||
|
||||
def test_history_field_values_are_hidden_without_field_permission(self):
|
||||
rows = [{'板块': '计划预测', '修改内容': '采购计划: (未记录) → 王某', '字段': '请求字段(旧日志未结构化)'}]
|
||||
with mock.patch('routes.auth._has_effective_page_permission', return_value=True), \
|
||||
mock.patch('services.field_perms.field_perms_for_page', return_value={'采购计划': {'view': False}}):
|
||||
result = ai._filter_operation_history_permissions(1, '采购跟单', rows)
|
||||
self.assertEqual('修改字段内容因字段权限不可见', result[0]['修改内容'])
|
||||
|
||||
def test_history_content_localizes_common_fields_and_keeps_full_values(self):
|
||||
fake_logs = [{'id': 4, 'created_at': None, 'user_id': 1, 'page': '产品信息管理',
|
||||
'action': '编辑产品', 'object_type': 'product', 'object_id': 'SKU-5',
|
||||
'old_values': '{"purchase_plan":"甲"}',
|
||||
'new_values': '{"purchase_plan":"乙","product_name":"Aromat 精华液"}',
|
||||
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
|
||||
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]):
|
||||
result = ai_datasources._h_operation_history({'product_code': 'SKU-5'})
|
||||
row = result['rows'][0]
|
||||
self.assertEqual('采购计划', row['字段'])
|
||||
self.assertIn('采购计划:甲 → 乙', row['修改内容'])
|
||||
self.assertNotIn('货品名称', row['修改内容'])
|
||||
|
||||
def test_nested_json_field_keys_are_localized(self):
|
||||
fake_logs = [{'id': 5, 'created_at': None, 'user_id': 1, 'page': 'planning',
|
||||
'action': '数据变更', 'object_type': None, 'object_id': None,
|
||||
'old_values': '{"columns":[{"field":"product_code","label":"货品编号"}],"sort_by":"product_name"}',
|
||||
'new_values': '{"columns":[{"field":"product_code","label":"货品编号"}],"sort_by":"product_code"}',
|
||||
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
|
||||
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]):
|
||||
row = ai_datasources._h_operation_history({'module': '计划预测'})['rows'][0]
|
||||
self.assertEqual('排序字段', row['字段'])
|
||||
self.assertIn('排序字段:货品名称 → 货品编号', row['修改内容'])
|
||||
self.assertNotIn('字段列', row['修改内容'])
|
||||
self.assertEqual('计划预测', row['板块'])
|
||||
|
||||
def test_history_omits_unverifiable_bulk_payload_and_shows_only_nested_changes(self):
|
||||
fake_logs = [
|
||||
{'id': 6, 'created_at': None, 'user_id': 1, 'page': '物料清单管理',
|
||||
'action': '数据变更', 'object_type': 'product', 'object_id': 'SKU-6',
|
||||
'old_values': None,
|
||||
'new_values': '{"product_codes":["SKU-6"],"bom_data_map":{"SKU-6":[{"material_code":"MAT-1","quantity":2}]}}',
|
||||
'detail': 'POST /api/bom/save;提交内容', 'result_status': 'success',
|
||||
'real_name': '用户', 'username': 'u'},
|
||||
{'id': 7, 'created_at': None, 'user_id': 1, 'page': '物料清单管理',
|
||||
'action': '数据变更', 'object_type': 'product', 'object_id': 'SKU-7',
|
||||
'old_values': '{"bom_data_map":{"SKU-7":[{"material_code":"MAT-1","quantity":2,"remark":"保留"}]}}',
|
||||
'new_values': '{"bom_data_map":{"SKU-7":[{"material_code":"MAT-1","quantity":3,"remark":"保留"}]}}',
|
||||
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'},
|
||||
]
|
||||
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 2}], fake_logs]):
|
||||
rows = ai_datasources._h_operation_history({'module': '物料清单管理'})['rows']
|
||||
self.assertEqual('该记录未保存可比较的字段前后值', rows[0]['修改内容'])
|
||||
self.assertNotIn('SKU-6', rows[0]['修改内容'])
|
||||
self.assertIn('数量:2 → 3', rows[1]['修改内容'])
|
||||
self.assertNotIn('保留', rows[1]['修改内容'])
|
||||
|
||||
def test_history_source_requires_operation_log_page_permission(self):
|
||||
with mock.patch('routes.auth._has_effective_page_permission', return_value=False):
|
||||
self.assertFalse(ai._can_view_datasource(8, '采购跟单', 'operation_history'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -22,6 +22,26 @@ class BackupRemainingPackagingTest(unittest.TestCase):
|
||||
purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8')
|
||||
self.assertIn("bm['post_pack_qty'] = post_pack_qty", purchase_source)
|
||||
|
||||
def test_backup_detail_includes_lead_time_and_packaging_progress(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
frontend = (root / 'static/js/app.js').read_text(encoding='utf-8')
|
||||
backend = (root / 'routes/purchase.py').read_text(encoding='utf-8')
|
||||
|
||||
self.assertIn("['货期', (item.lead_time", frontend)
|
||||
self.assertIn("item.lead_time + '天'", frontend)
|
||||
self.assertIn('>下单进度</th>', frontend)
|
||||
self.assertIn("b.order_progress || '-'", frontend)
|
||||
|
||||
normalize_source = backend[
|
||||
backend.index('def _normalize_backup_bom('):
|
||||
backend.index("# ─── API: 采购订单 ───")
|
||||
]
|
||||
self.assertIn("AS order_progress", normalize_source)
|
||||
self.assertIn("BINARY `编码`=BINARY %s", normalize_source)
|
||||
self.assertIn("BINARY `来源产品编码`=BINARY %s", normalize_source)
|
||||
self.assertIn("`下单时间`=%s", normalize_source)
|
||||
self.assertIn("current['order_progress']", normalize_source)
|
||||
|
||||
def test_backup_detail_reads_saved_planning_sales_snapshot(self):
|
||||
source = purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8')
|
||||
frontend = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8')
|
||||
@ -30,11 +50,14 @@ class BackupRemainingPackagingTest(unittest.TestCase):
|
||||
self.assertIn("raw_snapshot = row.pop('sales_snapshot', None)", detail_source)
|
||||
self.assertNotIn('_build_planning_sales_snapshot(', detail_source)
|
||||
self.assertIn('sales_snapshot, sales_snapshot_at', source)
|
||||
self.assertIn('与计划预测详情默认数据完全同源', frontend)
|
||||
self.assertIn('与计划预测详情云仓数据完全同源', frontend)
|
||||
self.assertIn("return {'__scope': 'cloud'", source)
|
||||
self.assertIn("row['sales_snapshot_scope']", source)
|
||||
self.assertIn("salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)'", frontend)
|
||||
self.assertIn('<span>销量信息</span></div>', frontend)
|
||||
self.assertIn('class="pd-fields pd-fields-4col"', frontend)
|
||||
self.assertIn("formatPopupFieldValue(field[0], value, salesInfo)", frontend)
|
||||
self.assertIn("['库存可支撑天数', '库存可支撑天数']", frontend)
|
||||
self.assertIn("['库存可支撑天数', salesLabelPrefix + '库存可支撑天数']", frontend)
|
||||
self.assertIn('历史订单未保存销量快照', frontend)
|
||||
|
||||
|
||||
|
||||
107
tests/test_department_matching.py
Normal file
107
tests/test_department_matching.py
Normal file
@ -0,0 +1,107 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import Flask
|
||||
from services.department_matching import match_department, validate_record, check_samples
|
||||
from routes.department_matching import department_matching_bp
|
||||
|
||||
|
||||
def rule(kind, payload, row_id=1, priority=100, enabled=1):
|
||||
return dict(kind=kind, payload=payload, id=row_id, priority=priority, enabled=enabled)
|
||||
|
||||
|
||||
class MatchingTest(unittest.TestCase):
|
||||
def test_specific_platform_precedes_general_keyword(self):
|
||||
rows = [rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords=''), 1, 10),
|
||||
rule('platform', dict(department='雪狼', shop_keywords='天猫国际', source_keywords=''), 2, 20)]
|
||||
self.assertEqual(match_department('天猫国际', rows=rows)['department'], '雪狼')
|
||||
|
||||
def test_brand_overrides_platform_case_and_spaces(self):
|
||||
rows = [rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords='')),
|
||||
rule('brand', dict(keyword='DR JEVEITAL', mode='fixed', department='洁维塔'), 2)]
|
||||
self.assertEqual(match_department('天猫-drjeveital旗舰店', rows=rows)['department'], '洁维塔')
|
||||
|
||||
def test_aromat_boundary(self):
|
||||
rows = [rule('brand', dict(keyword='AROMAT', mode='fixed', department='雪狼'))]
|
||||
self.assertEqual(match_department('AROMAT HERBS店', rows=rows)['department'], '雪狼')
|
||||
self.assertEqual(match_department('AROMATHERBS店', rows=rows)['department'], '')
|
||||
|
||||
def test_conditional_brand_and_platform_fallback(self):
|
||||
rows = [rule('brand', dict(keyword='MUTO', mode='split', department='灵动',
|
||||
pdd_department='雪狼', overseas_department='雪狼')),
|
||||
rule('brand', dict(keyword='UOUONE', mode='pdd_only', department='', pdd_department='春跃'), 2),
|
||||
rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords=''), 3)]
|
||||
for shop, expected in [('拼多多-MUTO', '雪狼'), ('京东海外-MUTO', '雪狼'),
|
||||
('京东国际-MUTO', '雪狼'), ('抖音-MUTO', '灵动'),
|
||||
('天猫-UOUONE', '战狼'), ('拼多多-UOUONE', '春跃')]:
|
||||
with self.subTest(shop=shop):
|
||||
self.assertEqual(match_department(shop, rows=rows)['department'], expected)
|
||||
|
||||
def test_source_only_for_empty_shop_and_exact(self):
|
||||
rows = [rule('platform', dict(department='洁维塔', shop_keywords='阿里健康', source_keywords='jdmrt,京东洁维塔'))]
|
||||
self.assertEqual(match_department(' ', 'JDMRT', rows)['department'], '洁维塔')
|
||||
self.assertEqual(match_department('未知店铺', 'jdmrt', rows)['department'], '')
|
||||
self.assertEqual(match_department('', 'jdmrt-extra', rows)['department'], '')
|
||||
|
||||
def test_disabled_rules_and_deterministic_priority(self):
|
||||
rows = [rule('platform', dict(department='停用', shop_keywords='天猫国际', source_keywords=''), enabled=0),
|
||||
rule('platform', dict(department='B', shop_keywords='天猫', source_keywords=''), 2, 20),
|
||||
rule('platform', dict(department='A', shop_keywords='天猫', source_keywords=''), 3, 10)]
|
||||
self.assertEqual(match_department('天猫国际', rows=rows)['department'], 'A')
|
||||
|
||||
def test_samples_report_mismatches_without_becoming_rules(self):
|
||||
rows = [rule('shop', dict(shop='未知店铺', department='A'))]
|
||||
self.assertEqual(match_department('未知店铺', rows=rows)['department'], '')
|
||||
report = check_samples(rows)
|
||||
self.assertEqual((report['total'], report['passed']), (1, 0))
|
||||
self.assertEqual(report['mismatches'][0]['expected'], 'A')
|
||||
|
||||
def test_reject_invalid_conditions_and_priority(self):
|
||||
for data in [dict(kind='unknown', payload={}),
|
||||
dict(kind='platform', payload=dict(department='A')),
|
||||
dict(kind='brand', payload=dict(keyword='A', mode='split', department='B')),
|
||||
dict(kind='platform', payload=dict(department='A', shop_keywords='x'), priority=-1)]:
|
||||
with self.subTest(data=data), self.assertRaises(ValueError):
|
||||
validate_record(data)
|
||||
|
||||
|
||||
class MatchingApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'testing'
|
||||
app.register_blueprint(department_matching_bp)
|
||||
self.client = app.test_client()
|
||||
patcher = patch('routes.auth.query', return_value=[{'status': 1}])
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def login(self):
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 1
|
||||
session['role'] = 'viewer'
|
||||
|
||||
def test_view_and_write_permissions_are_enforced(self):
|
||||
self.login()
|
||||
with patch('routes.department_matching.role_required', return_value=False), \
|
||||
patch('routes.department_matching.operation_required', return_value=False), \
|
||||
patch('routes.department_matching.query') as db:
|
||||
for path in ('records', 'save', 'delete', 'test', 'check'):
|
||||
response = self.client.get('/api/department-matching/records') if path == 'records' else self.client.post('/api/department-matching/' + path, json={})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
db.assert_not_called()
|
||||
|
||||
def test_read_only_test_has_no_writes(self):
|
||||
self.login()
|
||||
with patch('routes.department_matching.role_required', return_value=True), \
|
||||
patch('routes.department_matching.match_department', return_value={'department': '雪狼'}) as match, \
|
||||
patch('routes.department_matching.query') as db, \
|
||||
patch('routes.department_matching.add_operation_log') as log:
|
||||
response = self.client.post('/api/department-matching/test', json={'shop': '天猫国际'})
|
||||
self.assertEqual(response.get_json()['data']['department'], '雪狼')
|
||||
match.assert_called_once_with('天猫国际', '')
|
||||
db.assert_not_called()
|
||||
log.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
43
tests/test_inventory_board_logic.py
Normal file
43
tests/test_inventory_board_logic.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""库存数据看板月份与统计口径回归测试。"""
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class InventoryBoardLogicTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.frontend = ROOT.joinpath('static', 'js', 'app.js').read_text(encoding='utf-8')
|
||||
cls.backend = ROOT.joinpath('routes', 'dashboard.py').read_text(encoding='utf-8')
|
||||
|
||||
def test_month_is_dynamic_and_uses_shanghai_timezone(self):
|
||||
self.assertIn("timeZone: 'Asia/Shanghai'", self.frontend)
|
||||
self.assertIn('inventoryBoardCurrentMonth()', self.frontend)
|
||||
self.assertNotIn("monthInput.value = '2026-08'", self.frontend)
|
||||
|
||||
def test_snapshot_is_limited_to_selected_month(self):
|
||||
self.assertIn(
|
||||
'WHERE data_date >= %s AND data_date < %s',
|
||||
self.backend,
|
||||
)
|
||||
|
||||
def test_disabled_warehouses_and_all_warehouse_stockout_are_enforced(self):
|
||||
self.assertIn("active_warehouse_condition('id')", self.backend)
|
||||
self.assertIn('HAVING SUM(id.available_qty) <= 0', self.backend)
|
||||
self.assertIn('_stockout_codes.intersection_update(_sales_30.keys())', self.backend)
|
||||
self.assertNotIn('AND id.available_qty = 0', self.backend)
|
||||
|
||||
def test_external_value_is_optional_override(self):
|
||||
self.assertIn('if key not in external_map', self.backend)
|
||||
self.assertNotIn("if value in ('', None):\n result[key] = 0", self.backend)
|
||||
|
||||
def test_sku_thresholds_follow_visible_labels(self):
|
||||
self.assertIn("float(row['qty'] or 0) >= 30", self.backend)
|
||||
self.assertIn("float(row['qty'] or 0) >= 100", self.backend)
|
||||
self.assertIn("float(row['qty'] or 0) >= 200", self.backend)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -22,6 +22,8 @@ class InventoryBoardStaleCacheTest(unittest.TestCase):
|
||||
normalized = ' '.join(str(sql).split()).lower()
|
||||
if normalized.startswith('select count(*) as row_count'):
|
||||
return [{'row_count': 10, 'qty_sum': 20, 'updated_signature': 'sig'}]
|
||||
if normalized.startswith('select warehouse_code, warehouse_property, disabled from warehouses'):
|
||||
return [{'warehouse_code': '01', 'warehouse_property': '云仓', 'disabled': '否'}]
|
||||
if normalized.startswith('create table if not exists inventory_board_system_cache'):
|
||||
return 0
|
||||
if normalized.startswith('select payload, updated_at from inventory_board_system_cache'):
|
||||
|
||||
@ -69,6 +69,12 @@ class NavigationOrderTest(unittest.TestCase):
|
||||
self.assertIn('.nav-order-11 { order: 11; }', html)
|
||||
self.assertNotIn('测试问题', html)
|
||||
|
||||
def test_system_settings_contains_organization_page(self):
|
||||
html = BASE_TEMPLATE.read_text(encoding='utf-8')
|
||||
self.assertIn('href="/organization"', html)
|
||||
self.assertIn('data-page="organization"', html)
|
||||
self.assertIn('<span>组织架构</span>', html)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
43
tests/test_packaging_arrival_defective.py
Normal file
43
tests/test_packaging_arrival_defective.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""包材订单到货批次不良品补填规则回归测试。"""
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PackagingArrivalDefectiveTest(unittest.TestCase):
|
||||
def test_packaging_defective_allows_negative_and_over_arrival(self):
|
||||
from routes.purchase import _arrival_batch_quantities
|
||||
|
||||
self.assertEqual((10, -3, True, True), _arrival_batch_quantities(
|
||||
{'arrival_qty': '10', 'defective_qty': '-3'}, 'packaging'))
|
||||
self.assertEqual((10, 30, True, True), _arrival_batch_quantities(
|
||||
{'arrival_qty': '10', 'defective_qty': '30'}, 'packaging'))
|
||||
|
||||
def test_blank_defective_is_preserved_as_unfilled(self):
|
||||
from routes.purchase import _arrival_batch_quantities
|
||||
|
||||
self.assertEqual((10, 0, True, False), _arrival_batch_quantities(
|
||||
{'arrival_qty': '10', 'defective_qty': ''}, 'packaging'))
|
||||
|
||||
def test_domestic_and_overseas_use_same_defective_rules(self):
|
||||
from routes.purchase import _arrival_batch_quantities
|
||||
|
||||
self.assertEqual((10, -1, True, True), _arrival_batch_quantities(
|
||||
{'arrival_qty': '10', 'defective_qty': '-1'}, 'domestic'))
|
||||
self.assertEqual((10, 11, True, True), _arrival_batch_quantities(
|
||||
{'arrival_qty': '10', 'defective_qty': '11'}, 'overseas'))
|
||||
|
||||
def test_frontend_keeps_blank_and_only_validates_non_packaging_range(self):
|
||||
source = Path(__file__).resolve().parents[1].joinpath('static/js/app.js').read_text(encoding='utf-8')
|
||||
self.assertIn("defectiveRaw === '' ? ''", source)
|
||||
self.assertNotIn("orderType !== 'packaging' && defectiveRaw !== ''", source)
|
||||
self.assertIn("renderArrivalBatchStoredCell('number', 'batch-defective-qty'", source)
|
||||
self.assertNotIn('min="0" class="batch-defective-qty"', source)
|
||||
|
||||
def test_defective_column_is_visible_for_all_three_order_types(self):
|
||||
source = Path(__file__).resolve().parents[1].joinpath('static/js/app.js').read_text(encoding='utf-8')
|
||||
self.assertIn('var showDefectiveQty = true;', source)
|
||||
self.assertIn("renderArrivalBatchStoredCell('number', 'batch-defective-qty'", source)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -49,7 +49,7 @@ class PlanningNewOldTest(unittest.TestCase):
|
||||
for existing, captured in [(None, '新品'), ({'id': 5, 'bom_items': None, 'new_old_product': '旧品'}, '旧品')]:
|
||||
writes = []
|
||||
def sql(query, params=None):
|
||||
if query.lstrip().startswith('SELECT id, bom_items'):
|
||||
if 'SELECT id, bom_items, new_old_product' in query:
|
||||
return [existing] if existing else []
|
||||
if query.lstrip().startswith('SELECT'):
|
||||
return []
|
||||
@ -67,6 +67,38 @@ class PlanningNewOldTest(unittest.TestCase):
|
||||
params = writes[0][1]
|
||||
self.assertEqual(params[-4] if existing else params[-3], captured)
|
||||
|
||||
def test_save_order_modal_creates_hidden_draft_without_pending_order(self):
|
||||
writes = []
|
||||
def sql(query, params=None):
|
||||
if 'SELECT id, remark, bom_items, formal_order_qty, status FROM pending_orders' in query:
|
||||
return []
|
||||
if 'SELECT product_name, product_type FROM products' in query:
|
||||
return [{'product_name': '测试成品', 'product_type': '成品'}]
|
||||
writes.append((query, params))
|
||||
return 42
|
||||
|
||||
bom_items = [
|
||||
{'material_code': '成品_P1', 'actual_quantity': 12, 'remark': '成品备注'},
|
||||
{'material_code': 'MAT-1', 'material_name': '测试包材', 'quantity': 1,
|
||||
'actual_quantity': 8, 'remark': '包材备注', 'pre_pack_qty': 5,
|
||||
'pkg_remark': '余额备注', 'change_manuscript': '是'},
|
||||
]
|
||||
with self.app.test_request_context(json={
|
||||
'product_code': 'P1', 'remark': '成品备注', 'bom_items': bom_items
|
||||
}), mock.patch.object(purchase, 'operation_required', side_effect=lambda page, op: page == 'planning'), \
|
||||
mock.patch.object(purchase, 'query', side_effect=sql), \
|
||||
mock.patch.object(purchase, '_sync_pending_pre_packaging') as sync_pack, \
|
||||
mock.patch.object(purchase, 'add_operation_log'):
|
||||
response = purchase.purchase_update_pending.__wrapped__()
|
||||
|
||||
self.assertEqual(response.get_json()['code'], 0)
|
||||
insert = next((q, p) for q, p in writes if q.lstrip().startswith('INSERT INTO pending_orders'))
|
||||
self.assertIn("'draft'", insert[0])
|
||||
self.assertEqual(insert[1][0:3], ('P1', '测试成品', '成品'))
|
||||
self.assertEqual(insert[1][3], 12)
|
||||
self.assertTrue(any('UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s' in q for q, _ in writes))
|
||||
sync_pack.assert_called_once()
|
||||
|
||||
def test_batch_add_captures_each_product_choice(self):
|
||||
writes = []
|
||||
def sql(query, params=None):
|
||||
@ -87,6 +119,31 @@ class PlanningNewOldTest(unittest.TestCase):
|
||||
self.assertEqual(response.get_json()['data']['added'], 2)
|
||||
self.assertEqual([params[-3] for params in writes], ['新品', '旧品'])
|
||||
|
||||
def test_batch_add_promotes_existing_order_draft(self):
|
||||
writes = []
|
||||
def sql(query, params=None):
|
||||
if "status='pending'" in query and query.lstrip().startswith('SELECT id'):
|
||||
return []
|
||||
if "status='draft'" in query and query.lstrip().startswith('SELECT id'):
|
||||
return [{'id': 9, 'new_old_product': '新品', 'sales_snapshot': '{"昨日销量":1}',
|
||||
'sales_snapshot_at': '2026-10-28 09:00:00'}]
|
||||
if 'SELECT p.product_code' in query:
|
||||
return [{'product_code': 'P1', 'product_name': '商品', 'product_type': '成品',
|
||||
'planning_new_old': '新品'}]
|
||||
if query.lstrip().startswith('SELECT'):
|
||||
return []
|
||||
writes.append((query, params))
|
||||
return 1
|
||||
with self.app.test_request_context(json={'product_codes': ['P1']}), \
|
||||
mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
|
||||
mock.patch.object(purchase, 'query', side_effect=sql), \
|
||||
mock.patch.object(purchase, '_build_planning_sales_snapshot') as snapshot, \
|
||||
mock.patch.object(purchase, 'add_operation_log'):
|
||||
response = purchase.purchase_batch_add_pending.__wrapped__()
|
||||
self.assertEqual(response.get_json()['data'], {'added': 1, 'skipped': 0})
|
||||
self.assertTrue(any("status='pending'" in q and 'WHERE id=%s' in q for q, _ in writes))
|
||||
snapshot.assert_not_called()
|
||||
|
||||
def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self):
|
||||
for value in ('新品', '旧品'):
|
||||
writes = []
|
||||
|
||||
@ -19,3 +19,4 @@
|
||||
{"checked_at": "2026-10-26T09:15:23.729554+08:00", "version": "c3d7f4b283212db955f8d15b681c6cf3fa80e3b2874936002bfc90d06d840f68", "target_inventory": "2026-10-26", "target_outbound": "2026-10-25", "missing": ["outbound:2026-10-25:dymuto", "outbound:2026-10-25:dyqsimelr", "outbound:2026-10-25:jdbz", "outbound:2026-10-25:jdgj", "outbound:2026-10-25:jdjwt", "outbound:2026-10-25:jdmrt", "outbound:2026-10-25:jdsmj", "outbound:2026-10-25:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-26", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-26", "key": "02", "rows": 1760}, {"kind": "inventory", "date": "2026-10-26", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-26", "key": "05", "rows": 1455}, {"kind": "inventory", "date": "2026-10-26", "key": "06", "rows": 909}, {"kind": "inventory", "date": "2026-10-26", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-26", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-26", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-26", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-26", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-26", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-26", "key": "14", "rows": 377}, {"kind": "inventory", "date": "2026-10-26", "key": "15", "rows": 1572}, {"kind": "inventory", "date": "2026-10-26", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-26", "key": "20", "rows": 9389}, {"kind": "inventory", "date": "2026-10-26", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-26", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-26", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-26", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-26", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-26", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-25", "key": "wdt", "rows": 2195}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||
{"checked_at": "2026-10-27T09:15:28.445085+08:00", "version": "5c93a6bf338b2c936ee3f4f14fd0a47c985a093c9ea4887eaeb2331d1e0aaba4", "target_inventory": "2026-10-27", "target_outbound": "2026-10-26", "missing": ["outbound:2026-10-26:dymuto", "outbound:2026-10-26:dyqsimelr", "outbound:2026-10-26:jdbz", "outbound:2026-10-26:jdgj", "outbound:2026-10-26:jdjwt", "outbound:2026-10-26:jdmrt", "outbound:2026-10-26:jdsmj", "outbound:2026-10-26:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-27", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-27", "key": "02", "rows": 1762}, {"kind": "inventory", "date": "2026-10-27", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-27", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-27", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-27", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-27", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-27", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-27", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-27", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-27", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-27", "key": "14", "rows": 379}, {"kind": "inventory", "date": "2026-10-27", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-27", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-27", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-27", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-27", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-27", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-27", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-27", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-27", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-26", "key": "wdt", "rows": 2203}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||
{"checked_at": "2026-10-28T09:15:22.895734+08:00", "version": "1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83", "target_inventory": "2026-10-28", "target_outbound": "2026-10-27", "missing": ["outbound:2026-10-27:dymuto", "outbound:2026-10-27:dyqsimelr", "outbound:2026-10-27:jdbz", "outbound:2026-10-27:jdgj", "outbound:2026-10-27:jdjwt", "outbound:2026-10-27:jdmrt", "outbound:2026-10-27:jdsmj", "outbound:2026-10-27:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-28", "key": "01", "rows": 2381}, {"kind": "inventory", "date": "2026-10-28", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-28", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-28", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-28", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-28", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-28", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-28", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-28", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-28", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-28", "key": "13", "rows": 744}, {"kind": "inventory", "date": "2026-10-28", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-28", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-28", "key": "16", "rows": 276}, {"kind": "inventory", "date": "2026-10-28", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-28", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-28", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-28", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-28", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-28", "key": "29", "rows": 700}, {"kind": "inventory", "date": "2026-10-28", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-27", "key": "wdt", "rows": 2186}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||
{"checked_at": "2026-10-29T09:15:23.959650+08:00", "version": "b0cac8db89c53cafba0449fb85717cd53a9a70f0ac356bfcb1bb12582edf217d", "target_inventory": "2026-10-29", "target_outbound": "2026-10-28", "missing": ["outbound:2026-10-28:dymuto", "outbound:2026-10-28:dyqsimelr", "outbound:2026-10-28:jdbz", "outbound:2026-10-28:jdgj", "outbound:2026-10-28:jdjwt", "outbound:2026-10-28:jdmrt", "outbound:2026-10-28:jdsmj", "outbound:2026-10-28:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-29", "key": "01", "rows": 2384}, {"kind": "inventory", "date": "2026-10-29", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-29", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-29", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-29", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-29", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-29", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-29", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-29", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-29", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-29", "key": "13", "rows": 745}, {"kind": "inventory", "date": "2026-10-29", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-29", "key": "15", "rows": 1577}, {"kind": "inventory", "date": "2026-10-29", "key": "16", "rows": 277}, {"kind": "inventory", "date": "2026-10-29", "key": "20", "rows": 9417}, {"kind": "inventory", "date": "2026-10-29", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-29", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-29", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-29", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-29", "key": "29", "rows": 702}, {"kind": "inventory", "date": "2026-10-29", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-28", "key": "wdt", "rows": 2151}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||
|
||||
@ -1 +1 @@
|
||||
{"inventory:2026-10-28:01":"ee9ffde70d78afd9d4d27d6f1b22e9e0022fdd9c86e5c2c67d7d83f007503993","inventory:2026-10-28:02":"b6f32ab450d9b2f724a2b9ff161fbc1531efd9b279e6d09781d4b3bcb1f1d88f","inventory:2026-10-28:04":"d6ed21cd34cd5c2233762997ab25a36749c50d3e7e21f23451a6ec4f007c8f0e","inventory:2026-10-28:05":"46fe52b48ab93cab631ab1953ddb5c8726ffba25ac041b70bae4c3c6cd0b3e47","inventory:2026-10-28:06":"f0c8c9ead0853fc85c9cf26fe5a507b7aec0d3f2239a104b9b27333d84437198","inventory:2026-10-28:07":"8ac84fc6bedf774a7508ccbeeceb1390157876e326a0a6a2c03ad06e5d050ac1","inventory:2026-10-28:09":"2f0b19ffda192f152a43518da4e45e181a39fb93c790c1d7beaf15dfae57d25f","inventory:2026-10-28:10":"1dce710dbce65d875380ff6ebbc71eaa9b08334be2a86d49bab0b0c8699089ef","inventory:2026-10-28:11":"6b4271d5400dff848b73e41ae9cae8b3e9c4ae5dadf827e55a7ffb8359646078","inventory:2026-10-28:12":"36eccf226239325947a7e174a122d0915e7539e8219b5fb6c7377a31977b8ef6","inventory:2026-10-28:13":"f81ca4914ef3d5b3dcc1a5a89db8e3dbfafec2a06ceea6e165f7b3af44faef96","inventory:2026-10-28:14":"042427a0adf4670d4081d1ca290f85c85540a2b5cdc89ec258ef37aba5616e6e","inventory:2026-10-28:15":"62bc97bbb873b36e2ddddbf28910a4dc4bc8ba4d03bbf499d8ac5ca94adb6ea1","inventory:2026-10-28:16":"921f538bcf32911b784574dae5a1ddad961896fb3f85c5bf6a60adb56b08695e","inventory:2026-10-28:20":"646999613cdddee716b26e44c8df672172b0127b221e1e7730ff6e2867a4c662","inventory:2026-10-28:24":"9447b4d1792fb51f33a1dd197e449a79681403635f1c4aceaec063318c38f1e6","inventory:2026-10-28:25":"db3a781aee1ef0b9b645fcee4841d8e1a4eec508239765175566bb3a86cd2d02","inventory:2026-10-28:26":"36a8c7b6bb21d6a7bc94c593eb51cd0a6d352bea77d770582ef6abfea62be05d","inventory:2026-10-28:27":"5e06568d122df8724d8f4494a12930dedd74a4d29a417046111e74f65848b4f0","inventory:2026-10-28:29":"205a39cf7ec53a49e69e7c62844b0843dd38d6ad743787af510821cc815edfd0","inventory:2026-10-28:35":"e31341fd65088cf85008539e52923049acdd03d00aee97ea785751f2055c95f6","outbound:2026-10-27:wdt":"45dbce5b64f9f35e48475ec85ed3e91fe764c4472450384a59358699aa316883"}
|
||||
{"inventory:2026-10-29:01":"a0756f317b28e6f648e431cd44a894a92ba90dfce9f58508a1c774f9ad6ef0d0","inventory:2026-10-29:02":"ac4b8c3f4bc44f4d13030f253227268f43c7b94821878d1a90c7cecfe1b23cda","inventory:2026-10-29:04":"c899c38489e0f0e6cb35c6d208160bab84bb8653c0ca36a9a0e91ad70eb488f8","inventory:2026-10-29:05":"b6b6a51d9f9806012dae7cfa7701784ee739bc4476d8dde1906b8407a9c9b591","inventory:2026-10-29:06":"ed6fa1662393aa01a9666d5818deeb5cf6fe1b404f569bca83f0a6da50ca0504","inventory:2026-10-29:07":"bd0c62dc8b80a20b0b293e1b9944d45f3f30979750fa4770d3f77f2b2c0e2ace","inventory:2026-10-29:09":"f87c59ef82cc52eba6fba5dfeb519bb75b6f2e4dfaba08a2fa2e4a27a1d6a150","inventory:2026-10-29:10":"bcf8ff6f78ffc223ee6125df2d2b489008424daa1ce2a7f56314620fa4db8cbb","inventory:2026-10-29:11":"dd236bf42adf9b5ec05d5b0109ebef92d8a173fa2285551df38102c2bd5f6818","inventory:2026-10-29:12":"85b18fd3486a8bf6a10de9fd3dfbfc23ea3d3a28371442fd8c058cd954837f36","inventory:2026-10-29:13":"cda0408e658ee9b21e1b67ce189f03ea30ad8be4f9b091a91ccc69992df1cfb3","inventory:2026-10-29:14":"e8bbf760e54c1865b6c02c24259e095d07cb13e7107bb45cd0739ef606c958f7","inventory:2026-10-29:15":"a479de395b7d343952059c58d035bf9245760cf9781460ffb3c2f4212a72fe0b","inventory:2026-10-29:16":"7e2ec0676667fae020ebd26517e404c26b0ceb91f1ab56a460ea7d96809bd449","inventory:2026-10-29:20":"cb5ad1bed854158f723ade267cadcdd44d633015308b13deb97836b2252beb9d","inventory:2026-10-29:24":"7e2a4ee729327513326f2b20f7995a90a5582cf886c921b1d618b5f95fcf60ef","inventory:2026-10-29:25":"7d71ff3dca7adf3069feca3229eac7c59efc5990790219d89ec980d9eb8b4150","inventory:2026-10-29:26":"c297c48aa84a8424a3f7665e2b7eb070df3cce57462900dd797975715f09034f","inventory:2026-10-29:27":"0583cebd82db2a92fa88c3cf989460e3cff37e370ed9eef27e4d373d72325da6","inventory:2026-10-29:29":"14e00982e3e067f8b12a955a1ef8d018aa0dd2b3f56e064eb6b1af3e5b758c6f","inventory:2026-10-29:35":"3390cf1fe2b74c34f6e1f1a421c6e7af3727e0e09e17e046bbc75eab4af4b7e7","outbound:2026-10-28:wdt":"3daad2fd47d7d4c503134f42033027aa4cb6b765078fef214c0cab1bdda78b71"}
|
||||
@ -1 +1 @@
|
||||
{"cache_refreshed":true,"changed":[{"date":"2026-10-28","key":"01","kind":"inventory","rows":2381},{"date":"2026-10-28","key":"02","kind":"inventory","rows":1764},{"date":"2026-10-28","key":"04","kind":"inventory","rows":617},{"date":"2026-10-28","key":"05","kind":"inventory","rows":1458},{"date":"2026-10-28","key":"06","kind":"inventory","rows":911},{"date":"2026-10-28","key":"07","kind":"inventory","rows":348},{"date":"2026-10-28","key":"09","kind":"inventory","rows":473},{"date":"2026-10-28","key":"10","kind":"inventory","rows":220},{"date":"2026-10-28","key":"11","kind":"inventory","rows":242},{"date":"2026-10-28","key":"12","kind":"inventory","rows":314},{"date":"2026-10-28","key":"13","kind":"inventory","rows":744},{"date":"2026-10-28","key":"14","kind":"inventory","rows":382},{"date":"2026-10-28","key":"15","kind":"inventory","rows":1575},{"date":"2026-10-28","key":"16","kind":"inventory","rows":276},{"date":"2026-10-28","key":"20","kind":"inventory","rows":9393},{"date":"2026-10-28","key":"24","kind":"inventory","rows":182},{"date":"2026-10-28","key":"25","kind":"inventory","rows":88},{"date":"2026-10-28","key":"26","kind":"inventory","rows":78},{"date":"2026-10-28","key":"27","kind":"inventory","rows":59},{"date":"2026-10-28","key":"29","kind":"inventory","rows":700},{"date":"2026-10-28","key":"35","kind":"inventory","rows":19},{"date":"2026-10-27","key":"wdt","kind":"outbound","rows":2186}],"checked_at":"2026-10-28T09:15:22.895734+08:00","coverage":{"inventory":21,"outbound":1},"desktop_notification":"delivered","files":22,"missing":["outbound:2026-10-27:dymuto","outbound:2026-10-27:dyqsimelr","outbound:2026-10-27:jdbz","outbound:2026-10-27:jdgj","outbound:2026-10-27:jdjwt","outbound:2026-10-27:jdmrt","outbound:2026-10-27:jdsmj","outbound:2026-10-27:tmgj"],"missing_policy":"skip","state":"success","target_inventory":"2026-10-28","target_outbound":"2026-10-27","version":"1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83"}
|
||||
{"cache_refreshed":true,"changed":[{"date":"2026-10-29","key":"01","kind":"inventory","rows":2384},{"date":"2026-10-29","key":"02","kind":"inventory","rows":1764},{"date":"2026-10-29","key":"04","kind":"inventory","rows":617},{"date":"2026-10-29","key":"05","kind":"inventory","rows":1458},{"date":"2026-10-29","key":"06","kind":"inventory","rows":911},{"date":"2026-10-29","key":"07","kind":"inventory","rows":348},{"date":"2026-10-29","key":"09","kind":"inventory","rows":473},{"date":"2026-10-29","key":"10","kind":"inventory","rows":220},{"date":"2026-10-29","key":"11","kind":"inventory","rows":242},{"date":"2026-10-29","key":"12","kind":"inventory","rows":314},{"date":"2026-10-29","key":"13","kind":"inventory","rows":745},{"date":"2026-10-29","key":"14","kind":"inventory","rows":382},{"date":"2026-10-29","key":"15","kind":"inventory","rows":1577},{"date":"2026-10-29","key":"16","kind":"inventory","rows":277},{"date":"2026-10-29","key":"20","kind":"inventory","rows":9417},{"date":"2026-10-29","key":"24","kind":"inventory","rows":182},{"date":"2026-10-29","key":"25","kind":"inventory","rows":88},{"date":"2026-10-29","key":"26","kind":"inventory","rows":78},{"date":"2026-10-29","key":"27","kind":"inventory","rows":59},{"date":"2026-10-29","key":"29","kind":"inventory","rows":702},{"date":"2026-10-29","key":"35","kind":"inventory","rows":19},{"date":"2026-10-28","key":"wdt","kind":"outbound","rows":2151}],"checked_at":"2026-10-29T09:15:23.959650+08:00","coverage":{"inventory":21,"outbound":1},"desktop_notification":"delivered","files":22,"missing":["outbound:2026-10-28:dymuto","outbound:2026-10-28:dyqsimelr","outbound:2026-10-28:jdbz","outbound:2026-10-28:jdgj","outbound:2026-10-28:jdjwt","outbound:2026-10-28:jdmrt","outbound:2026-10-28:jdsmj","outbound:2026-10-28:tmgj"],"missing_policy":"skip","state":"success","target_inventory":"2026-10-29","target_outbound":"2026-10-28","version":"b0cac8db89c53cafba0449fb85717cd53a9a70f0ac356bfcb1bb12582edf217d"}
|
||||
0
var/stagnant-monthly-snapshot.lock
Normal file
0
var/stagnant-monthly-snapshot.lock
Normal file
Loading…
x
Reference in New Issue
Block a user