dy-dashboard/routes/purchase.py
2026-10-29 11:09:20 +08:00

4858 lines
236 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""采购路由"""
import json
import os
import re
import unicodedata
from datetime import datetime
from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template
from services.db import query, execute, add_operation_log, get_db
from services.field_perms import filter_rows as filter_field_rows
from services.sales_calc import (compute_product_sales_cached,
compute_product_sales_extended_cached)
from services.inventory_calc import (compute_product_inventory_cached,
compute_product_inventory_15,
compute_product_in_transit_cached,
compute_cloud_inventory_cached,
compute_external_inventory_cached,
_determine_trend)
from services.forecast import (compute_daily_forecast, sales_predict_config_cached,
get_global_config)
from routes.auth import login_required, role_required, operation_required
from routes.products import INNER_MATERIAL_NAME_KEYS
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__)
PACKAGING_ORDER_STATUSES = ('未完结', '已完结')
PLANNING_SALES_SNAPSHOT_FIELDS = (
'产品分级', '昨日销量', '销售趋势', '库存', '加工厂库存', '生产在途', '采购在途',
'日销量预测', '3天平均销量', '7天平均销量', '14天平均销量',
'库存可支撑天数', '加工厂库存支撑天数', '总支撑天数', '30天平均销量', '极值判断',
)
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:
where.append('p.product_name=%s')
params.append(product_name)
elif product_type:
where.append('p.product_type=%s')
params.append(product_type)
product_rows = query(f"""
SELECT p.*, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name
FROM products p LEFT JOIN suppliers s ON p.supplier_code=s.supplier_code
WHERE {' AND '.join(where)} ORDER BY p.id LIMIT 1
""", params)
if not product_rows:
raise ValueError(f'未找到货品 {product_code},无法保存销量快照')
planning_row = product_rows[0]
if planning_row.get('created_at'):
value = planning_row['created_at']
planning_row['created_at'] = value.strftime('%Y-%m-%d') if hasattr(value, 'strftime') else str(value)[:10]
if planning_row.get('cost_price') is not None:
planning_row['cost_price'] = float(planning_row['cost_price'])
planning_row['lead_time'] = planning_row.get('lead_time') or 0
planning_row['logistics_time'] = planning_row.get('logistics_time') or 0
planning_row['供应商名称'] = planning_row.get('supplier_name', '')
planning_row['生产货期'] = planning_row['lead_time']
planning_row['物流时间'] = planning_row['logistics_time']
planning_row['创建时间'] = planning_row.get('created_at', '')
planning_row['产品种类'] = get_product_type_cached(
planning_row.get('product_name', ''), planning_row.get('brand', ''),
planning_row.get('product_type'))
planning_row['批注'] = planning_row.get('remark') or ''
planning_row = _enrich_row_with_planning_data(
planning_row,
compute_product_sales_cached(),
compute_product_inventory_cached(),
compute_product_inventory_15(),
compute_product_in_transit_cached(),
{r['trend_type']: r for r in sales_predict_config_cached()},
get_global_config(),
cloud_inv_map=compute_cloud_inventory_cached(),
ext_inv_map=compute_external_inventory_cached(),
extended_sales=compute_product_sales_extended_cached(),
)
return {field: planning_row.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS}
# 海外产品清单专属备货通知:名单是非敏感业务配置,Webhook/加签只从运行环境读取,
# 不写入代码、仓库或普通配置文件,避免与采购跟单群机器人混用。
OVERSEAS_NOTIFY_NAMES_FILE = os.path.join(
os.path.dirname(os.path.dirname(__file__)), 'config', 'overseas_product_notify_names.json'
)
OVERSEAS_NOTIFY_WEBHOOK_FILE = '/etc/dy-dashboard/credentials/stocking-webhook'
OVERSEAS_NOTIFY_SECRET_FILE = '/etc/dy-dashboard/credentials/stocking-secret'
OVERSEAS_NOTIFY_GROUP_KEY = '__overseas_product_notify__'
def _normalize_overseas_product_name(value):
"""标准化海外产品清单名称,仅处理格式差异,不做模糊截断匹配。"""
text = unicodedata.normalize('NFKC', str(value or '')).casefold()
return re.sub(r'\s+', '', text)
def _read_overseas_notify_credential(path):
"""读取服务器侧受限凭据文件;明文不进入日志或应用响应。"""
try:
with open(path, 'r', encoding='utf-8') as handle:
return handle.read().strip()
except (OSError, UnicodeError):
return ''
def _load_overseas_notify_names():
"""加载海外产品名称名单;文件缺失/格式错误时安全降级为空名单。"""
try:
with open(OVERSEAS_NOTIFY_NAMES_FILE, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
names = payload.get('names', []) if isinstance(payload, dict) else payload
return {
_normalize_overseas_product_name(name)
for name in names
if _normalize_overseas_product_name(name)
}
except (OSError, TypeError, ValueError) as exc:
import logging
logging.warning('[DING_NOTIFY] 海外产品清单加载失败: %s', exc)
return set()
OVERSEAS_NOTIFY_NAMES = _load_overseas_notify_names()
def _is_overseas_notify_product(product_name):
"""按海外产品清单标准化名称判断,未匹配时不触发专属通知。"""
return _normalize_overseas_product_name(product_name) in OVERSEAS_NOTIFY_NAMES
def _require_purchase_operation(page, operation):
"""统一校验下单相关接口的操作权限。"""
if not operation_required(page, operation):
return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"})
return None
def _sync_backup_orderer(product_code, ordered_at, field, value):
"""将订单管理中的下单人同步到对应的下单备份记录。"""
if not product_code or not ordered_at or field not in ('product_orderer', 'packaging_orderer'):
return 0
return query(
f"UPDATE pending_orders SET `{field}`=%s WHERE product_code=%s "
"AND status IN ('ordered','rolled_back') AND ordered_at=%s",
(value or '', product_code, ordered_at)
)
def _latest_domestic_order_values(product_code):
"""读取同一货品编号最近一笔国内订单的单价和仓库。"""
if not product_code:
return {}
rows = query(
"SELECT `单价`, `仓库` FROM domestic_orders "
"WHERE `货品编号`=%s ORDER BY id DESC LIMIT 1",
(product_code,)
)
return rows[0] if rows else {}
def _latest_packaging_order_values(material_code):
"""读取同一包材编码最近一笔订单的单价和到货仓库。"""
if not material_code:
return {}
rows = query(
"SELECT `单价`, `仓库到货` FROM packaging_orders "
"WHERE `编码`=%s ORDER BY id DESC LIMIT 1",
(material_code,)
)
return rows[0] if rows else {}
def _supplier_maps():
"""构建供应商别名→全称,以及全称→简称映射。"""
rows = query("SELECT supplier_name, short_name, contract_short_name FROM suppliers")
full_name_map = {}
short_name_map = {}
for supplier in rows:
full_name = (supplier.get('supplier_name') or '').strip()
if not full_name:
continue
short_name = (supplier.get('short_name') or '').strip()
short_name_map[full_name] = short_name or full_name
for alias in (full_name, short_name, supplier.get('contract_short_name')):
alias = (alias or '').strip()
if alias:
full_name_map[alias] = full_name
return full_name_map, short_name_map
def _supplier_full_name_map():
"""兼容旧调用:构建供应商全称及简称到全称的映射。"""
return _supplier_maps()[0]
def _packaging_supplier_full_name(material_code, fallback='', source_product_code=''):
"""解析包材供应商全称,BOM/名称字段优先于可能已失步的供应商编码。
产品导入或人工编辑历史上可能只更新 ``supplier_name_match``,留下旧的
``supplier_code``。下单时若无条件按编码取供应商,会把正确名称覆盖成旧供应商。
因此优先级固定为:当前成品 BOM 供应商 > 物料供应商名称 > 请求回退值 > 编码绑定。
"""
material_code = str(material_code or '').strip()
source_product_code = str(source_product_code or '').strip()
fallback = str(fallback or '').strip()
if not material_code:
return fallback
rows = []
if source_product_code:
rows = query(
"""SELECT NULLIF(b.supplier, '') AS bom_supplier,
NULLIF(p.supplier_name_match, '') AS product_supplier,
NULLIF(s_code.supplier_name, '') AS code_supplier
FROM bom b
LEFT JOIN products p ON p.product_code=b.material_code
LEFT JOIN suppliers s_code ON s_code.supplier_code=p.supplier_code
WHERE b.product_code=%s AND b.material_code=%s
ORDER BY b.id DESC LIMIT 1""",
(source_product_code, material_code)
)
if not rows:
rows = query(
"""SELECT NULL AS bom_supplier,
NULLIF(p.supplier_name_match, '') AS product_supplier,
NULLIF(s_code.supplier_name, '') AS code_supplier
FROM products p
LEFT JOIN suppliers s_code ON s_code.supplier_code=p.supplier_code
WHERE p.product_code=%s LIMIT 1""",
(material_code,)
)
row = rows[0] if rows else {}
full_name_map, _ = _supplier_maps()
for candidate in (
row.get('bom_supplier'),
row.get('product_supplier'),
fallback,
row.get('code_supplier'),
):
candidate = str(candidate or '').strip()
if candidate:
return full_name_map.get(candidate, candidate)
return ''
# ════════════════════════════════════════════════════════════════
# 确认下单 → 钉钉群备货通知(采购跟单组别 → 对应钉钉群)
# 2026-08-24 旭东确认:群映射如下,通知格式为备货通知(货品编号/货品名称/成品数量/包材数量)
# ════════════════════════════════════════════════════════════════
FOLLOWER_GROUP_MAP = {
'跟单一组': {'group_name': '采购1组(方/余/卢/陈/李)', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=0f7c5e134dc165eb30d580a4da3e1e141ba45c68958c4b35505202a38d6e232c', 'secret': 'SEC8a6886f1dd64cdcb3ad03373f17038c1c482ed7834218ad72ab895ae279fa8cf'},
'跟单二组': {'group_name': '采购2组(刘/闫/许/邓/范)', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=f8314992a2a7ae29d1875ce97becadf2f6fc78cf6234083192eb9625675b3ece', 'secret': 'SEC3f83f35be412b6a977f3f9ac72a6d8e5f850010255adfb04e26029ee89332105'},
'跟单三组': {'group_name': '采购3组(蔡/张/邓/胡/李/张)', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=55df3fb01e5ab317d0ae37a14f28e1de20a981bc2839892b7c9f39446184b4e0', 'secret': 'SECa76a2139a41e16abbf799352526b3215e9843d780cbfd95bf8ca4865c1c1f26b'},
'现货小组': {'group_name': '现货采购群', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=8fd43253139d757ae4d14b71363ea4b40b2e8cc7165160227071d9d077e41088', 'secret': 'SEC14e42f8451d0fab4a572ffbb7b277b83857febc4e4478683fc1dc9518e91c5e9'},
}
def _resolve_follower_group(follower):
"""将采购跟单字段值解析为组别(组名或成员姓名列表 → 组名),解析不到返回 None。"""
if not follower:
return None
follower = str(follower).strip()
if follower in FOLLOWER_GROUP_MAP:
return follower
try:
groups = _personnel_group_members()
label = _personnel_group_for_value(follower, groups)
if label and label in FOLLOWER_GROUP_MAP:
return label
except Exception as e:
import logging
logging.warning(f'[DING_NOTIFY] 组别解析异常: {e}')
return None
def _send_ding_robot(webhook_url, secret, content):
"""通过钉钉机器人 Webhook 发送 markdown 消息;失败仅记日志,不影响主流程。
支持可选加签(secret 为空则跳过验签)。"""
import hashlib
import hmac
import base64
import time
import json
import urllib.parse
import urllib.request
import logging
try:
payload = {
"msgtype": "markdown",
"markdown": {"title": "备货通知", "text": content},
}
url = webhook_url
if secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{secret}'
hmac_code = hmac.new(secret.encode('utf-8'), string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
url = f'{webhook_url}&timestamp={timestamp}&sign={sign}'
req = urllib.request.Request(
url, data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode('utf-8'))
ok = result.get('errcode') == 0
if not ok:
logging.warning(f'[DING_NOTIFY] 机器人返回错误 errcode={result.get("errcode")} errmsg={result.get("errmsg")}')
return ok
except Exception as e:
logging.warning(f'[DING_NOTIFY] 群消息发送异常: {e}')
return False
def _notify_purchase_ready(notify_groups):
"""后台线程:按群聚合发送备货通知(每条商品一段,群内聚合为一条消息)。
钉钉机器人 markdown 单换行不生效,改用 <br> 强制换行,字段逐行展示。"""
import logging
for webhook, info in notify_groups.items():
try:
blocks = [line.replace('\n', '<br>') for line in info['lines']]
if info.get('kind') == 'overseas_product':
# 专属机器人必须单独配置;缺失时不回退到任何现有采购群机器人。
webhook = _read_overseas_notify_credential(OVERSEAS_NOTIFY_WEBHOOK_FILE)
secret = _read_overseas_notify_credential(OVERSEAS_NOTIFY_SECRET_FILE)
if not webhook:
logging.warning('[DING_NOTIFY] 海外产品专属机器人未配置,跳过通知')
continue
content = '备货通知<br><br>' + '<br>'.join(blocks)
_send_ding_robot(webhook, secret, content)
continue
content = '📦 **备货通知**<br><br>' + '<br><br>'.join(blocks)
_send_ding_robot(webhook, info.get('secret', ''), content)
except Exception as e:
logging.warning(f'[DING_NOTIFY] 备货通知发送失败: {e}')
@purchase_bp.route('/purchase')
@login_required
def purchase_page():
if not role_required('purchase'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='purchase')
@purchase_bp.route('/orders')
@login_required
def orders_page():
if not role_required('orders'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='orders')
@purchase_bp.route('/orders/domestic')
@login_required
def orders_domestic_page():
if not role_required('orders_domestic'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='orders_domestic')
@purchase_bp.route('/orders/overseas')
@login_required
def orders_overseas_page():
if not role_required('orders_overseas'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='orders_overseas')
@purchase_bp.route('/orders/packaging')
@login_required
def orders_packaging_page():
if not role_required('orders_packaging'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='orders_packaging')
@purchase_bp.route('/purchase/ready')
@login_required
def purchase_ready_page():
if not role_required('purchase_ready'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='purchase_ready')
@purchase_bp.route('/purchase/confirmed')
@login_required
def purchase_confirmed_page():
if not role_required('purchase'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='purchase_confirmed')
def _select_confirm_bom(request_bom, saved_bom, base_bom):
"""确认下单时按“本次请求 > 待下单已保存数据 > BOM基础清单”选择明细。"""
if request_bom:
return request_bom
if saved_bom:
return saved_bom
return base_bom
def _extract_product_actual_qty(items, product_code):
"""从 BOM 明细中读取明确填写的成品实际下单数量。
返回 None 表示请求没有携带成品行;返回 0 也表示用户明确填写了 0,
不能再回退到旧的正式下单数量。"""
target_code = f'成品_{product_code}'
for item in items or []:
if not isinstance(item, dict) or item.get('material_code') != target_code:
continue
value = item.get('actual_quantity')
if value in (None, ''):
return None
try:
return max(0, int(float(value)))
except (TypeError, ValueError):
return 0
return None
def _normalize_pending_bom_items(items):
"""规范化待下单 BOM,同时保留下单前/后包材数据。"""
normalized = []
for item in items or []:
if not isinstance(item, dict):
continue
material_code = str(item.get('material_code') or '').strip()
if not material_code:
continue
try:
quantity = float(item.get('quantity', item.get('bom_qty', 0)) or 0)
except (TypeError, ValueError):
quantity = 0.0
try:
actual_quantity = int(float(item.get('actual_quantity', 0) or 0))
except (TypeError, ValueError):
actual_quantity = 0
current = {
'material_code': material_code,
'material_name': item.get('material_name', ''),
'quantity': quantity,
'actual_quantity': actual_quantity,
'remark': item.get('remark', ''),
'pkg_remark': item.get('pkg_remark', ''),
'change_manuscript': item.get('change_manuscript', ''),
'purchase_order_no': item.get('purchase_order_no', ''),
}
# 只有请求明确携带时才保存,避免把历史缺失值强行变成 0。
for field in ('pre_pack_qty', 'post_pack_qty'):
if field in item and item.get(field) not in (None, ''):
try:
current[field] = int(float(item.get(field) or 0))
except (TypeError, ValueError):
current[field] = 0
normalized.append(current)
return normalized
def _sync_pending_pre_packaging(product_code, items):
"""把待下单请求里明确提交的下单前包材同步到独立包材结余表。"""
for item in items or []:
material_code = str(item.get('material_code') or '').strip()
if (not material_code or material_code.startswith('成品_')
or 'pre_pack_qty' not in item):
continue
pre_pack_qty = int(item.get('pre_pack_qty') or 0)
pkg_remark = str(item.get('pkg_remark') or '').strip()
query("""INSERT INTO bom_packaging
(product_code, material_code, pre_pack_qty, post_pack_qty, pkg_remark)
VALUES (%s,%s,%s,0,%s)
ON DUPLICATE KEY UPDATE pre_pack_qty=%s, pkg_remark=%s, updated_at=NOW()""",
(product_code, material_code, pre_pack_qty, pkg_remark,
pre_pack_qty, pkg_remark))
def _resolve_confirm_packaging_qty(item, product_actual_qty):
"""Resolve the explicit packaging order quantity without falling back for zero."""
actual = item.get('actual_quantity')
if actual is not None and actual != '':
try:
return max(0, int(float(actual)))
except (TypeError, ValueError):
return 0
try:
material_qty = float(item.get('quantity', item.get('bom_qty', 0)) or 0)
except (TypeError, ValueError):
material_qty = 0
return max(0, int(material_qty * product_actual_qty))
def _resolve_roll_bom_qty(item, bom_qty_map):
"""包材结余滚动用的BOM用量:优先取 bom 表权威值,其次 bom_qty,最后才用请求里的 quantity。
前端历史版本曾把 quantity 写成“实际下单数量”,直接当用量会算出天量负数。
"""
code = str(item.get('material_code') or '').strip()
if code in bom_qty_map:
return bom_qty_map[code]
for key in ('bom_qty', 'quantity'):
try:
value = float(item.get(key) or 0)
except (TypeError, ValueError):
continue
if value:
return value
return 0.0
def _normalize_backup_bom(items, enrich=True, source_product_code='', source_ordered_at=''):
"""Keep only real packaging materials and fill display fields from product data."""
if not items:
return []
result = []
seen = set()
for item in items:
code = str(item.get('material_code') or '').strip()
if not code or code.startswith('成品_') or code in seen:
continue
seen.add(code)
current = dict(item)
current['material_code'] = code
if enrich:
if source_product_code:
price_rows = query(
"""SELECT `单价` FROM packaging_orders
WHERE `编码`=%s AND (`来源产品编码`=%s OR COALESCE(`来源产品编码`, '')='')
ORDER BY (`来源产品编码`=%s) DESC, id DESC LIMIT 1""",
(code, source_product_code, source_product_code)
)
if price_rows:
current['unit_price'] = float(price_rows[0].get('单价') or 0)
rows = query("""SELECT p.product_name, p.cost_price
FROM products p
WHERE p.product_code=%s LIMIT 1""", (code,))
if rows:
product = rows[0]
if not current.get('material_name') or current.get('material_name') == '-':
current['material_name'] = product.get('product_name') or ''
if current.get('unit_price') in (None, ''):
current['unit_price'] = float(product.get('cost_price') or 0)
supplier_full_name = _packaging_supplier_full_name(
code,
current.get('supplier_short_name') or '',
source_product_code,
)
if supplier_full_name:
_, short_name_map = _supplier_maps()
current['supplier_short_name'] = short_name_map.get(supplier_full_name, supplier_full_name)
if source_product_code and source_ordered_at:
orderer_rows = query(
"""SELECT GROUP_CONCAT(DISTINCT NULLIF(`下单人`, '')
ORDER BY id SEPARATOR '、') AS packaging_orderer
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 ''
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)
result.append(current)
return result
# ─── API: 采购订单 ───
def _product_type_rank_sql(alias, preferred_type='成品'):
"""返回同一货品编码下产品档案的确定性优先级。"""
if preferred_type not in ('成品', '包材'):
raise ValueError('不支持的优先货品类型')
fallback_type = '包材' if preferred_type == '成品' else '成品'
return f"""CASE {alias}.product_type
WHEN '{preferred_type}' THEN 0
WHEN '{fallback_type}' THEN 1
ELSE 2
END"""
def _unique_product_join(alias, order_code_expr, preferred_type='成品'):
"""按订单货品编码关联一条产品档案。
同一编码允许同时建立成品和包材档案。订单列表只能关联其中一条产品记录,
否则一条订单会被联表放大成多条,删除时又因为共用同一个订单 ID 而同时消失。
先使用同字符集等值条件命中 product_code 索引,再用 BINARY 保证编码大小写
必须完全一致,避免逐条订单扫描整张产品表。
"""
rank_sql = _product_type_rank_sql('preferred_product', preferred_type)
return f"""LEFT JOIN products {alias}
ON {alias}.product_code = {order_code_expr} COLLATE utf8mb4_0900_ai_ci
AND BINARY {alias}.product_code = BINARY {order_code_expr}
AND {alias}.id = (
SELECT preferred_product.id
FROM products preferred_product
WHERE preferred_product.product_code = {order_code_expr} COLLATE utf8mb4_0900_ai_ci
AND BINARY preferred_product.product_code = BINARY {order_code_expr}
ORDER BY {rank_sql}, preferred_product.id
LIMIT 1
)"""
def _order_table_with_product_type(table, code_field, fallback_code_field=None):
"""构造带产品信息货品类型的订单数据源,确保一条订单只关联一条产品档案。"""
primary_join = _unique_product_join(
'product_type_lookup', f"order_rows.`{code_field}`", '成品'
)
type_expr = 'product_type_lookup.product_type'
if fallback_code_field:
fallback_join = _unique_product_join(
'fallback_product_type_lookup',
f"order_rows.`{fallback_code_field}`",
'成品',
)
type_expr = 'COALESCE(product_type_lookup.product_type, fallback_product_type_lookup.product_type, \'\')'
return (
f"(SELECT order_rows.*, {type_expr} AS `货品类型` FROM {table} order_rows "
f"{primary_join} {fallback_join}) {table}"
)
return (
f"(SELECT order_rows.*, COALESCE({type_expr}, '') AS `货品类型` FROM {table} order_rows "
f"{primary_join}) {table}"
)
def _order_product_type_values(table, code_field, where_extra='', params=None, fallback_code_field=None):
"""按订单筛选条件批量获取货品类型,避免构造整张关联订单表。"""
# 货品类型是低基数字段,统一在数据库完成筛选、关联和去重;即使存在联动
# 条件,也不再把订单编码全部拉回 Python 再分批查询 products。
filtered_source = f"(SELECT `{code_field}` AS primary_code"
if fallback_code_field:
filtered_source += f", `{fallback_code_field}` AS fallback_code"
filtered_source += f" FROM `{table}` WHERE 1=1 {where_extra}) o"
if fallback_code_field:
join_sql = (
f"{_unique_product_join('p_primary', 'o.primary_code', '成品')} "
f"{_unique_product_join('p_fallback', 'o.fallback_code', '成品')}"
)
type_expr = "COALESCE(p_primary.product_type, p_fallback.product_type, '')"
else:
join_sql = _unique_product_join('p', 'o.primary_code', '成品')
type_expr = "COALESCE(p.product_type, '')"
rows = query(
f"SELECT DISTINCT {type_expr} AS product_type FROM {filtered_source} {join_sql} "
f"ORDER BY product_type",
params or None
)
values = [str(row.get('product_type') or '') for row in rows]
result = [value for value in values if value]
if len(result) != len(values):
result.append('__EMPTY__')
return result
def _packaging_order_source():
"""Return packaging orders enriched with product type and finished supplier full name."""
material_product_join = _unique_product_join(
'material_product', "order_rows.`编码`", '包材'
)
finished_product_join = _unique_product_join(
'finished_product', "order_rows.`来源产品编码`", '成品'
)
return f"""(SELECT order_rows.*,
COALESCE(material_product.product_type, finished_product.product_type, '') AS `货品类型`,
COALESCE(NULLIF(finished_supplier.supplier_name, ''),
(SELECT NULLIF(alias_supplier.supplier_name, '')
FROM suppliers alias_supplier
WHERE alias_supplier.supplier_name=finished_product.supplier_name_match
OR alias_supplier.short_name=finished_product.supplier_name_match
OR alias_supplier.contract_short_name=finished_product.supplier_name_match
LIMIT 1),
NULLIF(finished_product.supplier_name_match, ''), '') AS `成品供应商`
FROM packaging_orders order_rows
{material_product_join}
{finished_product_join}
LEFT JOIN suppliers finished_supplier
ON finished_supplier.supplier_code = finished_product.supplier_code) packaging_orders"""
def _set_packaging_finished_supplier(order_id, supplier_value):
"""Update the supplier binding of the finished product linked by a packaging order."""
order_rows = query("SELECT `来源产品编码` FROM packaging_orders WHERE id=%s", (order_id,))
if not order_rows:
raise ValueError('未找到包材订单')
product_code = str(order_rows[0].get('来源产品编码') or '').strip()
if not product_code:
raise ValueError('该包材订单未关联成品,无法修改成品供应商')
supplier_value = str(supplier_value or '').strip()
if not supplier_value:
query("UPDATE products SET supplier_code=NULL, supplier_name_match=NULL WHERE product_code=%s",
(product_code,))
return product_code, ''
suppliers = query("""SELECT supplier_code, supplier_name
FROM suppliers
WHERE supplier_name=%s OR short_name=%s
OR contract_short_name=%s OR supplier_code=%s
ORDER BY CASE WHEN supplier_name=%s THEN 0 ELSE 1 END
LIMIT 1""",
(supplier_value, supplier_value, supplier_value, supplier_value, supplier_value))
if not suppliers:
raise ValueError('未匹配到供应商,请从模糊查询下拉列表中选择')
supplier = suppliers[0]
query("""UPDATE products SET supplier_code=%s, supplier_name_match=%s
WHERE product_code=%s""",
(supplier.get('supplier_code'), supplier.get('supplier_name'), product_code))
return product_code, supplier.get('supplier_name') or ''
def _enrich_order_product_types(rows, code_field, fallback_code_field=None):
"""按订单货品编号批量补充产品信息中的货品类型。"""
codes = list({str(row.get(field) or '').strip() for row in rows for field in
([fallback_code_field, code_field] if fallback_code_field else [code_field])
if field and str(row.get(field) or '').strip()})
if not codes:
return rows
placeholders = ','.join(['%s'] * len(codes))
product_rows = query(
f"SELECT product_code, product_type FROM products WHERE product_code IN ({placeholders})",
codes
)
type_by_code = {str(row.get('product_code') or '').strip(): row.get('product_type') or ''
for row in product_rows}
for row in rows:
if str(row.get('货品类型') or '').strip():
continue
code = str(row.get(code_field) or '').strip()
fallback_code = str(row.get(fallback_code_field) or '').strip() if fallback_code_field else ''
row['货品类型'] = type_by_code.get(code) or type_by_code.get(fallback_code, '')
return rows
def _resolve_order_unit_price(domestic_price, overseas_price):
"""下单备份单价:优先国内订单单价,其次海外订单单价;取不到返回 None。"""
for value in (domestic_price, overseas_price):
if value is None or str(value).strip() == '':
continue
try:
return float(value)
except (TypeError, ValueError):
continue
return None
@purchase_bp.route('/api/purchase/pending_list')
@login_required
def purchase_pending_list():
denied = _require_purchase_operation('purchase', 'view')
if denied:
return denied
"""获取待下单/已下单商品列表"""
status_filter = request.args.get('status', 'pending')
if status_filter == 'backup':
status_sql = "status IN ('ordered', 'rolled_back')"
status_params = ()
else:
status_sql = "status = %s"
status_params = (status_filter,)
bom_select = "bom_items" if status_filter != 'backup' else "NULL AS bom_items"
# 下单备份的单价按下单时点匹配生成的国内/海外订单;两表货品编号列排序规则不同,需显式指定。
price_select = ""
if status_filter == 'backup':
price_select = """,
(SELECT d.`单价` FROM domestic_orders d
WHERE d.`货品编号` = pending_orders.product_code COLLATE utf8mb4_unicode_ci
AND d.`计划下单时间` = pending_orders.ordered_at
ORDER BY d.id LIMIT 1) AS price_domestic,
(SELECT o.`单价` FROM overseas_orders o
WHERE o.`产品编码` = pending_orders.product_code COLLATE utf8mb4_unicode_ci
AND o.`计划下单时间` = pending_orders.ordered_at
ORDER BY o.id LIMIT 1) AS price_overseas"""
rows = query(f"""
SELECT id, product_code, product_name, product_type, product_grade,
supplier_name, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, {bom_select},
production_order_no,
product_orderer, packaging_orderer,
status, created_at, updated_at, ordered_at, remark{price_select}
FROM pending_orders
WHERE {status_sql}
ORDER BY COALESCE(ordered_at, created_at) DESC, created_at DESC, id DESC
""", status_params)
if status_filter == 'backup':
# 备份列表不需要库存、销量、预测及 BOM 富化;详情打开时再按单条记录加载。
for r in rows:
r['suggested_qty'] = int(r.get('suggested_qty') or 0)
r['actual_order_qty'] = int(r.get('actual_order_qty') or 0)
r['product_orderer'] = r.get('product_orderer') or ''
r['packaging_orderer'] = r.get('packaging_orderer') or ''
r['单价'] = _resolve_order_unit_price(r.pop('price_domestic', None), r.pop('price_overseas', None))
for key in ['created_at', 'updated_at', 'ordered_at']:
value = r.get(key)
if value and hasattr(value, 'strftime'):
r[key] = value.strftime('%Y-%m-%d %H:%M:%S')
elif value and not isinstance(value, str):
r[key] = str(value)
return jsonify({"code": 0, "data": rows})
# 补充待下单专用的库存/销量衍生字段,和计划预测使用同一批缓存数据及预测配置。
sales_map = compute_product_sales_cached()
inventory_map = compute_product_inventory_cached()
factory_inventory_map = compute_product_inventory_15()
forecast_config = {r.get('trend_type'): r for r in sales_predict_config_cached()}
global_config = get_global_config()
# 格式化日期时间字段为字符串
for r in rows:
pc = r.get('product_code', '')
sales_row = sales_map.get(pc) or {}
avg3 = float(sales_row.get('3天平均销量', 0) or 0)
avg7 = float(sales_row.get('7天平均销量', 0) or 0)
avg14 = float(sales_row.get('14天平均销量', 0) or 0)
trend = _determine_trend(avg3, avg7, avg14)
forecast = float(compute_daily_forecast({
'3天平均销量': avg3, '7天平均销量': avg7,
'14天平均销量': avg14,
'30天最高销量': float(sales_row.get('30天最高销量', 0) or 0),
'销售趋势': trend
}, forecast_config) or 0)
total_inventory = float(inventory_map.get(pc, 0) or 0) + float(factory_inventory_map.get(pc, 0) or 0)
actual_qty = float(r.get('actual_order_qty') or r.get('formal_order_qty') or 0)
r['inventory_support_days'] = round(total_inventory / forecast, 1) if forecast > 0 else 999
r['stocking_support_days'] = round(actual_qty / forecast, 1) if forecast > 0 else 999
r['avg_3d_sales'] = avg3
r['avg_7d_sales'] = avg7
r['avg_14d_sales'] = avg14
r['sales_trend'] = trend
if r.get('bom_items'):
try:
raw_bom = json.loads(r['bom_items']) if isinstance(r['bom_items'], str) else r['bom_items']
r['bom_items'] = _normalize_backup_bom(
raw_bom,
source_product_code=r['product_code'],
source_ordered_at=r.get('ordered_at')
)
except (TypeError, ValueError):
r['bom_items'] = []
else:
r['bom_items'] = []
r['suggested_qty'] = int(r.get('suggested_qty') or 0)
r['actual_order_qty'] = int(r.get('actual_order_qty') or 0)
# 成品/包材下单人是下单备份独立字段,由对应订单管理修改时同步更新。
r['product_orderer'] = r.get('product_orderer') or ''
r['packaging_orderer'] = r.get('packaging_orderer') or ''
for key in ['created_at', 'updated_at', 'ordered_at']:
v = r.get(key)
if v and hasattr(v, 'strftime'):
r[key] = v.strftime('%Y-%m-%d %H:%M:%S')
elif v and not isinstance(v, str):
r[key] = str(v)
filter_field_rows('purchase_pending', rows, session.get('user_id'), session.get('role'),
page_view=bool(role_required('purchase')),
page_edit=operation_required('purchase', 'edit'))
return jsonify({"code": 0, "data": rows})
@purchase_bp.route('/api/purchase/backup/detail')
@login_required
def purchase_backup_detail():
"""按需获取单条下单备份详情,避免列表加载时逐物料富化全部 BOM。"""
denied = _require_purchase_operation('purchase_ready', 'view')
if denied:
return denied
backup_id = request.args.get('id', type=int)
if not backup_id:
return jsonify({"code": 400, "msg": "缺少备份记录 ID"}), 400
rows = query("""
SELECT id, product_code, product_name, product_type, product_grade,
supplier_name, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items,
production_order_no, product_orderer, packaging_orderer,
status, created_at, updated_at, ordered_at, remark,
sales_snapshot, sales_snapshot_at,
(SELECT d.`单价` FROM domestic_orders d
WHERE d.`货品编号` = pending_orders.product_code COLLATE utf8mb4_unicode_ci
AND d.`计划下单时间` = pending_orders.ordered_at
ORDER BY d.id LIMIT 1) AS price_domestic,
(SELECT o.`单价` FROM overseas_orders o
WHERE o.`产品编码` = pending_orders.product_code COLLATE utf8mb4_unicode_ci
AND o.`计划下单时间` = pending_orders.ordered_at
ORDER BY o.id LIMIT 1) AS price_overseas
FROM pending_orders
WHERE id=%s AND status IN ('ordered', 'rolled_back')
LIMIT 1
""", (backup_id,))
if not rows:
return jsonify({"code": 404, "msg": "未找到下单备份"}), 404
row = rows[0]
try:
raw_bom = json.loads(row['bom_items']) if isinstance(row.get('bom_items'), str) else row.get('bom_items')
row['bom_items'] = _normalize_backup_bom(
raw_bom or [],
source_product_code=row.get('product_code'),
source_ordered_at=row.get('ordered_at')
)
except (TypeError, ValueError):
row['bom_items'] = []
for key in ['created_at', 'updated_at', 'ordered_at', 'sales_snapshot_at']:
value = row.get(key)
if value and hasattr(value, 'strftime'):
row[key] = value.strftime('%Y-%m-%d %H:%M:%S')
elif value and not isinstance(value, str):
row[key] = str(value)
row['suggested_qty'] = int(row.get('suggested_qty') or 0)
row['actual_order_qty'] = int(row.get('actual_order_qty') or 0)
row['单价'] = _resolve_order_unit_price(row.pop('price_domestic', None), row.pop('price_overseas', None))
# 只读取计划预测点击下单时保存的快照;历史备份禁止使用当前数据回填。
try:
raw_snapshot = row.pop('sales_snapshot', None)
row['sales_info'] = json.loads(raw_snapshot) if raw_snapshot else {}
if not isinstance(row['sales_info'], dict):
row['sales_info'] = {}
except (TypeError, ValueError, json.JSONDecodeError):
row['sales_info'] = {}
row['sales_snapshot_missing'] = not bool(row['sales_info'])
return jsonify({"code": 0, "data": row})
@purchase_bp.route('/api/purchase/pending_export')
@login_required
def purchase_pending_export():
"""导出下单备份历史,支持自选字段及下单时间范围。"""
denied = _require_purchase_operation('purchase_ready', 'export')
if denied:
return denied
from io import BytesIO
from flask import send_file
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill
field_defs = {
'product_type': ('产品类型', 'p.product_type', 12),
'product_code': ('货品编号', 'p.product_code', 24),
'product_name': ('货品名称', 'p.product_name', 34),
'ordered_at': ('下单时间', 'p.ordered_at', 20),
'created_by': ('下单人', 'p.created_by', 12),
'product_orderer': ('成品下单人', 'p.product_orderer', 16),
'packaging_orderer': ('包材下单人', 'p.packaging_orderer', 16),
'production_order_no': ('生产单号', 'p.production_order_no', 22),
'suggested_qty': ('建议下单数量', 'p.suggested_qty', 14),
'formal_qty': ('正式下单数量', 'p.formal_order_qty', 14),
'product_grade': ('产品分级', 'p.product_grade', 12),
'supplier_name': ('供应商名称', 'p.supplier_name', 28),
'remark': ('下单备注', 'p.remark', 30),
'lead_time': ('货期', 'p.lead_time', 10),
'logistics_time': ('物流', 'p.logistics_time', 10),
'total_support_days': ('总支撑天数', 'p.total_support_days', 14),
'procurement_follower': ('采购跟单', 'p.procurement_follower', 16),
'procurement_planner': ('采购计划', 'p.procurement_planner', 16),
'platform': ('归属平台', 'p.platform', 14),
'operator': ('运营专员', 'p.operator', 14),
'audit_price': ('是否审核价格', 'p.audit_price', 14),
'created_at': ('加入时间', 'p.created_at', 20),
}
selected = [f for f in request.args.get('fields', '').split(',') if f in field_defs]
if not selected:
return jsonify({'code': 400, 'msg': '请至少选择一个导出字段'}), 400
date_from = request.args.get('date_from', '').strip()
date_to = request.args.get('date_to', '').strip()
where = ["p.status IN ('ordered', 'rolled_back')"]
params = []
if date_from:
where.append('p.ordered_at >= %s'); params.append(date_from + ' 00:00:00')
if date_to:
where.append('p.ordered_at < DATE_ADD(%s, INTERVAL 1 DAY)'); params.append(date_to)
select_sql = ', '.join(field_defs[f][1] + ' AS `' + f + '`' for f in selected)
rows = query(f"SELECT {select_sql} FROM pending_orders p WHERE {' AND '.join(where)} ORDER BY p.ordered_at DESC, p.id DESC", params)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = '下单备份'
ws.append([field_defs[f][0] for f in selected])
for cell in ws[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='1890FF')
cell.alignment = Alignment(horizontal='center', vertical='center')
for row in rows:
values = []
for f in selected:
value = row.get(f, '')
if value is not None and hasattr(value, 'strftime'):
value = value.strftime('%Y-%m-%d %H:%M:%S')
values.append(value if value is not None else '')
ws.append(values)
for idx, f in enumerate(selected, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(idx)].width = field_defs[f][2]
ws.freeze_panes = 'A2'
ws.auto_filter.ref = ws.dimensions
output = BytesIO()
wb.save(output)
output.seek(0)
return send_file(output, as_attachment=True, download_name='下单备份.xlsx',
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@purchase_bp.route('/api/purchase/update_formal_qty', methods=['POST'])
@login_required
def purchase_update_formal_qty():
"""仅更新正式下单数量,不创建待下单记录"""
denied = _require_purchase_operation('purchase', 'edit')
if denied:
return denied
data = request.json
pc = data.get('product_code', '')
qty = int(data.get('formal_order_qty', 0))
if not pc:
return jsonify({"code": 400, "msg": "缺少货品编号"})
# 保存到待下单表(标记为草稿,不显示在待下单列表中)
# 注意:pending_orders 已支持同一商品多次下单(多条 ordered 历史),
# 这里只能更新当前待下单行(pending/draft),不能触碰历史备份行。
existing = query("SELECT id FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft')", (pc,))
if existing:
query("UPDATE pending_orders SET formal_order_qty=%s, updated_at=NOW() WHERE product_code=%s AND status IN ('pending','draft')",
(qty, pc))
else:
# 查询产品信息补全必填字段
prod = query("SELECT product_name, product_type FROM products WHERE product_code=%s", (pc,))
pname = prod[0]['product_name'] if prod else ''
ptype = prod[0]['product_type'] if prod else ''
query("""INSERT INTO pending_orders
(product_code, product_name, product_type, formal_order_qty, status, created_by)
VALUES (%s, %s, %s, %s, 'draft', %s)""",
(pc, pname, ptype, qty, session.get('real_name', '') or session.get('username', '')))
add_operation_log('修改下单数量', f'{pc} → {qty}')
return jsonify({"code": 0, "msg": "已更新"})
@purchase_bp.route('/api/purchase/check_pending')
@login_required
def purchase_check_pending():
denied = _require_purchase_operation('purchase', 'view')
if denied:
return denied
"""检查单个商品是否已在待下单列表"""
code = request.args.get('code', '')
if not code:
return jsonify({"code": 400, "exists": False})
existing = query("SELECT id FROM pending_orders WHERE product_code=%s AND status='pending'", (code,))
return jsonify({"code": 0, "exists": bool(existing)})
@purchase_bp.route('/api/purchase/add_pending', methods=['POST'])
@login_required
def purchase_add_pending():
"""将商品加入待下单列表"""
denied = _require_purchase_operation('purchase', 'add')
if denied:
return denied
data = request.json
pc = data.get('product_code', '')
if not pc:
return jsonify({"code": 400, "msg": "缺少货品编号"})
# 检查是否已存在待下单记录
# 如果是更新操作(携带更新字段),跳过重复检查
keys = set(data.keys())
update_fields = {'formal_order_qty', 'audit_price', 'actual_order_qty', 'suggested_qty', 'remark', 'bom_items'}
is_update = bool(keys & update_fields)
# 先复用当前待下单行,否则复用计划预测保存的草稿;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物料清单
# 如果请求带了bom_items,优先使用
req_bom_items = data.get('bom_items', '')
bom_json = None
bom_items_normalized = []
if req_bom_items:
try:
bom_items_normalized = _normalize_pending_bom_items(
json.loads(req_bom_items) if isinstance(req_bom_items, str) else req_bom_items
)
bom_json = json.dumps(bom_items_normalized, ensure_ascii=False) if bom_items_normalized else None
except:
bom_json = None
if not bom_json:
# 更新已有待下单记录的成品字段时,不得用基础BOM覆盖用户已填写的包材实际下单数量。
# 只有新增记录才从 bom 表初始化;明确提交的 bom_items 已在上方优先处理。
if existing_pending and existing_pending[0].get('bom_items'):
bom_json = existing_pending[0]['bom_items']
else:
bom_items = query("""
SELECT material_code, material_name, quantity
FROM bom WHERE product_code=%s ORDER BY material_code
""", (pc,))
if bom_items:
bom_items_res = [{'material_code': r['material_code'], 'material_name': r['material_name'], 'quantity': float(r['quantity'])} for r in bom_items]
if bom_items_res:
bom_json = json.dumps(bom_items_res, ensure_ascii=False)
# 从请求数据取值,空字段从 products 表补全
pn = data.get('product_name', '')
pt = data.get('product_type', '')
pg = data.get('product_grade', '')
sn = data.get('supplier_name', '')
lt = data.get('lead_time', 0)
lo = data.get('logistics_time', 0)
ts = data.get('total_support_days', 0)
pf = data.get('procurement_follower', '')
pp = data.get('procurement_planner', '')
pl = data.get('platform', '')
op = data.get('operator', '')
if not pn or not pt:
prod = query("SELECT product_name, product_type, supplier_name_match, lead_time, logistics_time, procurement_follower, procurement_planner, platform, operator FROM products WHERE product_code=%s", (pc,))
if prod:
p = prod[0]
if not pn: pn = p['product_name'] or ''
if not pt: pt = p['product_type'] or ''
if not sn: sn = p['supplier_name_match'] or ''
if not lt: lt = int(p['lead_time'] or 0)
if not lo: lo = int(p['logistics_time'] or 0)
if not pf: pf = p['procurement_follower'] or ''
if not pp: pp = p['procurement_planner'] or ''
if not pl: pl = p['platform'] or ''
if not op: op = p['operator'] or ''
formal_qty = int(data.get('formal_order_qty', 0))
suggested_qty = int(data.get('suggested_qty', 0))
actual_order_qty = int(data.get('actual_order_qty', 0))
created_by = session.get('real_name', '') or session.get('username', '')
remark_val = data.get('remark', '')
audit_price = float(data.get('audit_price', 0))
from services.planning_order_flag import snapshot_new_old
new_old_product = snapshot_new_old(pc, existing_pending[0].get('new_old_product') if existing_pending else None)
existing_sales_snapshot = existing_pending[0].get('sales_snapshot') if existing_pending else None
if existing_sales_snapshot:
sales_snapshot_json = existing_sales_snapshot
sales_snapshot_at = existing_pending[0].get('sales_snapshot_at') or datetime.now()
else:
try:
sales_snapshot = _build_planning_sales_snapshot(pc, pn, pt)
except Exception as exc:
return jsonify({"code": 500, "msg": f"销量信息快照保存失败:{exc}"})
sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str)
sales_snapshot_at = datetime.now()
# 已存在待下单行时按 id 更新,避免依赖 product_code 唯一索引;
# 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。
if existing_pending:
query("""
UPDATE pending_orders SET
product_name=%s, product_type=%s, product_grade=%s, supplier_name=%s,
lead_time=%s, logistics_time=%s, total_support_days=%s,
procurement_follower=%s, procurement_planner=%s, platform=%s, operator=%s, created_by=%s,
audit_price=%s, formal_order_qty=%s, suggested_qty=%s, actual_order_qty=%s, bom_items=%s, remark=%s,
new_old_product=%s, sales_snapshot=%s, sales_snapshot_at=%s,
status='pending', updated_at=NOW()
WHERE id=%s
""", (
pn, pt, pg, sn,
int(lt), int(lo), float(ts),
pf, pp, pl, op, created_by,
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val,
new_old_product, sales_snapshot_json, sales_snapshot_at,
existing_pending[0]['id']
))
else:
query("""
INSERT INTO pending_orders
(product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark,
new_old_product, sales_snapshot, sales_snapshot_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
pc,
pn, pt, pg, sn,
int(lt), int(lo), float(ts),
pf, pp, pl, op,
created_by,
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val,
new_old_product, sales_snapshot_json, sales_snapshot_at
))
if req_bom_items and bom_items_normalized:
_sync_pending_pre_packaging(pc, bom_items_normalized)
return jsonify({"code": 0, "msg": "已加入待下单列表"})
@purchase_bp.route('/api/purchase/update_pending', methods=['POST'])
@login_required
def purchase_update_pending():
"""保存计划预测下单信息或更新待下单记录;未下单的内容保存在隐藏草稿中。"""
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 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 = []
# 支持更新的字段
remark = data.get('remark')
if remark is not None:
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, updated_at=NOW() WHERE id=%s", (remark, current['id']))
bom_items = data.get('bom_items')
if bom_items is not None:
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)
try:
old_bom_items = json.loads(current.get('bom_items') or '[]')
except (TypeError, ValueError):
old_bom_items = []
old_by_code = {
item.get('material_code', ''): item
for item in old_bom_items if isinstance(item, dict)
}
new_by_code = {
item.get('material_code', ''): item
for item in bom_items if isinstance(item, dict)
}
def _format_bom_item(item):
code = item.get('material_code') or '未填写编码物料'
name = (item.get('material_name') or '').strip()
desc = code if not name else f'{code} {name}'
sub = []
aq = item.get('actual_quantity')
if aq not in (None, ''):
sub.append(f'实际下单数量 {aq}')
for key, label in (('pre_pack_qty', '前包数量'), ('post_pack_qty', '后包数量')):
v = item.get(key)
if v not in (None, '', 0):
sub.append(f'{label} {v}')
rk = (item.get('remark') or '').strip()
if rk:
sub.append('备注 ' + (rk if len(rk) <= 40 else rk[:40] + '…'))
if sub:
desc += '(' + ';'.join(sub) + ')'
return desc
if not old_by_code and new_by_code:
bom_parts = [_format_bom_item(item) for item in sorted(new_by_code.values(), key=lambda x: x.get('material_code') or '')]
changes.append(f'保存BOM明细:{len(new_by_code)}项(' + ';'.join(bom_parts) + ')')
bom_field_labels = {
'actual_quantity': '实际下单数量',
'remark': '备注',
'pkg_remark': '包材备注',
'change_manuscript': '改稿说明',
'pre_pack_qty': '前包数量',
'post_pack_qty': '后包数量',
}
for material_code in sorted(set(old_by_code) | set(new_by_code)):
old_item = old_by_code.get(material_code)
new_item = new_by_code.get(material_code)
display_code = material_code or '未填写编码物料'
if old_item is None:
if old_by_code:
changes.append(f'BOM新增:{_format_bom_item(new_item)}')
continue
if new_item is None:
changes.append(f'BOM移除:{display_code}')
continue
for field, label in bom_field_labels.items():
old_value = old_item.get(field, '')
new_value = new_item.get(field, '')
if old_value != new_value:
old_display = old_value if old_value not in (None, '') else '(空)'
new_display = new_value if new_value not in (None, '') else '(空)'
changes.append(
f'{display_code} {label}:{old_display} → {new_display}'
)
if product_actual_qty is None:
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, updated_at=NOW() WHERE id=%s",
(product_actual_qty, bom_str, current['id']))
# 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。
_sync_pending_pre_packaging(product_code, normalized_bom_items)
detail = f'更新 {product_code}'
if changes:
detail += ';' + ';'.join(changes)
else:
detail += ';提交内容无变化'
add_operation_log('待下单编辑', detail)
return jsonify({"code": 0, "msg": "更新成功"})
@purchase_bp.route('/api/purchase/remove_pending', methods=['POST'])
@login_required
def purchase_remove_pending():
"""从待下单列表中移除"""
data = request.json
product_codes = data.get('product_codes', [])
if not product_codes:
return jsonify({"code": 400, "msg": "缺少参数"})
# 待下单和下单备份使用不同的删除权限,避免一个权限误覆盖另一类数据。
has_ordered = query(
"SELECT id FROM pending_orders WHERE product_code IN (" + ','.join(['%s'] * len(product_codes)) + ") AND status='ordered' LIMIT 1",
product_codes
)
operation = 'delete' if has_ordered else 'delete'
page = 'purchase_ready' if has_ordered else 'purchase'
denied = _require_purchase_operation(page, operation)
if denied:
return denied
placeholders = ','.join(['%s'] * len(product_codes))
query(f"DELETE FROM pending_orders WHERE product_code IN ({placeholders}) AND status='pending'", product_codes)
for pc in product_codes:
add_operation_log('移出待下单', f'从待下单列表移除 {pc}')
return jsonify({"code": 0, "msg": "已移除"})
@purchase_bp.route('/api/purchase/confirm_order', methods=['POST'])
@login_required
def purchase_confirm_order():
"""确认下单:备份到下单管理,按产品品牌在品牌信息管理中的分类分流到国内/海外订单。"""
denied = _require_purchase_operation('purchase', 'batch_confirm')
if denied:
return denied
data = request.json
product_codes = data.get('product_codes', [])
bom_data_map = data.get('bom_data_map', {}) # 前端传入的BOM修改数据(若存在则优先使用)
if not product_codes:
return jsonify({"code": 400, "msg": "缺少参数"})
# 确认前统一按产品品牌匹配品牌信息管理分类;未分类时阻止确认,避免订单流向错误。
brand_routes = {}
product_names = {}
unresolved_brands = []
for pc in product_codes:
pending_rows = query(
"SELECT product_code, product_name, product_type FROM pending_orders "
"WHERE product_code=%s AND status='pending'",
(pc,),
)
if not pending_rows:
continue
pending = pending_rows[0]
product_rows = []
if pending.get('product_type'):
product_rows = query(
"SELECT brand, product_name FROM products WHERE product_code=%s "
"AND product_type=%s AND deleted_at IS NULL LIMIT 1",
(pc, pending['product_type']),
)
if not product_rows and pending.get('product_name'):
product_rows = query(
"SELECT brand, product_name FROM products WHERE product_code=%s "
"AND product_name=%s AND deleted_at IS NULL LIMIT 1",
(pc, pending['product_name']),
)
if not product_rows:
product_rows = query(
"SELECT brand, product_name FROM products WHERE product_code=%s "
"AND deleted_at IS NULL ORDER BY CASE WHEN TRIM(COALESCE(brand,''))='' THEN 1 ELSE 0 END, id LIMIT 1",
(pc,),
)
brand = (product_rows[0].get('brand') or '').strip() if product_rows else ''
product_names[pc] = (product_rows[0].get('product_name') or '') if product_rows else ''
brand_info = query("SELECT category FROM brand_information WHERE CONVERT(brand USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(%s USING utf8mb4) COLLATE utf8mb4_unicode_ci LIMIT 1", (brand,)) if brand else []
category = (brand_info[0].get('category') or '').strip() if brand_info else ''
if category not in ('国内', '海外'):
unresolved_brands.append(brand or f'货品编号:{pc}')
else:
brand_routes[pc] = category
if unresolved_brands:
names = '、'.join(dict.fromkeys(unresolved_brands))
return jsonify({"code": 400, "msg": f"以下品牌未在品牌信息管理中完成国内/海外分类,暂不能确认下单:{names}"})
now = datetime.now()
now_str = now.strftime('%Y-%m-%d %H:%M:%S')
today = now.strftime('%Y-%m-%d')
has_product_order = False
has_packaging_order = False
notify_groups = {} # webhook -> {'group_name':..., 'lines':[...]} 备货通知按群聚合
for pc in product_codes:
# 1. 查询待下单记录
rows = query("SELECT * FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
if not rows:
continue
row = rows[0]
request_bom = bom_data_map.get(pc)
request_product_actual_qty = _extract_product_actual_qty(request_bom, pc)
if request_product_actual_qty is not None:
# 用户在下单信息弹窗中明确填写的成品实际下单数量优先于旧快照。
# 这一步必须发生在生成国内/海外订单之前,否则订单和下单备份会沿用旧数量。
row['formal_order_qty'] = request_product_actual_qty
query("UPDATE pending_orders SET formal_order_qty=%s WHERE id=%s AND status='pending'", (request_product_actual_qty, row['id']))
# 1.5 原子抢占:先标记为已下单,防止重复点击/并发请求重复生成订单。
# 只有抢占成功(status 从 pending 变为 ordered)的请求才会继续插入订单,
# 后续重复请求因 status 已非 pending 而跳过。
# 按 id 抢占,确保命中当前待下单行(同商品可能已有历史 ordered 备份行)。
affected = execute(
"UPDATE pending_orders SET ordered_at=%s, status='ordered' WHERE id=%s AND status='pending'",
(now_str, row['id'])
)
if affected == 0:
# 已被其他请求确认,跳过该商品,避免生成重复订单
continue
# 2. 按产品品牌匹配品牌信息管理分类:国内→国内订单,海外→海外订单。
is_overseas = (brand_routes.get(pc) == '海外')
# 内料是否随包材订单同步到包材订单管理:需同时满足海外品牌 + 货品名称含
# 新西兰/新加坡/韩国(与计划预测下单信息弹窗的显示规则一致);
# is_overseas 仍按品牌分类决定订单流向,不受此规则影响。
_inner_name = product_names.get(pc) or (row.get('product_name') or '')
allow_inner_material = is_overseas and any(
_key in _inner_name for _key in INNER_MATERIAL_NAME_KEYS
)
# 3. 计算金额
qty = row.get('formal_order_qty') or 0
price = float(row.get('audit_price') or 0)
amount = qty * price
# 成品数量>0时才创建国内/海外订单
if qty > 0:
has_product_order = True
if is_overseas and qty > 0:
from services.planning_order_flag import snapshot_new_old
new_old_product = snapshot_new_old(pc, row.get('new_old_product'))
query('UPDATE pending_orders SET new_old_product=%s WHERE id=%s', (new_old_product, row['id']))
# 写入海外订单表(含全部字段,日期留空由用户自行填写)
query("""INSERT INTO overseas_orders
(日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号,新旧品)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
None, now_str,
row.get('product_code'), '未完结',
row.get('product_type', ''),
row.get('product_grade', ''),
row.get('lead_time', 0),
row.get('logistics_time', 0),
row.get('total_support_days', 0),
row.get('procurement_planner', ''),
row.get('platform', ''),
row.get('operator', ''),
price,
price,
'未审核',
row.get('suggested_qty', 0),
row.get('actual_order_qty', 0),
qty,
amount,
row.get('supplier_name'),
row.get('product_name'),
row.get('procurement_follower'),
row.get('product_name', ''),
row.get('remark', ''),
row.get('production_order_no', ''),
new_old_product
))
elif not is_overseas and qty > 0:
previous_order = _latest_domestic_order_values(row.get('product_code'))
if previous_order.get('单价') is not None:
price = float(previous_order['单价'])
amount = qty * price
warehouse = previous_order.get('仓库')
# 写入国内订单表(含全部字段,日期留空由用户自行填写)
query("""INSERT INTO domestic_orders
(日期,计划下单时间,货品编号,货品名称,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,仓库,供货商,采购员,下单备注,旺店通生产单号,未到货数,金蝶入库)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
None, now_str,
row.get('product_code'),
row.get('product_name'), '未完结',
row.get('product_type', ''),
row.get('product_grade', ''),
row.get('lead_time', 0),
row.get('logistics_time', 0),
row.get('total_support_days', 0),
row.get('procurement_planner', ''),
row.get('platform', ''),
row.get('operator', ''),
price,
price,
'未审核',
row.get('suggested_qty', 0),
row.get('actual_order_qty', 0),
qty,
amount,
warehouse,
row.get('supplier_name'),
row.get('procurement_follower'),
row.get('remark', ''),
row.get('production_order_no', ''),
qty,
'否'
))
# 4. 根据物料清单生成包材下单(写入包材订单表)
import logging
# 优先使用前端传入的BOM数据(用户在BOM弹窗中编辑过实际下单数量),合并数据库中完整BOM
import json
bom_raw = row.get('bom_items')
# 先从数据库加载完整BOM清单
if bom_raw:
try:
db_bom_list = json.loads(bom_raw) if isinstance(bom_raw, str) else bom_raw
except:
db_bom_list = []
else:
db_bom_list = []
# 当前确认下单请求携带的 bom_data_map 是本次弹窗的最新数据源;
# 未携带时使用待下单记录中已保存的 BOM,确保刷新页面后确认下单仍能带上包材数量和备注。
bom_list = _select_confirm_bom(request_bom, db_bom_list, [])
if not bom_list:
# 待下单记录尚未保存 BOM 时,才回退到当前产品 BOM 基础清单。
bom_rows = query(
"SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s ORDER BY id",
(pc,)
)
bom_list = _select_confirm_bom(request_bom, db_bom_list, [dict(r) for r in bom_rows])
bom_list = _normalize_backup_bom(bom_list)
# 成品实际下单数量优先取下单信息弹窗中的“成品实际下单数量”,
# 未填写时才回退到待下单记录的正式下单数量。
product_actual_qty = _extract_product_actual_qty(bom_list, pc)
if product_actual_qty is None:
product_actual_qty = int(qty or 0)
logging.warning(f'[SYNC_PACKAGING] pc={pc}, formal_qty={qty}, product_actual_qty={product_actual_qty}, bom_list_len={len(bom_list)}')
# BOM用量以 bom 表为权威来源:前端 bom_items 里的 quantity 可能被「实际下单数量」污染,
# 一旦参与「下单后包材 = 下单前 + 包材下单数 - 成品下单数×用量」就会算出天量负数。
bom_qty_map = {}
for _br in query("SELECT material_code, quantity FROM bom WHERE product_code=%s", (pc,)):
try:
bom_qty_map[str(_br.get('material_code') or '').strip()] = float(_br.get('quantity') or 0)
except (TypeError, ValueError):
continue
pack_items = [] # 本次下单包材明细 [(名称, 数量, 下单备注)],备货通知按包材分别展示
for bm in bom_list:
mat_code = bm.get('material_code', '')
mat_name = bm.get('material_name', '')
# 成品汇总项只用于读取成品实际下单数量,不参与包材结余计算或包材下单。
if not mat_code or mat_code.startswith('成品_'):
continue
# 内料默认不参与包材下单(名称含「内料」或编码含 -NL,双条件过滤);
# 海外品牌商品的内料需要显示并同步到包材订单管理,因此不跳过。
is_inner_material = ('内料' in (mat_name or '')) or ('-NL' in (mat_code or ''))
if is_inner_material and not allow_inner_material:
continue
# 明确保存过的实际下单数量优先,0表示不下单;只有未填写时才按用量×成品数量计算
actual_qty = bm.get('actual_quantity')
order_qty = _resolve_confirm_packaging_qty(bm, product_actual_qty)
# 将本次实际下单数量写回 BOM,确保下单备份详情弹窗能正确展示包材下单数量
bm['actual_quantity'] = order_qty
bm['amount'] = round(order_qty * float(bm.get('unit_price') or 0), 2)
if order_qty > 0:
pack_items.append((mat_name or mat_code, order_qty, bm.get('remark') or bm.get('pkg_remark', '') or ''))
print(f'[DEBUG confirm_order] mat={mat_code}, order_qty={order_qty}, act_qty={actual_qty}, formal_qty={qty}', flush=True)
if mat_code:
# 包材结余滚动:本次下单前数量取当前记录(首次由表格导入值提供)。
# 下单后 = 下单前 + 包材实际下单数量 - 成品实际下单数量,
# 并将本次结余作为下一次下单的下单前数量。
pack_rows = query(
"SELECT pre_pack_qty FROM bom_packaging WHERE product_code=%s AND material_code=%s",
(pc, mat_code)
)
pre_pack_qty = int(pack_rows[0].get('pre_pack_qty') or 0) if pack_rows else 0
bom_qty = _resolve_roll_bom_qty(bm, bom_qty_map)
post_pack_qty = pre_pack_qty + order_qty - product_actual_qty * bom_qty
bm['pre_pack_qty'] = pre_pack_qty
bm['post_pack_qty'] = post_pack_qty
query("""INSERT INTO bom_packaging
(product_code, material_code, pre_pack_qty, post_pack_qty)
VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE pre_pack_qty=%s, post_pack_qty=%s, updated_at=NOW()""",
(pc, mat_code, post_pack_qty, post_pack_qty, post_pack_qty, post_pack_qty))
print(f'[PACKAGING_ROLL] pc={pc}, mat={mat_code}, pre={pre_pack_qty}, pack_order={order_qty}, product_order={product_actual_qty}, post={post_pack_qty}', flush=True)
if mat_code and order_qty > 0:
has_packaging_order = True
previous_order = _latest_packaging_order_values(mat_code)
previous_price = previous_order.get('单价')
mat_price = float(previous_price if previous_price is not None else (bm.get('unit_price') or 0))
mat_amount = round(order_qty * mat_price, 2)
mat_supplier = _packaging_supplier_full_name(
mat_code,
bm.get('material_supplier') or bm.get('supplier_short_name') or row.get('supplier_name', ''),
pc,
)
warehouse = previous_order.get('仓库到货')
query("""INSERT INTO packaging_orders
(日期,计划下单时间,下单时间,编码,名称,单价,数量,合计,供应商,仓库到货,下单备注,进度,采购下单人,采购计划,来源产品编码,有无新稿件,是否审核价格)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'未审核')
""", (
None, today, now_str, mat_code, mat_name,
mat_price, order_qty, mat_amount, mat_supplier, warehouse,
bm.get('remark') or bm.get('pkg_remark', ''), '', row.get('procurement_follower'),
row.get('procurement_planner'), pc,
'是' if str(bm.get('change_manuscript') or '').strip() in ('是', '有') else '否'
))
# 5. 保存BOM数据(含更换稿件等)到下单备份。
# 状态和 ordered_at 已在第 1.5 步抢占时更新,这里只更新 BOM(按 id,避免影响历史备份行)。
import json
import decimal
def _json_default(o):
# bom 表 quantity 等字段可能为 Decimal,先转 float 再序列化,避免 500
if isinstance(o, decimal.Decimal):
return float(o)
raise TypeError(f'Object of type {o.__class__.__name__} is not JSON serializable')
bom_str = json.dumps(bom_list, ensure_ascii=False, default=_json_default) if bom_list else None
query("UPDATE pending_orders SET bom_items=%s WHERE id=%s AND status='ordered'",
(bom_str, row['id']))
# 6. 收集钉钉群备货通知(按采购跟单组别聚合,一个群一条消息)
group_label = _resolve_follower_group(row.get('procurement_follower') or '')
if group_label and (qty > 0 or pack_items):
webhook = FOLLOWER_GROUP_MAP[group_label]['webhook']
info = notify_groups.setdefault(webhook, {
'group_name': FOLLOWER_GROUP_MAP[group_label]['group_name'],
'secret': FOLLOWER_GROUP_MAP[group_label].get('secret', ''),
'lines': []
})
product_remark = (row.get('remark') or '').strip() or '无'
if pack_items:
pack_qty_lines = '\n'.join(f"包材数量:{name}:{q}" for name, q, _rm in pack_items)
pack_remark_lines = '\n'.join(f"包材下单备注:{(_rm or '').strip() or '无'}" for name, q, _rm in pack_items)
else:
pack_qty_lines = '包材数量:无'
pack_remark_lines = ''
parts = [
f"货品编号:{row.get('product_code')}",
f"货品名称:{row.get('product_name')}",
f"成品数量:{qty}",
f"成品下单备注:{product_remark}",
pack_qty_lines,
]
if pack_remark_lines:
parts.append(pack_remark_lines)
info['lines'].append('\n'.join(parts))
# 海外产品清单专属通知:与现有按跟单小组的通知分开,不改变原通知内容。
if qty > 0 and _is_overseas_notify_product(row.get('product_name', '')):
overseas_info = notify_groups.setdefault(OVERSEAS_NOTIFY_GROUP_KEY, {
'kind': 'overseas_product',
'lines': []
})
overseas_info['lines'].append('\n'.join([
f"货品编号:{row.get('product_code') or ''}",
f"货品名称:{row.get('product_name') or ''}",
f"下单数量:{product_actual_qty}",
f"采购跟单:{row.get('procurement_follower') or ''}",
f"工厂:{row.get('supplier_name') or ''}",
]))
# 确定前端跳转目标
redirect = None
if has_product_order:
first_pc = product_codes[0] if product_codes else ''
if first_pc:
is_overseas = (brand_routes.get(first_pc) == '海外')
redirect = 'orders_overseas' if is_overseas else 'orders_domestic'
# 7. 后台线程发送钉钉群备货通知(不阻塞下单接口)
if notify_groups:
import threading
threading.Thread(target=_notify_purchase_ready, args=(notify_groups,), daemon=True).start()
return jsonify({
"code": 0,
"msg": f"已确认 {len(product_codes)} 个商品",
"redirect": redirect,
"has_product_order": has_product_order,
"has_packaging_order": has_packaging_order
})
@purchase_bp.route('/api/purchase/create_material_order', methods=['POST'])
@login_required
def purchase_create_material_order():
denied = _require_purchase_operation('purchase', 'create')
if denied:
return denied
"""为物料(BOM配套)生成采购订单"""
data = request.json
pc = data.get('product_code', '')
material_code = data.get('material_code', '')
material_name = data.get('material_name', '')
order_qty = int(data.get('order_qty', 0))
if not material_code or order_qty <= 0:
return jsonify({"code": 400, "msg": "参数错误"})
now = datetime.now()
date_prefix = now.strftime('%Y%m%d')
prefix = f"DYCG{date_prefix}"
last = query("SELECT order_no FROM purchase_orders WHERE order_no LIKE %s ORDER BY id DESC LIMIT 1",
(prefix + '%',))
if last:
last_seq = int(last[0]['order_no'][-4:])
seq = last_seq + 1
else:
seq = 1
order_no = f"{prefix}{seq:04d}"
# 查找物料成本价(作为单价)
prod = query("SELECT cost_price FROM products WHERE product_code=%s", (material_code,))
cost_price = float(prod[0]['cost_price'] or 0) if prod else 0
amount = round(order_qty * cost_price, 2)
try:
order_id = query(
"INSERT INTO purchase_orders (order_no, supplier_name, total_qty, total_amount, created_by) "
"VALUES (%s, %s, %s, %s, %s)",
(order_no, material_name, order_qty, amount, session.get('real_name', ''))
)
query(
"INSERT INTO purchase_order_items (order_id, product_code, product_name, specification, supplier_name, "
"suggested_qty, order_qty, unit_price, amount, lead_time) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(order_id, material_code, material_name, '', material_name,
0, order_qty, cost_price, amount, 0)
)
add_operation_log(f"生成物料采购单 {material_code}", f"数量:{order_qty} 金额:{amount}")
return jsonify({"code": 0, "order_no": order_no, "msg": "物料采购单已生成"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
@purchase_bp.route('/api/purchase/batch_add_pending', methods=['POST'])
@login_required
def purchase_batch_add_pending():
denied = _require_purchase_operation('purchase', 'add')
if denied:
return denied
"""批量将商品加入待下单列表(从产品表同步数据)"""
data = request.json
product_codes = data.get('product_codes', [])
if not product_codes:
return jsonify({"code": 400, "msg": "缺少参数"})
# 查这些产品的基础信息
placeholders = ','.join(['%s'] * len(product_codes))
products = query(f"""
SELECT p.product_code, p.product_name, p.product_type,
p.lead_time, p.logistics_time, p.supplier_name_match AS supplier_name,
p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.planning_new_old
FROM products p WHERE p.product_code IN ({placeholders})
""", product_codes)
prod_map = {r['product_code']: r for r in products}
added = 0
skipped = 0
for pc in product_codes:
# 检查是否已存在
existing = query("SELECT id FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
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,))
import json
bom_data = json.dumps([{
'material_code': r['material_code'],
'material_name': r['material_name'],
'quantity': float(r['quantity'])
} for r in bom_rows], ensure_ascii=False) if bom_rows else None
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,
lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator,
audit_price, formal_order_qty, bom_items, new_old_product,
sales_snapshot, sales_snapshot_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
pc, 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), 0,
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '',
p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data,
normalize_new_old(p.get('planning_new_old')), sales_snapshot_json, sales_snapshot_at
))
added += 1
if added > 0:
add_operation_log('批量加入待下单', f'批量添加 {added} 个商品到待下单列表(跳过 {skipped} 个)')
return jsonify({"code": 0, "data": {"added": added, "skipped": skipped}})
@purchase_bp.route('/api/purchase/create_order', methods=['POST'])
@login_required
def purchase_create_order():
denied = _require_purchase_operation('purchase', 'create')
if denied:
return denied
"""生成采购订单"""
data = request.json
supplier_name = data.get('supplier_name', '')
items = data.get('items', [])
if not supplier_name or not items:
return jsonify({"code": 400, "msg": "参数错误"})
now = datetime.now()
date_prefix = now.strftime('%Y%m%d')
prefix = f"DYCG{date_prefix}"
last = query("SELECT order_no FROM purchase_orders WHERE order_no LIKE %s ORDER BY id DESC LIMIT 1",
(prefix + '%',))
if last:
last_seq = int(last[0]['order_no'][-4:])
seq = last_seq + 1
else:
seq = 1
order_no = f"{prefix}{seq:04d}"
total_qty = sum(item.get('order_qty', 0) for item in items)
total_amount = sum(item.get('order_qty', 0) * item.get('unit_price', 0) for item in items)
try:
order_id = query(
"INSERT INTO purchase_orders (order_no, supplier_name, total_qty, total_amount, created_by) "
"VALUES (%s, %s, %s, %s, %s)",
(order_no, supplier_name, total_qty, round(total_amount, 2), session.get('real_name', ''))
)
for item in items:
query(
"INSERT INTO purchase_order_items (order_id, product_code, product_name, specification, supplier_name, "
"suggested_qty, order_qty, unit_price, amount, lead_time) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(order_id, item.get('product_code', ''), item.get('product_name', ''),
item.get('specification', ''), supplier_name,
item.get('suggested_qty', 0), item.get('order_qty', 0),
item.get('unit_price', 0), round(item.get('order_qty', 0) * item.get('unit_price', 0), 2),
item.get('lead_time', 0))
)
add_operation_log('创建采购订单', f"供应商:{supplier_name} 单号:{order_no} 金额:¥{round(total_amount,2)}")
return jsonify({"code": 0, "msg": "采购订单创建成功", "data": {"order_no": order_no, "order_id": order_id}})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
# ─── API: 采购订单列表 ───
@purchase_bp.route('/api/purchase/orders/list')
@login_required
def purchase_orders_list():
denied = _require_purchase_operation('purchase_ready', 'view')
if denied:
return denied
page = int(request.args.get('page', 1))
size = int(request.args.get('size', 20))
offset = (page - 1) * size
order_type = request.args.get('type', '') # ''=全部, domestic=国内, overseas=海外, packaging=包材
where_extra = ''
params = []
if order_type == 'domestic':
where_extra = "AND (p.product_type IN ('成品','赠品','小样','物料','其他') OR p.product_type IS NULL)"
elif order_type == 'overseas':
where_extra = "AND p.product_type = '成品' AND (i.product_code LIKE '%%-TMGJ' OR i.product_code LIKE '%%-JDGJ' OR i.product_code LIKE '%%-JDBZ' OR i.product_code LIKE '%%-JDJWT')"
elif order_type == 'packaging':
where_extra = "AND p.product_type = '包材'"
total_count = query(f"""
SELECT COUNT(*) AS cnt FROM purchase_order_items i
JOIN purchase_orders o ON i.order_id = o.id
LEFT JOIN products p ON i.product_code = p.product_code
WHERE 1=1 {where_extra}
""", params)
total = total_count[0]['cnt'] if total_count else 0
items = query(f"""
SELECT o.id AS order_id, i.id AS item_id, o.status,
o.order_no, o.supplier_name, o.created_at AS order_time,
i.product_code, i.product_name, i.specification,
i.unit_price, i.order_qty, i.amount, i.lead_time,
COALESCE(p.product_type, '') AS product_type
FROM purchase_order_items i
JOIN purchase_orders o ON i.order_id = o.id
LEFT JOIN products p ON i.product_code = p.product_code
WHERE 1=1 {where_extra}
ORDER BY o.created_at DESC, i.id ASC
LIMIT %s OFFSET %s
""", params + [size, offset])
for row in items:
if row.get('order_time'):
try:
row['order_time'] = row['order_time'].strftime('%Y-%m-%d %H:%M') if hasattr(row['order_time'], 'strftime') else str(row['order_time'])[:16]
except:
pass
return jsonify({"code": 0, "data": {"total": total, "rows": items}})
@purchase_bp.route('/api/purchase/orders/by_product')
@login_required
def purchase_orders_by_product():
denied = _require_purchase_operation('purchase_ready', 'view')
if denied:
return denied
"""根据货品编码查询采购订单在途数据"""
code = request.args.get('code', '')
if not code:
return jsonify({"code": 400, "msg": "参数错误"})
items = query("""
SELECT o.id AS order_id, o.order_no, o.status, o.created_at AS order_time,
i.product_code, i.product_name, i.specification,
i.unit_price, i.order_qty, i.amount, i.lead_time, i.suggested_qty
FROM purchase_order_items i
JOIN purchase_orders o ON i.order_id = o.id
WHERE i.product_code = %s
ORDER BY o.created_at DESC
""", (code,))
for row in items:
if row.get('order_time'):
row['order_time'] = row['order_time'].strftime('%Y-%m-%d') if hasattr(row['order_time'], 'strftime') else str(row['order_time'])[:10]
# 汇总
total_in_transit = sum(r['order_qty'] or 0 for r in items)
total_amount = sum(float(r['amount'] or 0) for r in items)
pending_count = sum(1 for r in items if r['status'] == 'pending')
return jsonify({
"code": 0,
"data": {
"items": items,
"summary": {
"total_orders": len(items),
"total_qty": total_in_transit,
"total_amount": round(total_amount, 2),
"pending_count": pending_count
}
}
})
@purchase_bp.route('/api/purchase/orders/detail')
@login_required
def purchase_orders_detail():
denied = _require_purchase_operation('purchase_ready', 'view')
if denied:
return denied
order_id = request.args.get('id')
if not order_id:
return jsonify({"code": 400, "msg": "参数错误"})
order = query("SELECT * FROM purchase_orders WHERE id=%s", (order_id,))
if not order:
return jsonify({"code": 404, "msg": "订单不存在"})
items = query("SELECT * FROM purchase_order_items WHERE order_id=%s", (order_id,))
return jsonify({"code": 0, "data": {"order": order[0], "items": items}})
@purchase_bp.route('/api/purchase/orders/update_status', methods=['POST'])
@login_required
def purchase_orders_update_status():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
data = request.json
order_id = data.get('id')
status = data.get('status', '')
if not order_id or not status:
return jsonify({"code": 400, "msg": "参数错误"})
query("UPDATE purchase_orders SET status=%s WHERE id=%s", (status, order_id))
add_operation_log('更新订单状态', f"订单ID:{order_id} 状态->{status}")
return jsonify({"code": 0, "msg": "状态已更新"})
@purchase_bp.route('/api/purchase/orders/rollback', methods=['POST'])
@login_required
def purchase_orders_rollback():
denied = _require_purchase_operation('purchase_ready', 'rollback')
if denied:
return denied
data = request.json
order_id = data.get('order_id')
if not order_id:
return jsonify({"code": 400, "msg": "参数错误"})
query("DELETE FROM purchase_order_items WHERE order_id=%s", (order_id,))
query("DELETE FROM purchase_orders WHERE id=%s", (order_id,))
add_operation_log('回退订单', f"订单ID:{order_id} 回退到待下单")
return jsonify({"code": 0, "msg": "已回退到待下单管理"})
@purchase_bp.route('/api/purchase/orders/delete', methods=['POST'])
@login_required
def purchase_orders_delete():
denied = _require_purchase_operation('purchase_ready', 'delete')
if denied:
return denied
data = request.json
order_id = data.get('id')
if not order_id:
return jsonify({"code": 400, "msg": "参数错误"})
query("DELETE FROM purchase_order_items WHERE order_id=%s", (order_id,))
query("DELETE FROM purchase_orders WHERE id=%s", (order_id,))
add_operation_log('删除订单', f"订单ID:{order_id}")
return jsonify({"code": 0, "msg": "删除成功"})
def _append_table_filters(conditions, params, filters, skip_field=None):
"""Append shared list/search/numeric filters with exact list matching."""
if not isinstance(filters, dict):
return
for field, spec in filters.items():
if field == skip_field or field.startswith('_') or not isinstance(spec, dict):
continue
ftype = spec.get('type', '')
if ftype == 'search' and spec.get('value'):
conditions.append(f"`{field}` LIKE %s")
params.append('%' + str(spec['value']) + '%')
elif ftype == 'list' and spec.get('values'):
values = spec['values']
non_empty = [value for value in values if value != '__EMPTY__']
parts = []
if non_empty:
parts.append(f"`{field}` IN (" + ', '.join(['%s'] * len(non_empty)) + ')')
params.extend(non_empty)
if '__EMPTY__' in values:
parts.append(f"(`{field}` IS NULL OR `{field}` = '')")
if parts:
conditions.append('(' + ' OR '.join(parts) + ')')
elif ftype == 'num' and spec.get('cond') and spec.get('val') != '':
cond = spec['cond']
value = str(spec['val'])
op_map = {'eq': '=', 'neq': '!=', 'gt': '>', 'gte': '>=', 'lt': '<', 'lte': '<='}
try:
if cond == 'range':
bounds = [part.strip() for part in value.split(',') if part.strip()]
if len(bounds) == 2:
conditions.append(f"`{field}` BETWEEN %s AND %s")
params.extend([float(bounds[0]), float(bounds[1])])
elif cond in op_map:
conditions.append(f"`{field}` {op_map[cond]} %s")
params.append(float(value))
except ValueError:
continue
_PERSONNEL_FILTER_FIELDS = {
'采购跟单', '采购计划', '采购员', '采购开发', '包材开发', '采购下单人', '运营'
}
_PERSONNEL_FILTER_GROUPS = {
'跟单一组': ['方园霞','邝碧君','余瑞霞','卢浩然'],
'跟单二组': ['许晓珊','刘伟','王加飞','邓文婷'],
'跟单三组': ['闫伟华','蔡少铃','颜于凯','张文菁'],
'现货小组': ['莫碧霞','范亚欢'],
'计划小组': ['汤定毅','李媛','黄静','胡孟怡','张莹纯','范世澄','陈瑜'],
}
def _personnel_filter_groups():
"""读取人员信息管理中的实时分组;读取失败时兼容旧名单。"""
try:
rows = query("SELECT group_name, members_json FROM personnel_groups ORDER BY group_order, group_name")
except Exception:
return _PERSONNEL_FILTER_GROUPS
groups = {}
for row in rows:
try:
members = json.loads(row.get('members_json') or '[]')
except (TypeError, ValueError):
members = []
group_name = str(row.get('group_name') or '').strip()
if group_name:
groups[group_name] = [str(member).strip() for member in members if str(member).strip()]
return groups or _PERSONNEL_FILTER_GROUPS
def _build_exact_list_parts(field, values, params):
"""列表值精确匹配;人员分组值展开为实时组员,并兼容数据库已保存的组名。"""
if not values:
return []
if field in _PERSONNEL_FILTER_FIELDS:
groups = _personnel_filter_groups()
names = []
for selected in values:
# 订单中既有直接保存的组名,也有历史人员姓名,两种口径都要匹配。
names.append(selected)
names.extend(groups.get(selected, []))
names = list(dict.fromkeys(names))
parts = []
for name in names:
parts.append(f"FIND_IN_SET(%s, REPLACE(`{field}`, ',', ',')) > 0")
params.append(name)
return parts
placeholders = ', '.join(['%s'] * len(values))
params.extend(values)
return [f"`{field}` IN ({placeholders})"]
# 订单列表可排序字段白名单(中文列名 → 实际数据库列名)
_ORDER_SORTABLE_FIELDS = {
'domestic': {
'日期', '计划下单时间', '新旧品', '货品编号', '货品名称', '下单备注',
'单价', '是否审核价格', '数量', '金额', '供货商', '仓库', '金蝶采购单',
'采购员', '下单人', '进度', '旺店通生产单号', '是否已录入旺店通',
'采购开发', '实际到货日期', '实际到货数量', '未到货数', '预计到货日期',
'金蝶入库', '状态', '订单下达供应商时间', '待提数量', '货品类型',
},
'overseas': {
'日期', '计划下单时间', '产品编码', '单价', '是否审核价格', '数量',
'金额', '供应商', '仓库', '货品名称', '新旧品', '是否已做采购单',
'是否做入库', '采购员', '下单人', '进度', '下单备注', '采购开发',
'旺店通生产单号', '是否已录入到旺店通', '实际到货日期', '实际到货数量',
'预计到货日期', '未到货数', '状态', '订单下达供应商时间', '待提数量', '货品类型',
},
'packaging': {
'日期', '计划下单时间', '下单时间', '编码', '名称', '货品类型', '新旧品', '单价', '数量', '合计', '供应商',
'下单备注', '仓库到货', '进度', '是否已做采购单', '是否已做入库',
'采购下单人', '旺店通编号', '是否已录入旺店通', '到货日期', '状态',
'做货天数', '包材开发', '待提数量', '实际到货数量', '未到货数',
'是否审核价格', '下单人', '货品类型',
},
}
def _resolve_order_sort(order_filter_str, order_type, default_order):
"""从 order_filter JSON 解析 _sortField/_sortAsc,白名单校验后返回 ORDER BY 子句。
前端排序按钮把排序字段写入 filterState._sortField(字符串)与 _sortAsc(布尔),
序列化到 order_filter 参数中。此前后端只处理 dict 型条件(list/search/num),
排序字段被跳过导致排序失效。这里单独提取并白名单校验,防止 SQL 注入。
"""
sort_field = ''
sort_asc = False
if order_filter_str:
try:
_filters = json.loads(order_filter_str)
sort_field = _filters.get('_sortField') or ''
sort_asc = bool(_filters.get('_sortAsc'))
except Exception:
pass
allowed = _ORDER_SORTABLE_FIELDS.get(order_type, set())
if sort_field in allowed:
direction = 'ASC' if sort_asc else 'DESC'
return f"`{sort_field}` {direction}"
return default_order
# ─── 国内订单管理 API ───
def _apply_order_filter(where, params, order_filter_str, field_map):
"""解析 order_filter JSON 并追加 WHERE 条件到 where/params
field_map: {前端field: 数据库列名}
"""
if not order_filter_str:
return
try:
filters = json.loads(order_filter_str)
except Exception:
return
for field, cond in filters.items():
db_field = field_map.get(field, field)
if not isinstance(cond, dict):
continue
ftype = cond.get('type')
if ftype == 'list':
values = cond.get('values', [])
if values:
placeholders = ', '.join(['%s'] * len(values))
where.append(f"`{db_field}` IN ({placeholders})")
params.extend(values)
elif ftype == 'search':
val = cond.get('value', '')
if val:
where.append(f"`{db_field}` LIKE %s")
params.append(f"%{val}%")
elif ftype == 'num':
op_map = {
'eq': '=', 'neq': '!=', 'gt': '>', 'gte': '>=',
'lt': '<', 'lte': '<='
}
cond_op = cond.get('cond', '')
cond_val = cond.get('val', '')
if cond_op == 'range':
parts = [p.strip() for p in cond_val.split(',') if p.strip()]
if len(parts) == 2:
where.append(f"`{db_field}` >= %s AND `{db_field}` <= %s")
params.extend([float(parts[0]), float(parts[1])])
elif cond_op in op_map:
try:
num_val = float(cond_val)
where.append(f"`{db_field}` {op_map[cond_op]} %s")
params.append(num_val)
except ValueError:
pass
# 强制写入测试:如果没任何条件被加,这里硬塞一个
if not where:
where.append('1=0') # 测试标记:函数被执行
params.append('_debug_apply_executed')
@purchase_bp.route('/api/domestic/orders/list', methods=['GET', 'POST'])
@login_required
def domestic_orders_list():
denied = _require_purchase_operation('orders_domestic', 'view')
if denied:
return denied
"""国内订单列表"""
page = int(request.args.get('page', 1))
size = int(request.args.get('size', 100))
search = request.args.get('search', '')
status_filter = request.args.get('status', '')
supplier_filter = request.args.get('supplier', '')
orderer_filter = request.args.get('orderer', '')
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
order_filter_str = request.args.get('order_filter', '') or ''
if body.get('order_filter') is not None:
order_filter_str = json.dumps(body.get('order_filter') or {}, ensure_ascii=False)
where = []
params = []
if search:
where.append("(`货品编号` LIKE %s OR `货品名称` LIKE %s OR `供货商` LIKE %s OR `下单备注` LIKE %s)")
kw = f'%{search}%'
params.extend([kw, kw, kw, kw])
if status_filter:
where.append("`状态` = %s")
params.append(status_filter)
if supplier_filter:
where.append("`供货商` LIKE %s")
params.append(f'%{supplier_filter}%')
if orderer_filter:
where.append("`下单人` LIKE %s")
params.append(f'%{orderer_filter}%')
# 内联处理表头筛选:直接解析 order_filter_str
if order_filter_str:
import json as _json
try:
_filters = _json.loads(order_filter_str)
for _f, _c in _filters.items():
_db = _f # 前后端字段名一致
if not isinstance(_c, dict):
continue
_t = _c.get('type', '')
if _t == 'search':
_v = _c.get('value', '')
if _v:
where.append('`' + _db + '` LIKE %s')
params.append('%' + _v + '%')
elif _t == 'list':
_vs = _c.get('values', [])
if _vs:
_non_empty = [x for x in _vs if x != '__EMPTY__']
_list_parts = []
if _non_empty:
_list_parts.extend(_build_exact_list_parts(_db, _non_empty, params))
if '__EMPTY__' in _vs:
_list_parts.append("(`" + _db + "` IS NULL OR `" + _db + "` = '')")
if _list_parts:
where.append('(' + ' OR '.join(_list_parts) + ')')
elif _t == 'num':
_op = _c.get('cond', '')
_vl = _c.get('val', '')
_om = {'eq':'=','neq':'!=','gt':'>','gte':'>=','lt':'<','lte':'<='}
if _op == 'range' and _vl:
_parts = [x.strip() for x in _vl.split(',') if x.strip()]
if len(_parts) == 2:
where.append('`' + _db + '` >= %s AND `' + _db + '` <= %s')
params.extend([float(_parts[0]), float(_parts[1])])
elif _op in _om:
try:
_nv = float(_vl)
where.append('`' + _db + '` ' + _om[_op] + ' %s')
params.append(_nv)
except ValueError:
pass
except Exception:
pass
where_clause = " WHERE " + " AND ".join(where) if where else ""
offset = (page - 1) * size
domestic_source = _order_table_with_product_type('domestic_orders', '货品编号')
# 总记录数
cnt = query(f"SELECT COUNT(*) AS cnt FROM {domestic_source}{where_clause}", params)
total = cnt[0]['cnt'] if cnt else 0
# 查询数据
order_by = _resolve_order_sort(order_filter_str, 'domestic', "IFNULL(created_at,'1970-01-01') DESC, `计划下单时间` DESC, `日期` DESC")
rows = query(f"SELECT * FROM {domestic_source}{where_clause} ORDER BY {order_by} LIMIT %s OFFSET %s",
params + [size, offset])
# 兼容历史导入数据:没有到货批次时也按订单数量和实际到货数量补齐未到货数。
# 金蝶入库的业务默认值在旧数据中可能是 NULL,列表边界统一按“否”展示。
for row in rows:
if row.get('金蝶入库') in (None, ''):
row['金蝶入库'] = '否'
if row.get('未到货数') is None:
order_qty = int(row.get('数量') or 0)
arrived_qty = int(row.get('实际到货数量') or 0)
row['未到货数'] = order_qty - arrived_qty
_enrich_order_product_types(rows, '货品编号')
return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}})
@purchase_bp.route('/api/domestic/orders/filters')
@login_required
def domestic_orders_filters():
denied = _require_purchase_operation('orders_domestic', 'view')
if denied:
return denied
"""获取筛选选项"""
suppliers = query("SELECT DISTINCT `供货商` FROM domestic_orders WHERE `供货商` IS NOT NULL AND `供货商` != '' ORDER BY `供货商`")
statuses = query("SELECT DISTINCT `状态` FROM domestic_orders WHERE `状态` IS NOT NULL AND `状态` != '' ORDER BY `状态`")
orderers = query("SELECT DISTINCT `下单人` FROM domestic_orders WHERE `下单人` IS NOT NULL AND `下单人` != '' ORDER BY `下单人`")
return jsonify({
"code": 0,
"data": {
"suppliers": [s['供货商'] for s in suppliers],
"statuses": [s['状态'] for s in statuses],
"orderers": [o['下单人'] for o in orderers]
}
})
@purchase_bp.route('/api/domestic/orders/update', methods=['POST'])
@login_required
def domestic_orders_update():
denied = _require_purchase_operation('orders_domestic', 'edit')
if denied:
return denied
"""更新某一行字段值"""
data = request.json
row_id = data.get('id')
field = data.get('field')
value = data.get('value')
if not row_id or not field:
return jsonify({"code": 400, "msg": "参数错误"})
allowed_fields = [c[0] for c in [
("日期",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),
("platform",1),("订单下达供应商时间",1),("total_support_days",1),("actual_order_qty",1)
]]
if field not in allowed_fields:
return jsonify({"code": 400, "msg": f"不允许修改字段: {field}"})
# 数值字段空字符串转NULL
numeric_fields = ['单价','数量','金额','待提数量','未到货数','实际到货数量','total_support_days','actual_order_qty']
if value == '' and field in numeric_fields:
value = None
orderer_sync = None
if field == '下单人':
rows = query("SELECT `货品编号`, `计划下单时间` FROM domestic_orders WHERE id=%s", (row_id,))
if rows: orderer_sync = (rows[0].get('货品编号'), rows[0].get('计划下单时间'))
if field == '状态':
# 完结日期与状态绑定:已完结记当天,其他状态清空。
if str(value or '').strip() == '已完结':
query("UPDATE domestic_orders SET `状态`=%s, `完结时间`=CURDATE() WHERE id=%s", (value, row_id))
else:
query("UPDATE domestic_orders SET `状态`=%s, `完结时间`=NULL WHERE id=%s", (value, row_id))
else:
query(f"UPDATE domestic_orders SET `{field}`=%s WHERE id=%s", (value, row_id))
if field == '下单人' and orderer_sync:
_sync_backup_orderer(orderer_sync[0], orderer_sync[1], 'product_orderer', value)
if field in ('数量', '实际到货日期', '实际到货数量', '未到货数'):
sync_order_arrival_totals(row_id, 'domestic')
return jsonify({"code": 0, "msg": "更新成功"})
@purchase_bp.route('/api/domestic/orders/update_fields', methods=['POST'])
@login_required
def domestic_orders_update_fields():
"""批量更新国内订单字段(详情弹窗保存用,只记录一条合并日志)"""
denied = _require_purchase_operation('orders_domestic', 'edit')
if denied:
return denied
data = request.json or {}
row_id = data.get('id')
fields = data.get('fields') or {}
if not row_id or not fields:
return jsonify({"code": 400, "msg": "参数错误"})
allowed_fields = [c[0] for c in [
("日期",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),
("platform",1),("订单下达供应商时间",1),("total_support_days",1),("actual_order_qty",1)
]]
numeric_fields = ['单价','数量','金额','待提数量','未到货数','实际到货数量','total_support_days','actual_order_qty']
changed_parts = []
orderer_sync = None
if '下单人' in fields:
rows = query("SELECT `货品编号`, `计划下单时间` FROM domestic_orders WHERE id=%s", (row_id,))
if rows: orderer_sync = (rows[0].get('货品编号'), rows[0].get('计划下单时间'))
has_status_update = '状态' in fields and '状态' in allowed_fields
completes_order = has_status_update and str(fields.get('状态') or '').strip() == '已完结'
for field, value in fields.items():
if field not in allowed_fields:
continue
if value == '' and field in numeric_fields:
value = None
query(f"UPDATE domestic_orders SET `{field}`=%s WHERE id=%s", (value, row_id))
changed_parts.append(f'{field}={value if value is not None else "空"}')
if field in ('数量', '实际到货日期', '实际到货数量', '未到货数'):
sync_order_arrival_totals(row_id, 'domestic')
if has_status_update:
if completes_order:
query("UPDATE domestic_orders SET `完结时间`=CURDATE() WHERE id=%s", (row_id,))
else:
query("UPDATE domestic_orders SET `完结时间`=NULL WHERE id=%s", (row_id,))
changed_parts.append('完结时间=系统自动记录' if completes_order else '完结时间=已清空')
if orderer_sync:
_sync_backup_orderer(orderer_sync[0], orderer_sync[1], 'product_orderer', fields.get('下单人'))
if changed_parts:
add_operation_log('订单字段编辑', ';'.join(changed_parts) + f'(订单ID={row_id})', page='国内订单管理', field=';'.join(fields.keys()))
return jsonify({"code": 0, "msg": "更新成功", "changed": len(changed_parts)})
_ARRIVAL_BATCHES_SCHEMA_READY = False
def _arrival_operator_name():
name = str(session.get('real_name') or '').strip()
if name:
return name
user_id = session.get('user_id')
if user_id:
rows = query("SELECT real_name, username FROM dashboard_users WHERE id=%s", (user_id,))
if rows:
return str(rows[0].get('real_name') or rows[0].get('username') or '').strip()
return str(session.get('username') or '').strip()
def _ensure_arrival_batches_table():
"""Ensure arrival batch storage records which immutable fields were actually filled."""
global _ARRIVAL_BATCHES_SCHEMA_READY
if _ARRIVAL_BATCHES_SCHEMA_READY:
return
query("""CREATE TABLE IF NOT EXISTS arrival_batches (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NOT NULL,
order_type VARCHAR(20) NOT NULL DEFAULT 'domestic',
arrival_date DATE,
arrival_qty INT DEFAULT 0,
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
)""")
columns = query("""SELECT COLUMN_NAME AS field
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='arrival_batches'""")
if 'defective_qty' not in {row.get('field') for row in columns}:
query("""ALTER TABLE arrival_batches
ADD COLUMN defective_qty INT NOT NULL DEFAULT 0 AFTER arrival_qty""")
column_names = {row.get('field') for row in columns}
if 'operator_name' not in column_names:
query("""ALTER TABLE arrival_batches
ADD COLUMN operator_name VARCHAR(100) NOT NULL DEFAULT '' AFTER defective_qty""")
if 'operated_at' not in column_names:
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 _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:
return int(raw), True
except (TypeError, ValueError):
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):
"""只读取当前国内订单确认下单时的包材快照,不用滚动结余回填历史。"""
orders = query("SELECT `货品编号`, `计划下单时间` FROM domestic_orders WHERE id=%s", (order_id,))
if not orders or not orders[0].get('计划下单时间'):
return {}
order = orders[0]
snapshots = query("""
SELECT bom_items FROM pending_orders
WHERE product_code=%s AND ordered_at=%s AND status='ordered'
ORDER BY id DESC LIMIT 2
""", (order['货品编号'], order['计划下单时间']))
if len(snapshots) != 1:
return {}
raw = snapshots[0].get('bom_items')
try:
items = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, ValueError):
return {}
if not isinstance(items, list):
return {}
return {str(item['material_code']): item['pre_pack_qty']
for item in items if isinstance(item, dict) and item.get('material_code')
and item.get('pre_pack_qty') is not None}
def _order_packaging_materials(order_id, order_type):
"""按成品订单的来源编码和确认时间取本批包材,禁止用最新BOM/订单代替历史。"""
fields = {
'domestic': ('domestic_orders', '货品编号'),
'overseas': ('overseas_orders', '产品编码'),
}
if order_type not in fields:
return []
table, code_field = fields[order_type]
orders = query(f'SELECT `{code_field}` AS product_code, `计划下单时间` AS ordered_at '
f'FROM {table} WHERE id=%s', (order_id,))
if not orders:
return []
order = orders[0]
ordered_at = order.get('ordered_at')
if not order.get('product_code') or not ordered_at:
return []
# 仅有日期无法区分同一天的多次确认,不猜测关联关系。
stamp = ordered_at.strftime('%Y-%m-%d %H:%M:%S') if hasattr(ordered_at, 'strftime') else str(ordered_at).strip()
if len(stamp) <= 10:
return []
packages = query("""
SELECT id, `编码` AS material_code, `名称` AS material_name,
`数量` AS order_qty, `实际到货数量` AS arrival_qty,
`下单时间` AS order_time, `到货日期` AS arrival_date, `进度` AS progress
FROM packaging_orders
WHERE `来源产品编码`=%s AND `下单时间`=%s
ORDER BY id
""", (order['product_code'], stamp))
pre_packaging = _domestic_order_pre_packaging(order_id) if order_type == 'domestic' else {}
result = []
for package in packages:
material_code = package.get('material_code') or ''
material_name = package.get('material_name') or ''
if '内料' in material_name or '-NL' in material_code:
continue
def date_text(value, length):
return (value.isoformat(sep=' ') if isinstance(value, datetime)
else str(value or ''))[:length]
result.append({
'packaging_order_id': package['id'],
'material_code': material_code,
'material_name': material_name,
'pre_pack_qty': pre_packaging.get(material_code),
'order_time': date_text(package.get('order_time'), 16),
'order_qty': package.get('order_qty') or 0,
'arrival_qty': package.get('arrival_qty') or 0,
'arrival_date': date_text(package.get('arrival_date'), 10),
'progress': package.get('progress') or '',
})
return result
@purchase_bp.route('/api/domestic/orders/detail_extras')
@login_required
def domestic_orders_detail_extras():
denied = _require_purchase_operation('orders_domestic', 'view')
if denied:
return denied
"""Return arrival batches for one exact order plus its packaging details."""
order_id = request.args.get('order_id', '')
product_code = request.args.get('product_code', '')
order_type = request.args.get('order_type', 'domestic')
if not order_id:
return jsonify({"code": 400, "msg": "缺少订单ID"})
if order_type not in ('domestic', 'overseas', 'packaging'):
return jsonify({"code": 400, "msg": "订单类型错误"})
try:
order_id_int = int(order_id)
except (TypeError, ValueError):
return jsonify({"code": 400, "msg": "订单ID错误"})
_ensure_arrival_batches_table()
arrival_batches = list(query("""
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
""", (order_id_int, order_type)))
for batch in arrival_batches:
batch['from_type'] = 'add'
value = batch.get('arrival_date')
if value and hasattr(value, 'strftime'):
batch['arrival_date'] = value.strftime('%Y-%m-%d')
elif value and isinstance(value, str):
batch['arrival_date'] = value[:10]
if batch.get('operated_at') and hasattr(batch['operated_at'], 'strftime'):
batch['operated_at'] = batch['operated_at'].strftime('%Y-%m-%d %H:%M:%S')
elif batch.get('operated_at') is not None:
batch['operated_at'] = str(batch['operated_at'])[:19]
packaging_materials = _order_packaging_materials(order_id_int, order_type)
return jsonify({"code": 0, "data": {
"arrival_batches": arrival_batches,
"packaging_materials": packaging_materials,
}})
@purchase_bp.route('/api/domestic/orders/add_batch', methods=['POST'])
@login_required
def domestic_orders_add_batch():
denied = _require_purchase_operation('orders_domestic', 'edit')
if denied:
return denied
"""Add an arrival batch scoped to one order type and order ID."""
data = request.json or {}
order_id = data.get('order_id')
order_type = data.get('order_type', 'domestic')
if not order_id:
return jsonify({"code": 400, "msg": "缺少订单ID"})
if order_type not in ('domestic', 'overseas', 'packaging'):
return jsonify({"code": 400, "msg": "订单类型错误"})
_ensure_arrival_batches_table()
try:
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,
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,
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,
"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),
}})
@purchase_bp.route('/api/domestic/orders/update_batch', methods=['POST'])
@login_required
def domestic_orders_update_batch():
denied = _require_purchase_operation('orders_domestic', 'edit')
if denied:
return denied
"""Update only a batch belonging to the current order type."""
data = request.json or {}
batch_id = data.get('id')
order_type = data.get('order_type', 'domestic')
if not batch_id:
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, "
"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()
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, ''):
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, ''):
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 '')
requested_remark = str(data.get('remark') or '')
final_remark = old_remark
if requested_remark != old_remark:
if not requested_remark.startswith(old_remark):
return jsonify({"code": 400, "msg": "历史备注不允许修改,请在备注末尾追加说明"}), 400
added = requested_remark[len(old_remark):].strip()
if added:
trace = f"[{operated_at} {operator_name or '未知账号'}] {added}"
final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace
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(
'到货批次及数量',
f"追加到货批次备注:{added}(批次ID={batch_id},订单ID={current['order_id']})",
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],
"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,
}})
@purchase_bp.route('/api/domestic/orders/delete_batch', methods=['POST'])
@login_required
def domestic_orders_delete_batch():
denied = _require_purchase_operation('orders_domestic', 'edit')
if denied:
return denied
"""Delete only a batch belonging to the current order type."""
data = request.json or {}
batch_id = data.get('id')
order_type = data.get('order_type', 'domestic')
if not batch_id:
return jsonify({"code": 400, "msg": "缺少批次ID"})
_ensure_arrival_batches_table()
batch_rows = query("SELECT order_id FROM arrival_batches WHERE id=%s AND order_type=%s", (batch_id, order_type))
if not batch_rows:
return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"})
query("DELETE FROM arrival_batches WHERE id=%s AND order_type=%s", (batch_id, order_type))
sync_order_arrival_totals(batch_rows[0]['order_id'], order_type)
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
add_operation_log('到货批次及数量', f"删除到货批次(批次ID={batch_id},订单ID={batch_rows[0]['order_id']})", page=page_name, field='到货批次及数量')
return jsonify({"code": 0, "msg": "删除成功"})
def sync_order_arrival_totals(order_id, order_type='domestic'):
"""Sync totals from batches belonging to one exact order."""
table_map = {
'domestic': ('domestic_orders', '实际到货日期'),
'overseas': ('overseas_orders', '实际到货日期'),
'packaging': ('packaging_orders', '到货日期'),
}
if order_type not in table_map:
return
table, date_col = table_map[order_type]
order_rows = query("SELECT `数量` FROM " + table + " WHERE id=%s", (order_id,))
if not order_rows:
return
_ensure_arrival_batches_table()
aggregate = query("""SELECT COUNT(*) AS batch_count,
COALESCE(SUM(arrival_qty - COALESCE(defective_qty, 0)),0) AS total,
MAX(arrival_date) AS latest_date
FROM arrival_batches
WHERE order_id=%s AND order_type=%s""", (order_id, order_type))[0]
order_qty = int(order_rows[0].get('数量') or 0)
# 批次是明细真源;没有批次时保留导入/人工维护的实际到货数量作为计算基础。
if int(aggregate.get('batch_count') or 0) > 0:
total = int(aggregate.get('total') or 0)
latest_date = aggregate.get('latest_date')
else:
current_rows = query(
"SELECT `实际到货数量`, `" + date_col + "` AS current_arrival_date FROM " + table + " WHERE id=%s",
(order_id,)
)
current = current_rows[0] if current_rows else {}
total = int(current.get('实际到货数量') or 0)
latest_date = current.get('current_arrival_date')
unfinished = order_qty - total
query("UPDATE " + table + " SET `实际到货数量`=%s, `未到货数`=%s, `" + date_col + "`=%s WHERE id=%s",
(total, unfinished, latest_date, order_id))
@purchase_bp.route('/api/domestic/orders/add', methods=['POST'])
@login_required
def domestic_orders_add():
denied = _require_purchase_operation('orders_domestic', 'create')
if denied:
return denied
"""新增一行"""
data = request.json or {}
if not data.get('货品编号'):
return jsonify({"code": 400, "msg": "货品编号不能为空"})
if '单价' not in data or '仓库' not in data:
previous_order = _latest_domestic_order_values(data['货品编号'])
if '单价' not in data and previous_order.get('单价') is not None:
data['单价'] = previous_order['单价']
if '仓库' not in data and '仓库' in previous_order:
data['仓库'] = previous_order['仓库']
if data.get('金蝶入库') in (None, ''):
data['金蝶入库'] = '否'
if data.get('数量') is not None and data.get('未到货数') in (None, ''):
data['未到货数'] = int(data.get('数量') or 0) - int(data.get('实际到货数量') or 0)
fields = ["货品编号","货品名称","供货商","数量","单价","金额","日期","计划下单时间","仓库","下单备注",
"新旧品","是否审核价格","金蝶采购单","采购员","下单人","进度","旺店通生产单号",
"是否已录入旺店通","采购开发","实际到货日期","实际到货数量","未到货数",
"预计到货日期","金蝶入库","状态","父记录"]
col_names = []
values = []
params = []
# 默认状态为未完结
has_status = False
for f in fields:
val = data.get(f)
if val is not None:
col_names.append(f"`{f}`")
values.append(f"%s")
params.append(val)
if f == '状态':
has_status = True
# 如果前端没传状态,默认设为未完结
if not has_status:
col_names.append('`状态`')
values.append('%s')
params.append('未完结')
if not col_names:
return jsonify({"code": 400, "msg": "没有可用的字段"})
sql = f"INSERT INTO domestic_orders ({', '.join(col_names)}) VALUES ({', '.join(values)})"
query(sql, params)
return jsonify({"code": 0, "msg": "添加成功"})
@purchase_bp.route('/api/domestic/orders/delete', methods=['POST'])
@login_required
def domestic_orders_delete():
denied = _require_purchase_operation('orders_domestic', 'delete')
if denied:
return denied
"""删除一行"""
data = request.json
row_id = data.get('id')
if not row_id:
return jsonify({"code": 400, "msg": "参数错误"})
query("DELETE FROM domestic_orders WHERE id=%s", (row_id,))
return jsonify({"code": 0, "msg": "删除成功"})
@purchase_bp.route('/api/domestic/orders/by_ids', methods=['POST'])
@login_required
def domestic_orders_by_ids():
denied = _require_purchase_operation('orders_domestic', 'view')
if denied:
return denied
"""根据ID列表获取订单数据(供合同生成弹窗预览用),支持多类型"""
data = request.json
ids = data.get('ids', [])
order_type = data.get('order_type', 'domestic')
if not ids:
return jsonify({"code": 400, "msg": "参数错误"})
ids = [int(i) for i in ids if str(i).isdigit()]
if not ids:
return jsonify({"code": 400, "msg": "无效的ID"})
# 根据类型选择表和字段映射
if order_type == 'overseas':
table = 'overseas_orders'
code_col = '`产品编码`'
name_col = '`货品名称`'
amt_col = '`金额`'
sup_col = '`供应商`'
elif order_type == 'packaging':
table = 'packaging_orders'
code_col = '`编码`'
name_col = '`名称`'
amt_col = '`合计`'
sup_col = '`供应商`'
else:
table = 'domestic_orders'
code_col = '`货品编号`'
name_col = '`货品名称`'
amt_col = '`金额`'
sup_col = '`供货商`'
placeholders = ','.join(['%s'] * len(ids))
rows = query(
f"SELECT id, {code_col} AS `编码`, {name_col} AS `名称`, `数量`, `单价`, {amt_col} AS `金额`, {sup_col} AS `供货商` FROM {table} WHERE id IN ({placeholders}) ORDER BY id",
ids
)
for r in rows:
for k in ['数量', '单价', '金额']:
if r.get(k) is None:
r[k] = 0
else:
try:
r[k] = float(r[k])
except:
r[k] = 0
return jsonify({"code": 0, "data": rows})
# ─── 海外订单管理 API ───
@purchase_bp.route('/api/overseas/orders/list', methods=['GET', 'POST'])
@login_required
def overseas_orders_list():
denied = _require_purchase_operation('orders_overseas', 'view')
if denied:
return denied
"""海外订单列表"""
page = int(request.args.get('page', 1))
size = int(request.args.get('size', 100))
search = request.args.get('search', '')
status_filter = request.args.get('status', '')
supplier_filter = request.args.get('supplier', '')
orderer_filter = request.args.get('orderer', '')
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
order_filter_str = request.args.get('order_filter', '') or ''
if body.get('order_filter') is not None:
order_filter_str = json.dumps(body.get('order_filter') or {}, ensure_ascii=False)
where = []
params = []
if search:
where.append("(`产品编码` LIKE %s OR `货品名称` LIKE %s OR `供应商` LIKE %s OR `下单备注` LIKE %s)")
kw = f'%{search}%'
params.extend([kw, kw, kw, kw])
if status_filter:
where.append("`状态` = %s")
params.append(status_filter)
if supplier_filter:
where.append("`供应商` LIKE %s")
params.append(f'%{supplier_filter}%')
if orderer_filter:
where.append("`下单人` LIKE %s")
params.append(f'%{orderer_filter}%')
# 内联处理表头筛选
if order_filter_str:
import json as _json
try:
_filters = _json.loads(order_filter_str)
for _f, _c in _filters.items():
_db = _f
if not isinstance(_c, dict):
continue
_t = _c.get('type', '')
if _t == 'search':
_v = _c.get('value', '')
if _v:
where.append('`' + _db + '` LIKE %s')
params.append('%' + _v + '%')
elif _t == 'list':
_vs = _c.get('values', [])
if _vs:
_non_empty = [x for x in _vs if x != '__EMPTY__']
_list_parts = []
if _non_empty:
_list_parts.extend(_build_exact_list_parts(_db, _non_empty, params))
if '__EMPTY__' in _vs:
_list_parts.append("(`" + _db + "` IS NULL OR `" + _db + "` = '')")
if _list_parts:
where.append('(' + ' OR '.join(_list_parts) + ')')
elif _t == 'num':
_op = _c.get('cond', '')
_vl = _c.get('val', '')
_om = {'eq':'=','neq':'!=','gt':'>','gte':'>=','lt':'<','lte':'<='}
if _op == 'range' and _vl:
_parts = [x.strip() for x in _vl.split(',') if x.strip()]
if len(_parts) == 2:
where.append('`' + _db + '` >= %s AND `' + _db + '` <= %s')
params.extend([float(_parts[0]), float(_parts[1])])
elif _op in _om:
try:
_nv = float(_vl)
where.append('`' + _db + '` ' + _om[_op] + ' %s')
params.append(_nv)
except ValueError:
pass
except Exception:
pass
where_clause = " WHERE " + " AND ".join(where) if where else ""
offset = (page - 1) * size
overseas_source = _order_table_with_product_type('overseas_orders', '产品编码')
cnt = query(f"SELECT COUNT(*) AS cnt FROM {overseas_source}{where_clause}", params)
total = cnt[0]['cnt'] if cnt else 0
order_by = _resolve_order_sort(order_filter_str, 'overseas', "IFNULL(created_at,'1970-01-01') DESC, `计划下单时间` DESC, `日期` DESC")
rows = query(f"SELECT * FROM {overseas_source}{where_clause} ORDER BY {order_by} LIMIT %s OFFSET %s",
params + [size, offset])
_enrich_order_product_types(rows, '产品编码')
return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}})
@purchase_bp.route('/api/overseas/orders/filters')
@login_required
def overseas_orders_filters():
denied = _require_purchase_operation('orders_overseas', 'view')
if denied:
return denied
suppliers = query("SELECT DISTINCT `供应商` FROM overseas_orders WHERE `供应商` IS NOT NULL AND `供应商` != '' ORDER BY `供应商`")
statuses = query("SELECT DISTINCT `状态` FROM overseas_orders WHERE `状态` IS NOT NULL AND `状态` != '' ORDER BY `状态`")
orderers = query("SELECT DISTINCT `下单人` FROM overseas_orders WHERE `下单人` IS NOT NULL AND `下单人` != '' ORDER BY `下单人`")
return jsonify({"code": 0, "data": {"suppliers": [s['供应商'] for s in suppliers], "statuses": [s['状态'] for s in statuses], "orderers": [o['下单人'] for o in orderers]}})
@purchase_bp.route('/api/overseas/orders/update', methods=['POST'])
@login_required
def overseas_orders_update():
denied = _require_purchase_operation('orders_overseas', 'edit')
if denied:
return denied
data = request.json
row_id = data.get('id')
field = data.get('field')
value = data.get('value')
if not row_id or not field:
return jsonify({"code": 400, "msg": "参数错误"})
allowed_fields = ['日期','计划下单时间','产品编码','单价','数量','金额','供应商','仓库','货品名称','新旧品',
'是否已做采购单','是否做入库','采购员','下单人','进度','下单备注','采购开发',
'旺店通生产单号','是否已录入到旺店通','实际到货日期','实际到货数量','待提数量','未到货数',
'预计到货日期','状态','是否审核价格',
'platform','订单下达供应商时间','total_support_days','lead_time']
if field not in allowed_fields:
return jsonify({"code": 400, "msg": f"不允许修改字段: {field}"})
# 空字符串转NULL(适配日期/数字列)
dt_fields = ['日期','计划下单时间','预计到货日期','实际到货日期','订单下达供应商时间']
numeric_fields = ['单价','数量','金额','实际到货数量','未到货数','待提数量','total_support_days','lead_time']
if value == '' and (field in dt_fields or field in numeric_fields):
value = None
orderer_sync = None
if field == '下单人':
rows = query("SELECT `产品编码`, `计划下单时间` FROM overseas_orders WHERE id=%s", (row_id,))
if rows: orderer_sync = (rows[0].get('产品编码'), rows[0].get('计划下单时间'))
query(f"UPDATE overseas_orders SET `{field}`=%s WHERE id=%s", (value, row_id))
if field == '下单人' and orderer_sync:
_sync_backup_orderer(orderer_sync[0], orderer_sync[1], 'product_orderer', value)
if field in ('数量', '实际到货日期', '实际到货数量', '未到货数'):
sync_order_arrival_totals(row_id, 'overseas')
return jsonify({"code": 0, "msg": "更新成功"})
@purchase_bp.route('/api/overseas/orders/update_fields', methods=['POST'])
@login_required
def overseas_orders_update_fields():
"""批量更新海外订单字段(详情弹窗保存用,只记录一条合并日志)"""
denied = _require_purchase_operation('orders_overseas', 'edit')
if denied:
return denied
data = request.json or {}
row_id = data.get('id')
fields = data.get('fields') or {}
if not row_id or not fields:
return jsonify({"code": 400, "msg": "参数错误"})
allowed_fields = ['日期','计划下单时间','产品编码','单价','数量','金额','供应商','仓库','货品名称','新旧品',
'是否已做采购单','是否做入库','采购员','下单人','进度','下单备注','采购开发',
'旺店通生产单号','是否已录入到旺店通','实际到货日期','实际到货数量','待提数量','未到货数',
'预计到货日期','状态','是否审核价格',
'platform','订单下达供应商时间','total_support_days','lead_time']
dt_fields = ['日期','计划下单时间','预计到货日期','实际到货日期','订单下达供应商时间']
numeric_fields = ['单价','数量','金额','实际到货数量','未到货数','待提数量','total_support_days','lead_time']
changed_parts = []
orderer_sync = None
if '下单人' in fields:
rows = query("SELECT `产品编码`, `计划下单时间` FROM overseas_orders WHERE id=%s", (row_id,))
if rows: orderer_sync = (rows[0].get('产品编码'), rows[0].get('计划下单时间'))
for field, value in fields.items():
if field not in allowed_fields:
continue
if value == '' and (field in dt_fields or field in numeric_fields):
value = None
query(f"UPDATE overseas_orders SET `{field}`=%s WHERE id=%s", (value, row_id))
changed_parts.append(f'{field}={value if value is not None else "空"}')
if field in ('数量', '实际到货日期', '实际到货数量', '未到货数'):
sync_order_arrival_totals(row_id, 'overseas')
if orderer_sync:
_sync_backup_orderer(orderer_sync[0], orderer_sync[1], 'product_orderer', fields.get('下单人'))
if changed_parts:
add_operation_log('订单字段编辑', ';'.join(changed_parts) + f'(订单ID={row_id})', page='海外订单管理', field=';'.join(fields.keys()))
return jsonify({"code": 0, "msg": "更新成功", "changed": len(changed_parts)})
@purchase_bp.route('/api/overseas/orders/add', methods=['POST'])
@login_required
def overseas_orders_add():
denied = _require_purchase_operation('orders_overseas', 'create')
if denied:
return denied
data = request.json
if not data.get('产品编码'):
return jsonify({"code": 400, "msg": "产品编码不能为空"})
fields = ['产品编码','货品名称','供应商','数量','单价','金额','日期','计划下单时间','仓库','下单备注',
'新旧品','是否已做采购单','是否做入库','采购员','下单人','进度','旺店通生产单号',
'是否已录入到旺店通','采购开发','实际到货日期','实际到货数量','待提数量','未到货数','预计到货日期','状态']
col_names, values, params = [], [], []
has_status = False
for f in fields:
val = data.get(f)
if val is not None:
col_names.append(f"`{f}`")
values.append("%s")
params.append(val)
if f == '状态':
has_status = True
# 默认状态为未完结
if not has_status:
col_names.append('`状态`')
values.append('%s')
params.append('未完结')
if not col_names:
return jsonify({"code": 400, "msg": "没有可用的字段"})
query(f"INSERT INTO overseas_orders ({', '.join(col_names)}) VALUES ({', '.join(values)})", params)
return jsonify({"code": 0, "msg": "添加成功"})
@purchase_bp.route('/api/overseas/orders/delete', methods=['POST'])
@login_required
def overseas_orders_delete():
denied = _require_purchase_operation('orders_overseas', 'delete')
if denied:
return denied
row_id = request.json.get('id')
if not row_id:
return jsonify({"code": 400, "msg": "参数错误"})
query("DELETE FROM overseas_orders WHERE id=%s", (row_id,))
return jsonify({"code": 0, "msg": "删除成功"})
@purchase_bp.route('/api/purchase/orders/update_item', methods=['POST'])
@login_required
def purchase_orders_update_item():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
data = request.json
item_id = data.get('item_id')
order_qty = data.get('order_qty')
unit_price = data.get('unit_price')
if not item_id:
return jsonify({"code": 400, "msg": "参数错误"})
if order_qty is not None:
query("UPDATE purchase_order_items SET order_qty=%s WHERE id=%s", (int(order_qty), item_id))
if unit_price is not None:
query("UPDATE purchase_order_items SET unit_price=%s WHERE id=%s", (float(unit_price), item_id))
query("UPDATE purchase_order_items SET amount = order_qty * unit_price WHERE id=%s", (item_id,))
query("""
UPDATE purchase_orders o
SET o.total_qty = (SELECT COALESCE(SUM(order_qty),0) FROM purchase_order_items WHERE order_id=o.id),
o.total_amount = (SELECT COALESCE(SUM(amount),0) FROM purchase_order_items WHERE order_id=o.id)
WHERE o.id = (SELECT order_id FROM purchase_order_items WHERE id=%s)
""", (item_id,))
return jsonify({"code": 0, "msg": "更新成功"})
# ─── 包材订单管理 API ───
@purchase_bp.route('/api/packaging/orders/list', methods=['GET', 'POST'])
@login_required
def packaging_orders_list():
denied = _require_purchase_operation('orders_packaging', 'view')
if denied:
return denied
page = int(request.args.get('page', 1))
size = int(request.args.get('size', 100))
search = request.args.get('search', '')
status_filter = request.args.get('status', '')
supplier_filter = request.args.get('supplier', '')
orderer_filter = request.args.get('orderer', '')
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
order_filter_str = request.args.get('order_filter', '') or ''
if body.get('order_filter') is not None:
order_filter_str = json.dumps(body.get('order_filter') or {}, ensure_ascii=False)
where = []
params = []
if search:
where.append("(`编码` LIKE %s OR `名称` LIKE %s OR `供应商` LIKE %s OR `进度` LIKE %s)")
kw = f'%{search}%'
params.extend([kw, kw, kw, kw])
if status_filter:
where.append("`状态` = %s")
params.append(status_filter)
if supplier_filter:
where.append("`供应商` LIKE %s")
params.append(f'%{supplier_filter}%')
if orderer_filter:
where.append("`采购下单人` LIKE %s")
params.append(f'%{orderer_filter}%')
# 内联处理表头筛选
if order_filter_str:
import json as _json
try:
_filters = _json.loads(order_filter_str)
for _f, _c in _filters.items():
_db = _f
if not isinstance(_c, dict):
continue
_t = _c.get('type', '')
if _t == 'search':
_v = _c.get('value', '')
if _v:
where.append('`' + _db + '` LIKE %s')
params.append('%' + _v + '%')
elif _t == 'list':
_vs = _c.get('values', [])
if _vs:
_non_empty = [x for x in _vs if x != '__EMPTY__']
_list_parts = []
if _non_empty:
_list_parts.extend(_build_exact_list_parts(_db, _non_empty, params))
if '__EMPTY__' in _vs:
_list_parts.append("(`" + _db + "` IS NULL OR `" + _db + "` = '')")
if _list_parts:
where.append('(' + ' OR '.join(_list_parts) + ')')
elif _t == 'num':
_op = _c.get('cond', '')
_vl = _c.get('val', '')
_om = {'eq':'=','neq':'!=','gt':'>','gte':'>=','lt':'<','lte':'<='}
if _op == 'range' and _vl:
_parts = [x.strip() for x in _vl.split(',') if x.strip()]
if len(_parts) == 2:
where.append('`' + _db + '` >= %s AND `' + _db + '` <= %s')
params.extend([float(_parts[0]), float(_parts[1])])
elif _op in _om:
try:
_nv = float(_vl)
where.append('`' + _db + '` ' + _om[_op] + ' %s')
params.append(_nv)
except ValueError:
pass
except Exception:
pass
where_clause = " WHERE " + " AND ".join(where) if where else ""
offset = (page - 1) * size
packaging_source = _packaging_order_source()
cnt = query(f"SELECT COUNT(*) AS cnt FROM {packaging_source}{where_clause}", params)
total = cnt[0]['cnt'] if cnt else 0
order_by = _resolve_order_sort(order_filter_str, 'packaging', '`计划下单时间` DESC, id DESC')
rows = query(f"SELECT * FROM {packaging_source}{where_clause} ORDER BY {order_by} LIMIT %s OFFSET %s", params + [size, offset])
supplier_full_names = _supplier_full_name_map()
for row in rows:
supplier = (row.get('供应商') or '').strip()
if supplier in supplier_full_names:
row['供应商'] = supplier_full_names[supplier]
_enrich_order_product_types(rows, '编码', '来源产品编码')
filter_field_rows('order_packaging', rows, session.get('user_id'), session.get('role'),
page_view=bool(role_required('orders_packaging')),
page_edit=operation_required('orders_packaging', 'edit'))
return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}})
@purchase_bp.route('/api/packaging/orders/filters')
@login_required
def packaging_orders_filters():
denied = _require_purchase_operation('orders_packaging', 'view')
if denied:
return denied
suppliers = query("SELECT DISTINCT `供应商` FROM packaging_orders WHERE `供应商` IS NOT NULL AND `供应商` != '' ORDER BY `供应商`")
supplier_full_names = _supplier_full_name_map()
supplier_options = sorted({
supplier_full_names.get((supplier.get('供应商') or '').strip(), (supplier.get('供应商') or '').strip())
for supplier in suppliers if (supplier.get('供应商') or '').strip()
})
orderers = query("SELECT DISTINCT `采购下单人` FROM packaging_orders WHERE `采购下单人` IS NOT NULL AND `采购下单人` != '' ORDER BY `采购下单人`")
return jsonify({"code": 0, "data": {"suppliers": supplier_options, "statuses": list(PACKAGING_ORDER_STATUSES), "orderers": [o['采购下单人'] for o in orderers]}})
def _sync_packaging_price_to_backup(material_code, source_product_code, unit_price):
"""Sync a packaging order price into the matching ordered backup BOM.
同一商品可能存在多条 ordered 历史备份,这里只同步最新一条(当前批次)。
"""
if not material_code:
return 0
if source_product_code:
backup_rows = query(
"""SELECT id, product_code, bom_items FROM pending_orders
WHERE status='ordered' AND product_code=%s AND bom_items IS NOT NULL""",
(source_product_code,)
)
else:
backup_rows = query(
"""SELECT id, product_code, bom_items FROM pending_orders
WHERE status='ordered' AND bom_items IS NOT NULL"""
)
synced = 0
normalized_price = float(unit_price or 0)
for backup in backup_rows:
try:
bom = json.loads(backup['bom_items']) if isinstance(backup['bom_items'], str) else backup['bom_items']
except (TypeError, ValueError):
continue
changed = False
for item in bom or []:
if str(item.get('material_code') or '').strip() == material_code:
item['unit_price'] = normalized_price
changed = True
if changed:
# 只更新该备份行自身(按 id),避免影响同商品其他历史备份
query("UPDATE pending_orders SET bom_items=%s WHERE id=%s AND status='ordered'",
(json.dumps(bom, ensure_ascii=False), backup['id']))
synced += 1
return synced
@purchase_bp.route('/api/packaging/orders/update', methods=['POST'])
@login_required
def packaging_orders_update():
denied = _require_purchase_operation('orders_packaging', 'edit')
if denied:
return denied
data = request.json
row_id = data.get('id')
field = data.get('field')
value = data.get('value')
if not row_id or not field:
return jsonify({"code": 400, "msg": "参数错误"})
allowed = ['日期','计划下单时间','编码','名称','单价','数量','合计','供应商','成品供应商','仓库到货','进度','是否已做采购单','是否审核价格',
'是否已做入库','采购下单人','下单人','旺店通编号','是否已录入旺店通','到货日期','状态','做货天数','包材开发','下单备注',
'采购计划','产品种类','订单下达时间','有无新稿件','包材异常状态','待提数量','新旧品']
if field not in allowed:
return jsonify({"code": 400, "msg": f"不允许修改字段: {field}"})
if field == '新旧品' and value not in ('新品', '旧品', ''):
return jsonify({"code": 400, "msg": "新旧品只能选择新品、旧品或无"})
if field == '状态' and value not in PACKAGING_ORDER_STATUSES:
return jsonify({"code": 400, "msg": "包材订单状态只能选择未完结或已完结"})
# 数值字段空字符串转NULL
numeric_fields = ['单价','数量','合计']
if value == '' and field in numeric_fields:
value = None
if field == '成品供应商':
try:
product_code, full_name = _set_packaging_finished_supplier(row_id, value)
except ValueError as exc:
return jsonify({"code": 400, "msg": str(exc)})
add_operation_log('订单字段编辑', f'成品供应商={full_name or "空"}(订单ID={row_id},成品编码={product_code})',
page='包材订单管理', field='成品供应商')
return jsonify({"code": 0, "msg": "更新成功", "value": full_name})
packaging_row = None
if field == '单价':
rows = query("SELECT `编码`, `来源产品编码` FROM packaging_orders WHERE id=%s", (row_id,))
if not rows:
return jsonify({"code": 404, "msg": "未找到包材订单"})
packaging_row = rows[0]
orderer_sync = None
if field == '下单人':
rows = query("SELECT `来源产品编码`, `下单时间` FROM packaging_orders WHERE id=%s", (row_id,))
if rows: orderer_sync = (rows[0].get('来源产品编码'), rows[0].get('下单时间'))
query(f"UPDATE packaging_orders SET `{field}`=%s WHERE id=%s", (value, row_id))
if field == '下单人' and orderer_sync:
_sync_backup_orderer(orderer_sync[0], orderer_sync[1], 'packaging_orderer', value)
if field == '数量':
sync_order_arrival_totals(row_id, 'packaging')
synced = 0
if packaging_row:
synced = _sync_packaging_price_to_backup(
str(packaging_row.get('编码') or '').strip(),
str(packaging_row.get('来源产品编码') or '').strip(),
value
)
return jsonify({"code": 0, "msg": "更新成功", "backup_synced": synced})
@purchase_bp.route('/api/packaging/orders/update_fields', methods=['POST'])
@login_required
def packaging_orders_update_fields():
"""批量更新包材订单字段(详情弹窗保存用,只记录一条合并日志)"""
denied = _require_purchase_operation('orders_packaging', 'edit')
if denied:
return denied
data = request.json or {}
row_id = data.get('id')
fields = data.get('fields') or {}
if not row_id or not fields:
return jsonify({"code": 400, "msg": "参数错误"})
allowed = ['日期','计划下单时间','编码','名称','单价','数量','合计','供应商','成品供应商','仓库到货','进度','是否已做采购单','是否审核价格',
'是否已做入库','采购下单人','下单人','旺店通编号','是否已录入旺店通','到货日期','状态','做货天数','包材开发','下单备注',
'采购计划','产品种类','订单下达时间','有无新稿件','包材异常状态','待提数量','新旧品']
numeric_fields = ['单价','数量','合计']
if '新旧品' in fields and fields['新旧品'] not in ('新品', '旧品', ''):
return jsonify({"code": 400, "msg": "新旧品只能选择新品、旧品或无"})
if '状态' in fields and fields['状态'] not in PACKAGING_ORDER_STATUSES:
return jsonify({"code": 400, "msg": "包材订单状态只能选择未完结或已完结"})
changed_parts = []
orderer_sync = None
if '下单人' in fields:
rows = query("SELECT `来源产品编码`, `下单时间` FROM packaging_orders WHERE id=%s", (row_id,))
if rows: orderer_sync = (rows[0].get('来源产品编码'), rows[0].get('下单时间'))
packaging_row = None
if '单价' in fields:
rows = query("SELECT `编码`, `来源产品编码` FROM packaging_orders WHERE id=%s", (row_id,))
if rows:
packaging_row = rows[0]
for field, value in fields.items():
if field not in allowed:
continue
if field == '成品供应商':
try:
product_code, full_name = _set_packaging_finished_supplier(row_id, value)
except ValueError as exc:
return jsonify({"code": 400, "msg": str(exc)})
changed_parts.append(f'成品供应商={full_name or "空"}(成品编码={product_code})')
continue
if value == '' and field in numeric_fields:
value = None
query(f"UPDATE packaging_orders SET `{field}`=%s WHERE id=%s", (value, row_id))
changed_parts.append(f'{field}={value if value is not None else "空"}')
if field == '数量':
sync_order_arrival_totals(row_id, 'packaging')
if packaging_row and '单价' in fields:
_sync_packaging_price_to_backup(
str(packaging_row.get('编码') or '').strip(),
str(packaging_row.get('来源产品编码') or '').strip(),
fields.get('单价')
)
if orderer_sync:
_sync_backup_orderer(orderer_sync[0], orderer_sync[1], 'packaging_orderer', fields.get('下单人'))
if changed_parts:
add_operation_log('订单字段编辑', ';'.join(changed_parts) + f'(订单ID={row_id})', page='包材订单管理', field=';'.join(fields.keys()))
return jsonify({"code": 0, "msg": "更新成功", "changed": len(changed_parts)})
@purchase_bp.route('/api/packaging/orders/add', methods=['POST'])
@login_required
def packaging_orders_add():
denied = _require_purchase_operation('orders_packaging', 'create')
if denied:
return denied
data = request.json or {}
if not data.get('编码'):
return jsonify({"code": 400, "msg": "编码不能为空"})
material_code = data['编码']
previous_order = _latest_packaging_order_values(material_code)
if '单价' not in data and previous_order.get('单价') is not None:
data['单价'] = previous_order['单价']
if '仓库到货' not in data and '仓库到货' in previous_order:
data['仓库到货'] = previous_order['仓库到货']
data['供应商'] = _packaging_supplier_full_name(material_code, data.get('供应商', ''))
fields = ['编码','名称','供应商','数量','单价','合计','日期','计划下单时间','仓库到货','进度','是否已做采购单',
'是否已做入库','采购下单人','旺店通编号','是否已录入旺店通','到货日期','状态','做货天数','包材开发']
col_names, values, params = [], [], []
for f in fields:
val = data.get(f)
if val is not None:
col_names.append(f"`{f}`")
values.append("%s")
params.append(val)
query(f"INSERT INTO packaging_orders ({', '.join(col_names)}) VALUES ({', '.join(values)})", params)
return jsonify({"code": 0, "msg": "添加成功"})
@purchase_bp.route('/api/packaging/orders/delete', methods=['POST'])
@login_required
def packaging_orders_delete():
denied = _require_purchase_operation('orders_packaging', 'delete')
if denied:
return denied
row_id = request.json.get('id')
if not row_id:
return jsonify({"code": 400, "msg": "参数错误"})
query("DELETE FROM packaging_orders WHERE id=%s", (row_id,))
return jsonify({"code": 0, "msg": "删除成功"})
# ─── 成品提货表 API ───
def _finished_order_progress_map():
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
return unfinished_order_progress_map()
def _finished_inventory_maps():
"""提货可用库存排除千羽仓,千羽仓按最新库存日单独汇总。"""
available = dict(compute_cloud_inventory_cached())
rows = query("""
SELECT product_code, SUM(available_qty) AS quantity
FROM inventory_detail
WHERE data_date = (SELECT MAX(data_date) FROM inventory_detail)
AND warehouse_code REGEXP '^[0-9]+$'
AND CAST(warehouse_code AS UNSIGNED) = 35
GROUP BY product_code
""")
qianyu = {r['product_code']: int(r['quantity'] or 0) for r in rows}
cloud_35 = query("""
SELECT warehouse_code FROM warehouses
WHERE warehouse_code REGEXP '^[0-9]+$'
AND CAST(warehouse_code AS UNSIGNED) = 35
AND FIND_IN_SET('云仓', warehouse_property) > 0
LIMIT 1
""")
if cloud_35:
for code, quantity in qianyu.items():
available[code] = available.get(code, 0) - quantity
return available, qianyu
def _finished_planning_rows():
"""以计划预测的成品集合为主,补入提货表的可编辑备注字段。"""
groups = _personnel_group_members()
products = query("""
SELECT p.product_code, p.product_name, p.brand, p.product_type, p.created_at,
p.lead_time AS 生产货期, p.logistics_time AS 物流时间,
COALESCE(NULLIF(s.supplier_name, ''), NULLIF(s.short_name, ''), p.supplier_name_match) AS 供应商名称,
p.procurement_follower AS 采购跟单, p.procurement_planner AS 采购计划
FROM products p
LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code
OR p.supplier_name_match = s.short_name
OR p.supplier_name_match = s.contract_short_name
WHERE p.product_type NOT IN ('包材', '其他')
AND p.product_code NOT IN (SELECT product_code FROM plan_hidden)
AND NOT EXISTS (
SELECT 1 FROM brand_information bi
WHERE CONVERT(TRIM(bi.brand) USING utf8mb4) COLLATE utf8mb4_unicode_ci =
CONVERT(TRIM(p.brand) USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND TRIM(bi.category) = '海外'
)
AND p.deleted_at IS NULL
ORDER BY p.product_code ASC
""")
saved = query("SELECT * FROM finished_products")
saved_by_code = {str(r.get('货品编号') or '').strip(): r for r in saved
if str(r.get('货品编号') or '').strip()}
sales = compute_product_sales_cached()
# 成品提货表的库存/销量字段与“计划预测-国内品”保持云仓口径一致。
# 保留普通库存映射供通用计划字段计算,列表字段在下方明确覆盖为云仓口径。
inv_map = compute_product_inventory_cached()
cloud_inv_map, qianyu_inv_map = _finished_inventory_maps()
from services.inventory_calc import compute_product_inventory_15
inv15_map = compute_product_inventory_15()
transit_map = compute_product_in_transit_cached()
extended_sales = compute_product_sales_extended_cached()
# 成品提货详情和外部“产品进度”列使用同一套国内/海外订单进度来源。
finished_progress_map = _finished_order_progress_map()
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
gc = _ensure_global_config()
result = []
for product in products:
code = product.get('product_code', '')
row = dict(product)
row['产品种类'] = product.get('product_type', '')
row = _enrich_row_with_planning_data(
row, sales, inv_map, inv15_map, transit_map, sp_map, gc,
cloud_inv_map=cloud_inv_map, extended_sales=extended_sales
)
# 仅成品提货口径拆分千羽仓,不影响计划预测的云仓汇总。
row['可用库存'] = row.get('云仓库存', 0) or 0
row['千羽仓库存'] = qianyu_inv_map.get(code, 0)
row['昨日销量'] = row.get('云仓昨日销量', 0) or 0
row['3天均销量'] = row.get('云仓3天平均销量', 0) or 0
row['7天均销量'] = row.get('云仓7天平均销量', 0) or 0
row['15天均销量'] = row.get('云仓14天平均销量', 0) or 0
# 支撑天数改为云仓可用库存 / 云仓昨日销量;昨日销量为0时按999展示。
cloud_inventory = float(row.get('可用库存', 0) or 0)
cloud_yesterday = float(row.get('昨日销量', 0) or 0)
row['支撑天数'] = round(cloud_inventory / cloud_yesterday, 1) if cloud_yesterday > 0 else 999
# 采购在途取最新库存日期下所有仓库 purchase_in_transit 合计。
row['采购在途'] = transit_map.get(code, {}).get('采购在途', 0)
# 采购总数量改为最新库存日期下的生产在途与采购在途合计。
row['采购总数量'] = (row.get('生产在途', 0) or 0) + (row.get('采购在途', 0) or 0)
old = saved_by_code.get(str(code).strip(), {})
follower_group = _personnel_group_for_value(product.get('采购跟单'), groups)
follower_group = next((name for name in groups if name.casefold() == str(follower_group).casefold()), follower_group)
planner_value = str(product.get('采购计划') or '').strip()
planner_group = next((name for name in groups if name.casefold() == planner_value.casefold()), '')
planner_display = '、'.join(groups[planner_group]) if planner_group else planner_value
row.update({'id': old.get('id'), '品类': old.get('品类') or row.get('产品种类') or '',
'货品编号': code, '货品名称': product.get('product_name', ''),
'货品类型': product.get('product_type', ''),
'供应商': product.get('供应商名称') or '', '采购跟单': follower_group or product.get('采购跟单') or '',
'采购计划': planner_display, '产品进度': finished_progress_map.get(str(code).strip(), ''),
'待提数量': old.get('待提数量'),
'进度': finished_progress_map.get(str(code).strip(), ''),
'备注': old.get('备注') or '',
'可用库存': row.get('可用库存', 0), '15天均销量': row.get('15天均销量', 0),
'支撑天数': row.get('支撑天数', 0), '加工厂库存': row.get('加工厂库存', 0),
'采购总数量': row.get('采购总数量', 0), '采购在途': row.get('采购在途', 0)})
result.append(row)
return result
def _finished_row_matches(row, search, category, supplier, filter_data):
values = [row.get('货品编号'), row.get('货品名称'), row.get('供应商'), row.get('备注')]
if search and not any(search.casefold() in str(v or '').casefold() for v in values):
return False
if category and row.get('品类') != category or supplier and row.get('供应商') != supplier:
return False
for field, spec in (filter_data or {}).items():
if not isinstance(spec, dict):
continue
value = row.get(field)
if spec.get('type') == 'search' and spec.get('value') and spec['value'].casefold() not in str(value or '').casefold():
return False
if spec.get('type') == 'list' and spec.get('values'):
selected = spec['values']
if '__EMPTY__' not in selected or str(value or ''):
if str(value) not in [str(v) for v in selected]:
return False
if spec.get('type') == 'num' and spec.get('cond') and spec.get('val') != '':
try:
number = float(value or 0)
target = spec['val']
if spec['cond'] == 'range':
bounds = [float(x.strip()) for x in str(target).split(',')]
matched = len(bounds) == 2 and bounds[0] <= number <= bounds[1]
else:
target = float(target)
matched = {'eq': number == target, 'neq': number != target, 'gt': number > target,
'gte': number >= target, 'lt': number < target, 'lte': number <= target}.get(spec['cond'], True)
if not matched:
return False
except (TypeError, ValueError):
return False
return True
@purchase_bp.route('/orders/finished')
@login_required
def orders_finished_page():
if not role_required('orders_finished'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='orders_finished')
@purchase_bp.route('/api/finished/list', methods=['GET', 'POST'])
@login_required
def finished_products_list():
denied = _require_purchase_operation('orders_finished', 'view')
if denied:
return denied
page = int(request.args.get('page', 1))
size = int(request.args.get('size', 100))
search = request.args.get('search', '')
category_filter = request.args.get('category', '')
supplier_filter = request.args.get('supplier', '')
# 成品提货的多选筛选值可能很多,POST 时从请求体读取,避免 GET URL 超长。
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
order_filter_str = request.args.get('order_filter', '') or ''
filter_data = body.get('order_filter') if request.method == 'POST' else None
if filter_data is None:
try:
filter_data = json.loads(order_filter_str) if order_filter_str else {}
except (TypeError, ValueError, json.JSONDecodeError):
filter_data = {}
if not isinstance(filter_data, dict):
filter_data = {}
sort_by = request.args.get('sort_by', '')
sort_order = request.args.get('sort_order', 'asc').lower()
all_rows = _finished_planning_rows()
rows = [r for r in all_rows if _finished_row_matches(r, search, category_filter, supplier_filter, filter_data)]
allowed_sort_fields = {'品类','货品编号','货品名称','货品类型','供应商','可用库存','千羽仓库存','昨日销量','3天均销量','7天均销量','15天均销量','支撑天数','采购总数量','生产在途','采购在途','产品进度','加工厂库存','采购跟单','采购计划','备注'}
numeric_sort_fields = {'可用库存','千羽仓库存','昨日销量','3天均销量','7天均销量','15天均销量','支撑天数','采购总数量','生产在途','采购在途','加工厂库存'}
if sort_by in allowed_sort_fields:
reverse = sort_order == 'desc'
if sort_by in numeric_sort_fields:
def sort_key(row):
try:
return float(row.get(sort_by) or 0)
except (TypeError, ValueError):
return 0
else:
def sort_key(row):
return str(row.get(sort_by) or '').casefold()
rows.sort(key=sort_key, reverse=reverse)
total = len(rows)
offset = (page - 1) * size
rows = rows[offset:offset + size]
return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}})
@purchase_bp.route('/api/finished/export', methods=['POST'])
@login_required
def finished_products_export():
"""按成品提货当前筛选条件导出 Excel,并仅输出用户选择的字段。"""
denied = _require_purchase_operation('orders_finished', 'export')
if denied:
return denied
from io import BytesIO
from flask import send_file
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
body = {}
field_defs = {c['field']: (c['label'], c.get('minWidth', 80) // 5 + 8)
for c in (
{'field': '品类', 'label': '品类', 'minWidth': 60},
{'field': '货品编号', 'label': '货品编号', 'minWidth': 120},
{'field': '货品名称', 'label': '货品名称', 'minWidth': 250},
{'field': '货品类型', 'label': '货品类型', 'minWidth': 100},
{'field': '供应商', 'label': '供应商', 'minWidth': 100},
{'field': '可用库存', 'label': '可用库存', 'minWidth': 70},
{'field': '千羽仓库存', 'label': '千羽仓库存', 'minWidth': 90},
{'field': '昨日销量', 'label': '昨日销量', 'minWidth': 60},
{'field': '3天均销量', 'label': '3天均销', 'minWidth': 65},
{'field': '7天均销量', 'label': '7天均销', 'minWidth': 65},
{'field': '15天均销量', 'label': '15天均销', 'minWidth': 65},
{'field': '支撑天数', 'label': '支撑天数', 'minWidth': 60},
{'field': '加工厂库存', 'label': '加工厂库存', 'minWidth': 80},
{'field': '采购总数量', 'label': '采购总数量', 'minWidth': 80},
{'field': '生产在途', 'label': '生产在途', 'minWidth': 70},
{'field': '采购在途', 'label': '采购在途', 'minWidth': 70},
{'field': '产品进度', 'label': '产品进度', 'minWidth': 130},
{'field': '采购跟单', 'label': '跟单小组', 'minWidth': 150},
{'field': '采购计划', 'label': '采购计划', 'minWidth': 100},
{'field': '备注', 'label': '备注', 'minWidth': 200},
)}
selected = [field for field in body.get('fields', []) if field in field_defs]
if session.get('role') not in ('admin', 'super_admin'):
from services.field_perms import field_perms_for_page
field_perms = field_perms_for_page(
session.get('user_id'), session.get('role'), 'orders_finished',
page_view=True, page_edit=False
)
selected = [field for field in selected
if field_perms.get(field_defs[field][0], {}).get('view', True)]
if not selected:
return jsonify({'code': 400, 'msg': '请至少选择一个有查看权限的导出字段'}), 400
search = str(body.get('search') or '').strip()
category = str(body.get('category') or '').strip()
supplier = str(body.get('supplier') or '').strip()
filter_data = body.get('order_filter') or {}
if not isinstance(filter_data, dict):
filter_data = {}
rows = [row for row in _finished_planning_rows()
if _finished_row_matches(row, search, category, supplier, filter_data)]
sort_by = body.get('sort_by') or ''
sort_order = str(body.get('sort_order') or 'asc').lower()
numeric_fields = {'可用库存', '千羽仓库存', '昨日销量', '3天均销量', '7天均销量', '15天均销量',
'支撑天数', '采购总数量', '生产在途', '采购在途', '加工厂库存'}
if sort_by in field_defs:
if sort_by in numeric_fields:
def sort_key(row):
try:
return float(row.get(sort_by) or 0)
except (TypeError, ValueError):
return 0
else:
def sort_key(row):
return str(row.get(sort_by) or '').casefold()
rows.sort(key=sort_key, reverse=sort_order == 'desc')
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.title = '成品提货管理'
sheet.append([field_defs[field][0] for field in selected])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='1890FF')
cell.alignment = Alignment(horizontal='center', vertical='center')
for row in rows:
sheet.append([row.get(field) if row.get(field) is not None else '' for field in selected])
for index, field in enumerate(selected, 1):
sheet.column_dimensions[openpyxl.utils.get_column_letter(index)].width = field_defs[field][1]
sheet.freeze_panes = 'A2'
sheet.auto_filter.ref = sheet.dimensions
output = BytesIO()
workbook.save(output)
output.seek(0)
return send_file(output, as_attachment=True, download_name='成品提货管理.xlsx',
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@purchase_bp.route('/api/finished/filters')
@login_required
def finished_products_filters():
denied = _require_purchase_operation('orders_finished', 'view')
if denied:
return denied
rows = _finished_planning_rows()
categories = sorted({str(r.get('品类') or '') for r in rows if r.get('品类')})
suppliers = sorted({str(r.get('供应商') or '') for r in rows if r.get('供应商')})
return jsonify({"code": 0, "data": {"categories": categories, "suppliers": suppliers}})
@purchase_bp.route('/api/finished/column-values', methods=['GET', 'POST'])
@login_required
def finished_products_column_values():
denied = _require_purchase_operation('orders_finished', 'view')
if denied:
return denied
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
field = request.args.get('field', '')
allowed_fields = {'货品编号','货品名称','货品类型','供应商','品类','可用库存','千羽仓库存','昨日销量','3天均销量','7天均销量','15天均销量','支撑天数','采购总数量','生产在途','采购在途','产品进度','加工厂库存','采购跟单','采购计划','备注'}
if not field:
return jsonify({"code": 400, "msg": "缺少field参数"})
if field not in allowed_fields:
return jsonify({"code": 0, "data": []})
try:
filters = body.get('order_filter') if body.get('order_filter') is not None else json.loads(request.args.get('order_filter', '') or '{}')
except (TypeError, ValueError, json.JSONDecodeError):
filters = {}
filters.pop(field, None)
rows = _finished_planning_rows()
rows = [r for r in rows if _finished_row_matches(r, request.args.get('search', ''), request.args.get('category', ''), request.args.get('supplier', ''), filters)]
values = sorted({str(r.get(field)) for r in rows if r.get(field) not in (None, '')})
if any(r.get(field) in (None, '') for r in rows):
values.append('__EMPTY__')
return jsonify({"code": 0, "data": values})
@purchase_bp.route('/api/finished/unfinished-orders')
@login_required
def finished_unfinished_orders():
"""根据货品编号抓取国内/海外订单管理中状态为未完结的订单列表(成品提货详情-未完结订单列表)"""
denied = _require_purchase_operation('orders_finished', 'view')
if denied:
return denied
product_code = request.args.get('product_code', '').strip()
if not product_code:
return jsonify({"code": 400, "msg": "缺少货品编号"})
rows = []
# 国内订单:货品编号匹配,状态未完结(跟单下单日期取 日期 字段,与国内订单列表一致)
domestic = list(query("""
SELECT IFNULL(DATE_FORMAT(`订单下达供应商时间`, '%%Y-%%m-%%d'), DATE_FORMAT(`日期`, '%%Y-%%m-%%d')) AS 下单日期,
`数量` AS 下单数量,
`实际到货数量` AS 到货数量, `未到货数` AS 未到货数, `进度` AS 订单进度
FROM domestic_orders
WHERE `货品编号`=%s AND `状态`='未完结'
ORDER BY 下单日期 ASC, id ASC
""", (product_code,)))
for r in domestic:
r['订单来源'] = '国内'
rows.append(r)
# 海外订单:产品编码匹配,状态未完结(跟单下单日期取 日期 字段,与海外订单列表一致)
overseas = list(query("""
SELECT IFNULL(DATE_FORMAT(`订单下达供应商时间`, '%%Y-%%m-%%d'), DATE_FORMAT(`日期`, '%%Y-%%m-%%d')) AS 下单日期,
`数量` AS 下单数量,
`实际到货数量` AS 到货数量, `未到货数` AS 未到货数, `进度` AS 订单进度
FROM overseas_orders
WHERE `产品编码`=%s AND `状态`='未完结'
ORDER BY 下单日期 ASC, id ASC
""", (product_code,)))
for r in overseas:
r['订单来源'] = '海外'
rows.append(r)
# 日期格式化
for r in rows:
d = r.get('下单日期')
if d and hasattr(d, 'strftime'):
r['下单日期'] = d.strftime('%Y-%m-%d')
elif d and isinstance(d, str):
r['下单日期'] = d[:10]
else:
r['下单日期'] = ''
r['下单数量'] = r.get('下单数量') or 0
r['到货数量'] = r.get('到货数量') or 0
r['未到货数'] = r.get('未到货数') or 0
r['订单进度'] = r.get('订单进度') or ''
# 外部“产品进度”列和详情弹窗使用同一口径,返回当前货品统一进度。
product_progress = _finished_order_progress_map().get(product_code, '')
return jsonify({"code": 0, "data": {"rows": rows, "total": len(rows),
"product_progress": product_progress}})
@purchase_bp.route('/api/finished/orders/update_fields', methods=['POST'])
@login_required
def finished_products_update_fields():
"""保存成品提货详情中的可编辑字段。"""
denied = _require_purchase_operation('orders_finished', 'view')
if denied:
return denied
data = request.json or {}
product_code = str(data.get('product_code') or '').strip()
fields = data.get('fields') or {}
if not product_code or '待提数量' not in fields:
return jsonify({"code": 400, "msg": "参数错误"})
raw_value = fields.get('待提数量')
try:
pending_qty = None if raw_value in (None, '') else int(str(raw_value).replace(',', '').strip())
except (TypeError, ValueError):
return jsonify({"code": 400, "msg": "待提数量必须填写整数"})
if pending_qty is not None and pending_qty < 0:
return jsonify({"code": 400, "msg": "待提数量不能小于0"})
# 兼容历史库:旧版 finished_products 尚无待提数量字段,首次保存时自动补齐。
columns = query("""
SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='finished_products' AND COLUMN_NAME='待提数量'
""")
if not columns:
query("ALTER TABLE finished_products ADD COLUMN `待提数量` INT DEFAULT NULL AFTER `采购总数量`")
saved = query("SELECT id FROM finished_products WHERE `货品编号`=%s ORDER BY id DESC LIMIT 1", (product_code,))
if saved:
query("UPDATE finished_products SET `待提数量`=%s WHERE id=%s", (pending_qty, saved[0]['id']))
else:
query("INSERT INTO finished_products (`货品编号`, `待提数量`) VALUES (%s, %s)", (product_code, pending_qty))
add_operation_log('成品提货编辑', f'货品编号:{product_code}; 待提数量:{pending_qty if pending_qty is not None else "空"}',
page='成品提货管理', field='待提数量')
return jsonify({"code": 0, "msg": "保存成功"})
@purchase_bp.route('/api/finished/orders/delete', methods=['POST'])
@login_required
def finished_products_delete():
denied = _require_purchase_operation('orders_finished', 'delete')
if denied:
return denied
row_id = request.json.get('id')
if not row_id:
return jsonify({"code": 400, "msg": "参数错误"})
query("DELETE FROM finished_products WHERE id=%s", (row_id,))
return jsonify({"code": 0, "msg": "删除成功"})
@purchase_bp.route('/api/purchase/rollback_order', methods=['POST'])
@login_required
def purchase_rollback_order():
"""下单回退:校验状态和原因后,仅回退当前下单时间对应的订单批次,历史批次不受影响。"""
denied = _require_purchase_operation('purchase_ready', 'rollback')
if denied:
return denied
data = request.json or {}
product_code = str(data.get('product_code', '')).strip()
backup_id = data.get('backup_id')
ordered_at = str(data.get('ordered_at', '') or '').strip()
rollback_reason = str(data.get('rollback_reason', '')).strip()
if not product_code:
return jsonify({"code": 400, "msg": "缺少货品编号"})
if len(rollback_reason) < 2:
return jsonify({"code": 400, "msg": "请填写至少2个字的回退原因"})
# 精确定位要回退的备份行:优先按 backup_id(同商品可能有多条 ordered 历史),
# 否则按 ordered_at,最后回退到该商品最近一次下单。
if backup_id:
rows = query("SELECT * FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,))
elif ordered_at:
rows = query(
"SELECT * FROM pending_orders WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(product_code, ordered_at)
)
else:
rows = query(
"SELECT * FROM pending_orders WHERE product_code=%s AND status='ordered' ORDER BY ordered_at DESC, id DESC LIMIT 1",
(product_code,)
)
if not rows:
return jsonify({"code": 400, "msg": "仅已下单状态的记录可以回退"})
row = rows[0]
backup_id = row['id']
# 用该备份行自身的下单时间定位订单批次,不依赖其他行。
if not ordered_at:
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id)
if not ordered_at:
return jsonify({"code": 400, "msg": "未找到对应下单时间,无法定位回退订单"})
planner = (row.get('procurement_planner') or '').strip()
is_overseas = ('张莹纯' in planner)
order_table = 'overseas_orders' if is_overseas else 'domestic_orders'
code_field = '产品编码' if is_overseas else '货品编号'
order_rows = query(
f"SELECT id, `状态`, COALESCE(`实际到货数量`, 0) AS arrived_qty FROM {order_table} WHERE `{code_field}`=%s AND `计划下单时间`=%s",
(product_code, ordered_at)
)
blocked = [o for o in order_rows if str(o.get('状态') or '').strip() == '已完结' or int(o.get('arrived_qty') or 0) > 0]
if blocked:
return jsonify({"code": 400, "msg": "订单已完结或已有到货数量,不能回退"})
bom_raw = row.get('bom_items')
try:
bom_list = json.loads(bom_raw) if isinstance(bom_raw, str) else (bom_raw or [])
except (TypeError, ValueError, json.JSONDecodeError):
bom_list = []
material_codes = [str(bm.get('material_code') or '').strip() for bm in bom_list if str(bm.get('material_code') or '').strip()]
packaging_count = 0
if material_codes:
placeholders = ','.join(['%s'] * len(material_codes))
packaging_rows = query(
f"SELECT id, `状态`, COALESCE(`实际到货数量`, 0) AS arrived_qty FROM packaging_orders WHERE `来源产品编码`=%s AND `下单时间`=%s AND `编码` IN ({placeholders})",
(product_code, ordered_at) + tuple(material_codes)
)
if any(str(o.get('状态') or '').strip() == '已完结' or int(o.get('arrived_qty') or 0) > 0 for o in packaging_rows):
return jsonify({"code": 400, "msg": "关联包材订单已完结或已有到货数量,不能回退"})
packaging_count = len(packaging_rows)
order_ids = [str(o['id']) for o in order_rows]
query(f"DELETE FROM {order_table} WHERE `{code_field}`=%s AND `计划下单时间`=%s", (product_code, ordered_at))
if material_codes:
placeholders = ','.join(['%s'] * len(material_codes))
query(f"DELETE FROM packaging_orders WHERE `来源产品编码`=%s AND `下单时间`=%s AND `编码` IN ({placeholders})",
(product_code, ordered_at) + tuple(material_codes))
# 包材结余回退:把本次下单消耗的包材加回下单前包材(与确认下单公式互逆)。
# 消耗量 = 成品实际下单数量×包材用量 - 包材实际下单数量;回退后 pre = 当前 pre + 消耗量,post 同步为新 pre。
# 示例:成品下单1000、包材不下单,下单前5000→下单后4000;回退后 4000+1000=5000。
product_actual_qty = 0
for bm in bom_list:
if str(bm.get('material_code') or '').strip() == f'成品_{product_code}':
if bm.get('actual_quantity') is not None:
product_actual_qty = int(bm.get('actual_quantity') or 0)
break
if product_actual_qty <= 0:
product_actual_qty = int(row.get('actual_order_qty') or row.get('formal_order_qty') or 0)
pack_rollback_detail = []
# 用量同样以 bom 表为权威来源,避免历史被污染的 bom_items.quantity 再次算错结余。
bom_qty_map = {}
for _br in query("SELECT material_code, quantity FROM bom WHERE product_code=%s", (product_code,)):
try:
bom_qty_map[str(_br.get('material_code') or '').strip()] = float(_br.get('quantity') or 0)
except (TypeError, ValueError):
continue
for bm in bom_list:
mat_code = str(bm.get('material_code') or '').strip()
if not mat_code or mat_code.startswith('成品_'):
continue
# 与确认下单一致:优先使用实际下单数量,无则按用量×成品数量
mat_qty = _resolve_roll_bom_qty(bm, bom_qty_map)
actual_qty = bm.get('actual_quantity')
if actual_qty is not None and int(actual_qty) > 0:
order_qty = int(actual_qty)
elif actual_qty is not None and int(actual_qty) == 0:
order_qty = 0
else:
order_qty = int(mat_qty * product_actual_qty)
consumed = product_actual_qty * mat_qty - order_qty
if consumed == 0:
continue
pack_rows = query(
"SELECT pre_pack_qty FROM bom_packaging WHERE product_code=%s AND material_code=%s",
(product_code, mat_code)
)
cur_pre = int(pack_rows[0].get('pre_pack_qty') or 0) if pack_rows else 0
new_pre = cur_pre + consumed
query("""INSERT INTO bom_packaging
(product_code, material_code, pre_pack_qty, post_pack_qty)
VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE pre_pack_qty=%s, post_pack_qty=%s, updated_at=NOW()""",
(product_code, mat_code, new_pre, new_pre, new_pre, new_pre))
pack_rollback_detail.append(f"{mat_code}:{cur_pre}→{new_pre}")
print(f'[PACKAGING_ROLLBACK] pc={product_code}, mat={mat_code}, consumed={consumed}, pre={cur_pre}->{new_pre}', flush=True)
# 回退时保留原下单行作为历史备份,并复制一行回到待下单列表。
# 这样订单管理不再展示已回退行,但订单备份管理仍能完整保留这次下单记录。
query("UPDATE pending_orders SET status='rolled_back' WHERE id=%s AND status='ordered'", (backup_id,))
# 使用已定位到的备份行显式复制,避免 INSERT...SELECT 在不同 MySQL 配置下
# 无法可靠返回新记录并导致回退后待下单列表没有记录。
pending_id = query("""
INSERT INTO pending_orders
(product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items,
production_order_no, remark, new_old_product, sales_snapshot, sales_snapshot_at,
status, created_at, updated_at, ordered_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending', NOW(), NOW(), NULL)
""", (
row.get('product_code'), row.get('product_name'), row.get('product_type'), row.get('product_grade'),
row.get('supplier_name'), row.get('lead_time'), row.get('logistics_time'), row.get('total_support_days'),
row.get('procurement_follower'), row.get('procurement_planner'), row.get('platform'), row.get('operator'),
row.get('created_by'), row.get('audit_price'), row.get('formal_order_qty'), row.get('suggested_qty'),
row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark'),
row.get('new_old_product'), row.get('sales_snapshot'), row.get('sales_snapshot_at')
))
pending_check = query(
"SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1",
(pending_id, product_code)
)
if not pending_check:
return jsonify({"code": 500, "msg": "回退失败:未能生成待下单记录"})
detail = (
f"商品:{product_code}; 原状态:ordered; 下单时间:{ordered_at}; 订单类型:{'海外' if is_overseas else '国内'}; "
f"订单ID:{','.join(order_ids) or '无'}; 删除包材订单:{packaging_count}条; 包材回退:{';'.join(pack_rollback_detail) or '无'}; 原因:{rollback_reason}"
)
add_operation_log('下单回退', detail)
return jsonify({"code": 0, "msg": "已回退到待下单列表"})
@purchase_bp.route('/api/purchase/save_bom', methods=['POST'])
@login_required
def purchase_save_bom():
denied = _require_purchase_operation('purchase', 'edit')
if denied:
return denied
"""保存BOM实际下单数量到pending_orders"""
data = request.json
pc = data.get('product_code', '')
materials = data.get('materials', [])
if not pc or not materials:
return jsonify({"code": 400, "msg": "参数错误"})
rows = query("SELECT bom_items FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
if not rows:
return jsonify({"code": 400, "msg": "暂无待下单记录,请先通过计划预测下单"})
import json
bom_raw = rows[0].get('bom_items', '[]')
try:
bom_list = json.loads(bom_raw) if isinstance(bom_raw, str) else bom_raw
except:
bom_list = []
# 将actual_quantity写入bom_items
mat_map = {}
for m in materials:
mat_map[m['material_code']] = m['actual_quantity']
for b in bom_list:
mc = b.get('material_code', '')
if mc in mat_map:
b['actual_quantity'] = int(mat_map[mc])
del mat_map[mc]
# 新增不在原BOM中的物料
for mc, aq in mat_map.items():
bom_list.append({'material_code': mc, 'material_name': '', 'quantity': 0, 'actual_quantity': aq})
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='pending'",
(json.dumps(bom_list, ensure_ascii=False), pc))
return jsonify({"code": 0, "msg": "保存成功"})
def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
"""Return the exact order time used when this backup created its order rows.
优先按 backup_id 定位(同一商品可能存在多条 ordered 历史),
其次按传入 ordered_at,最后回退到该商品最近一次下单。
"""
if backup_id:
rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND product_code=%s AND status='ordered'",
(backup_id, product_code))
elif ordered_at:
return str(ordered_at)[:19]
else:
rows = query(
"SELECT ordered_at FROM pending_orders WHERE product_code=%s AND status='ordered' "
"ORDER BY ordered_at DESC, id DESC LIMIT 1", (product_code,)
)
if not rows or not rows[0].get('ordered_at'):
return None
value = rows[0]['ordered_at']
return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19]
def _backup_linked_order_ids(read, table, product_code, ordered_at):
"""Prefer the exact order time; date-only legacy rows need a unique source."""
code_field, time_field = {
'domestic_orders': ('货品编号', '计划下单时间'),
'overseas_orders': ('产品编码', '计划下单时间'),
'packaging_orders': ('来源产品编码', '下单时间'),
}[table]
rows = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND `{time_field}`=%s',
(product_code, ordered_at))
if rows:
return [row['id'] for row in rows]
day = ordered_at[:10]
legacy = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND TRIM(`{time_field}`)=%s',
(product_code, day))
if not legacy:
return []
sources = read(
"SELECT id FROM pending_orders WHERE product_code=%s "
"AND status IN ('ordered','rolled_back') "
"AND ordered_at >= %s AND ordered_at < DATE_ADD(%s, INTERVAL 1 DAY)",
(product_code, day, day),
)
# 成品订单须唯一;同一备份可以生成多条包材订单。
if len(sources) != 1 or (table != 'packaging_orders' and len(legacy) != 1):
raise ValueError('同一商品当天有多笔记录,日期不足以确定对应订单,请核对计划下单时间')
return [row['id'] for row in legacy]
@purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST'])
@login_required
def backup_update_prod_no():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
"""Save the backup and uniquely linked order numbers in one transaction."""
data = request.get_json(silent=True) or {}
product_code = data.get('product_code', '')
prod_no = data.get('production_order_no', '')
backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '')
if not product_code:
return jsonify({"code": 400, "msg": "参数错误"})
conn = get_db()
counts = {}
try:
conn.begin()
with conn.cursor() as cur:
def read(sql, params):
cur.execute(sql, params)
return cur.fetchall()
if backup_id:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE id=%s "
"AND product_code=%s AND status='ordered' FOR UPDATE", (backup_id, product_code))
elif ordered_at:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
"AND status='ordered' AND ordered_at=%s FOR UPDATE", (product_code, ordered_at))
else:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
"AND status='ordered' ORDER BY ordered_at DESC,id DESC LIMIT 1 FOR UPDATE", (product_code,))
if len(backups) != 1 or not backups[0].get('ordered_at'):
conn.rollback()
return jsonify({"code": 404, "msg": "未找到唯一对应的下单备份"})
backup = backups[0]
time = backup['ordered_at']
ordered_at = time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(time, 'strftime') else str(time)[:19]
targets = {table: _backup_linked_order_ids(read, table, product_code, ordered_at)
for table in ('domestic_orders', 'overseas_orders', 'packaging_orders')}
cur.execute('UPDATE pending_orders SET production_order_no=%s WHERE id=%s', (prod_no, backup['id']))
for table, ids in targets.items():
counts[table] = len(ids)
if ids:
field = '生产单号' if table == 'packaging_orders' else '旺店通生产单号'
placeholders = ','.join(['%s'] * len(ids))
cur.execute(f'UPDATE {table} SET `{field}`=%s WHERE id IN ({placeholders})',
(prod_no, *ids))
conn.commit()
except ValueError as exc:
conn.rollback()
return jsonify({"code": 409, "msg": str(exc)})
except Exception:
conn.rollback()
raise
finally:
conn.close()
add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}')
msg = '生产单号已保存,已同步对应订单'
if not any(counts.values()):
msg = '生产单号已保存,但未找到可同步的订单,请核对订单记录'
return jsonify({"code": 0, "msg": msg, "data": {"synced": counts}})
@purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST'])
@login_required
def backup_audit_material_price():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
data = request.json or {}
product_code = data.get('product_code', '')
material_code = data.get('material_code', '')
backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '')
if not product_code or not material_code:
return jsonify({"code": 400, "msg": "参数错误"})
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at)
if not ordered_at:
return jsonify({"code": 404, "msg": "未找到对应下单时间"})
if backup_id:
rows = query("SELECT bom_items FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,))
else:
rows = query("SELECT bom_items FROM pending_orders WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(product_code, ordered_at))
if not rows:
return jsonify({"code": 404, "msg": "未找到下单备份"})
bom = rows[0].get('bom_items') or []
if isinstance(bom, str):
bom = json.loads(bom)
changed = False
for item in bom:
if item.get('material_code') == material_code:
item['price_audit_status'] = '已审核'
changed = True
if not changed:
return jsonify({"code": 404, "msg": "未找到包材"})
if backup_id:
query("UPDATE pending_orders SET bom_items=%s WHERE id=%s", (json.dumps(bom, ensure_ascii=False), backup_id))
else:
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(json.dumps(bom, ensure_ascii=False), product_code, ordered_at))
query("UPDATE packaging_orders SET `是否审核价格`='已审核' WHERE `编码`=%s AND `来源产品编码`=%s AND `下单时间`=%s",
(material_code, product_code, ordered_at))
add_operation_log('包材价格审核', f'{product_code}/{ordered_at}/{material_code}')
return jsonify({"code": 0, "msg": "已审核"})
@purchase_bp.route('/api/purchase/backup/update_mat_prod_no', methods=['POST'])
@login_required
def backup_update_mat_prod_no():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
"""更新下单备份中某个包材的生产单号"""
data = request.json
product_code = data.get('product_code', '')
material_code = data.get('material_code', '')
prod_no = data.get('production_order_no', '')
backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '')
if not product_code or not material_code:
return jsonify({"code": 400, "msg": "参数错误"})
import json
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at)
if not ordered_at:
return jsonify({"code": 404, "msg": "未找到对应下单时间"})
if backup_id:
rows = query("SELECT bom_items FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,))
else:
rows = query("SELECT bom_items FROM pending_orders WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(product_code, ordered_at))
if not rows:
return jsonify({"code": 404, "msg": "未找到"})
bom = rows[0]['bom_items']
if isinstance(bom, str):
bom = json.loads(bom)
changed = False
for item in bom:
if item.get('material_code') == material_code:
item['production_order_no'] = prod_no
changed = True
break
if changed:
if backup_id:
query("UPDATE pending_orders SET bom_items=%s WHERE id=%s",
(json.dumps(bom, ensure_ascii=False), backup_id))
else:
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(json.dumps(bom, ensure_ascii=False), product_code, ordered_at))
query("""UPDATE packaging_orders SET `生产单号`=%s
WHERE `编码`=%s AND `来源产品编码`=%s AND `下单时间`=%s""",
(prod_no, material_code, product_code, ordered_at))
add_operation_log('包材生产单号更新', f'{product_code}/{material_code}/{ordered_at}: {prod_no}')
return jsonify({"code": 0, "msg": "保存成功"})
@purchase_bp.route('/api/purchase/backup/update_mat_purchase_no', methods=['POST'])
@login_required
def backup_update_mat_purchase_no():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
"""更新下单备份中某个包材的采购单号"""
data = request.json
product_code = data.get('product_code', '')
material_code = data.get('material_code', '')
purchase_no = data.get('purchase_order_no', '')
change_manuscript = data.get('change_manuscript', '')
backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '')
if not product_code or not material_code:
return jsonify({"code": 400, "msg": "参数错误"})
import json
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at)
if not ordered_at:
return jsonify({"code": 404, "msg": "未找到对应下单时间"})
if backup_id:
rows = query("SELECT bom_items FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,))
else:
rows = query("SELECT bom_items FROM pending_orders WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(product_code, ordered_at))
if not rows:
return jsonify({"code": 404, "msg": "未找到"})
bom = rows[0]['bom_items']
if isinstance(bom, str):
bom = json.loads(bom)
changed = False
for item in bom:
if item.get('material_code') == material_code:
item['purchase_order_no'] = purchase_no
item['change_manuscript'] = change_manuscript
changed = True
break
if not changed:
return jsonify({"code": 404, "msg": "未找到对应包材"})
if backup_id:
query("UPDATE pending_orders SET bom_items=%s WHERE id=%s",
(json.dumps(bom, ensure_ascii=False), backup_id))
else:
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='ordered' AND ordered_at=%s",
(json.dumps(bom, ensure_ascii=False), product_code, ordered_at))
packaging_rows = query(
"""SELECT id FROM packaging_orders
WHERE `编码`=%s AND `来源产品编码`=%s AND `下单时间`=%s
ORDER BY id DESC LIMIT 1""", (material_code, product_code, ordered_at)
)
if not packaging_rows:
# Legacy rows may lack source product code, but must still match the exact order time.
packaging_rows = query(
"""SELECT id FROM packaging_orders
WHERE `编码`=%s AND COALESCE(`来源产品编码`, '')='' AND `下单时间`=%s
ORDER BY id DESC LIMIT 1""", (material_code, ordered_at)
)
if not packaging_rows:
# Fallback: the ordered batch may have been deleted (e.g. dedup);
# sync into the remaining packaging order for this material/product.
packaging_rows = query(
"""SELECT id FROM packaging_orders
WHERE `编码`=%s AND `来源产品编码`=%s
ORDER BY `下单时间` DESC, id DESC LIMIT 1""", (material_code, product_code)
)
if packaging_rows:
query("""UPDATE packaging_orders
SET `是否已做采购单`=%s, `旺店通编号`=%s, `来源产品编码`=%s
WHERE id=%s""",
('是' if purchase_no else '', purchase_no, product_code, packaging_rows[0]['id']))
add_operation_log('包材采购单号更新', f'{product_code}/{material_code}/{ordered_at}: {purchase_no}')
return jsonify({"code": 0, "msg": "保存成功", "packaging_synced": bool(packaging_rows)})
@purchase_bp.route('/api/domestic/orders/column-values', methods=['GET', 'POST'])
@login_required
def domestic_orders_column_values():
"""获取国内订单指定列的唯一值(全量,支持联动筛选+搜索条件)"""
field = request.args.get('field', '')
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
filter_str = request.args.get('order_filter', '') or ''
if body.get('order_filter') is not None:
filter_str = json.dumps(body.get('order_filter') or {}, ensure_ascii=False)
search = request.args.get('search', '') or ''
status = request.args.get('status', '') or ''
supplier = request.args.get('supplier', '') or ''
orderer = request.args.get('orderer', '') or ''
if not field:
return jsonify({"code": 400, "msg": "缺少field参数"})
allowed_fields = {'货品编号','货品名称','货品类型','单价','数量','金额','实际到货数量','未到货数','供货商','仓库','采购员','下单人','进度','状态','下单备注','采购开发','旺店通生产单号','金蝶采购单','是否已录入旺店通','是否审核价格','新旧品','金蝶入库','日期','计划下单时间','实际到货日期','预计到货日期'}
if field not in allowed_fields:
return jsonify({"code": 0, "data": []})
source = _order_table_with_product_type('domestic_orders', '货品编号')
where_extra = ""
params = []
# 搜索栏条件
if search:
where_extra += " AND (`货品编号` LIKE %s OR `货品名称` LIKE %s OR `供货商` LIKE %s OR `下单备注` LIKE %s)"
kw = '%' + search + '%'
params.extend([kw, kw, kw, kw])
if status:
where_extra += " AND `状态` = %s"
params.append(status)
if supplier:
where_extra += " AND `供货商` LIKE %s"
params.append('%' + supplier + '%')
if orderer:
where_extra += " AND `下单人` LIKE %s"
params.append('%' + orderer + '%')
# 表头筛选条件
if filter_str:
try:
_filters = json.loads(filter_str)
_conds = []
for _k, _v in _filters.items():
if _k == field:
continue
if not isinstance(_v, dict):
continue
_t = _v.get('type', '')
if _t == 'search':
_val = _v.get('value', '')
if _val:
_conds.append('`' + _k + '` LIKE %s')
params.append('%' + _val + '%')
elif _t == 'list':
_vals = _v.get('values', [])
if _vals:
_non_empty = [x for x in _vals if x != '__EMPTY__']
_list_parts = []
if _non_empty:
_list_parts.extend(_build_exact_list_parts(_k, _non_empty, params))
if '__EMPTY__' in _vals:
_list_parts.append("(`" + _k + "` IS NULL OR `" + _k + "` = '')")
if _list_parts:
_conds.append('(' + ' OR '.join(_list_parts) + ')')
elif _t == 'num':
_cond = _v.get('cond', '')
_val = _v.get('val', '')
if _cond and _val:
cond_map = {'eq': '=', 'neq': '!=', 'gt': '>', 'gte': '>=', 'lt': '<', 'lte': '<='}
if _cond == 'range':
parts = [part.strip() for part in str(_val).split(',') if part.strip()]
if len(parts) == 2:
try:
_conds.append('`' + _k + '` >= %s AND `' + _k + '` <= %s')
params.extend([float(parts[0]), float(parts[1])])
except ValueError:
pass
else:
_op = cond_map.get(_cond)
if _op:
_conds.append('`' + _k + '` ' + _op + ' %s')
params.append(float(_val) if _val.replace('.', '').replace('-', '').isdigit() else _val)
except:
pass
if _conds:
where_extra += ' AND ' + ' AND '.join(_conds)
if field == '货品类型':
return jsonify({"code": 0, "data": _order_product_type_values('domestic_orders', '货品编号', where_extra, params)})
# 如果有空值(考虑现有筛选),追加 __EMPTY__ 标记
try:
hasEmpty = query('SELECT COUNT(*) AS cnt FROM ' + source + ' WHERE (`' + field + '` IS NULL OR CAST(`' + field + '` AS CHAR) = \'\')' + where_extra, params or None)
hasEmptyCnt = hasEmpty[0]['cnt'] if hasEmpty else 0
except Exception:
hasEmptyCnt = 0
sql = 'SELECT DISTINCT `' + field + '` AS val FROM ' + source + ' WHERE `' + field + '` IS NOT NULL AND CAST(`' + field + '` AS CHAR) != \'\'' + where_extra + ' ORDER BY val'
rows = query(sql, params or None)
vals = [r['val'] for r in rows]
if hasEmptyCnt > 0:
vals.append('__EMPTY__')
return jsonify({"code": 0, "data": vals})
@purchase_bp.route('/api/overseas/orders/column-values', methods=['GET', 'POST'])
@login_required
def overseas_orders_column_values():
"""获取海外订单指定列的唯一值(全量,支持联动筛选+搜索条件)"""
field = request.args.get('field', '')
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
filter_str = request.args.get('order_filter', '') or ''
if body.get('order_filter') is not None:
filter_str = json.dumps(body.get('order_filter') or {}, ensure_ascii=False)
search = request.args.get('search', '') or ''
status = request.args.get('status', '') or ''
supplier = request.args.get('supplier', '') or ''
orderer = request.args.get('orderer', '') or ''
if not field:
return jsonify({"code": 400, "msg": "缺少field参数"})
allowed_fields = {'产品编码','货品名称','货品类型','单价','数量','金额','实际到货数量','未到货数','供应商','仓库','采购员','下单人','进度','状态','下单备注','采购开发','旺店通生产单号','是否已录入到旺店通','是否审核价格','新旧品','是否已做采购单','是否做入库','日期','计划下单时间','实际到货日期','预计到货日期'}
if field not in allowed_fields:
return jsonify({"code": 0, "data": []})
source = _order_table_with_product_type('overseas_orders', '产品编码')
where_extra = ""
params = []
if search:
where_extra += " AND (`产品编码` LIKE %s OR `货品名称` LIKE %s OR `供应商` LIKE %s OR `下单备注` LIKE %s)"
kw = '%' + search + '%'
params.extend([kw, kw, kw, kw])
if status:
where_extra += " AND `状态` = %s"
params.append(status)
if supplier:
where_extra += " AND `供应商` LIKE %s"
params.append('%' + supplier + '%')
if orderer:
where_extra += " AND `下单人` LIKE %s"
params.append('%' + orderer + '%')
if filter_str:
try:
_filters = json.loads(filter_str)
_conds = []
for _k, _v in _filters.items():
if _k == field:
continue
if not isinstance(_v, dict):
continue
_t = _v.get('type', '')
if _t == 'search':
_val = _v.get('value', '')
if _val:
_conds.append('`' + _k + '` LIKE %s')
params.append('%' + _val + '%')
elif _t == 'list':
_vals = _v.get('values', [])
if _vals:
_non_empty = [x for x in _vals if x != '__EMPTY__']
_list_parts = []
if _non_empty:
_list_parts.extend(_build_exact_list_parts(_k, _non_empty, params))
if '__EMPTY__' in _vals:
_list_parts.append("(`" + _k + "` IS NULL OR `" + _k + "` = '')")
if _list_parts:
_conds.append('(' + ' OR '.join(_list_parts) + ')')
elif _t == 'num':
_cond = _v.get('cond', '')
_val = _v.get('val', '')
if _cond and _val:
cond_map = {'eq': '=', 'neq': '!=', 'gt': '>', 'gte': '>=', 'lt': '<', 'lte': '<='}
if _cond == 'range':
parts = [part.strip() for part in str(_val).split(',') if part.strip()]
if len(parts) == 2:
try:
_conds.append('`' + _k + '` >= %s AND `' + _k + '` <= %s')
params.extend([float(parts[0]), float(parts[1])])
except ValueError:
pass
else:
_op = cond_map.get(_cond)
if _op:
_conds.append('`' + _k + '` ' + _op + ' %s')
params.append(float(_val) if _val.replace('.', '').replace('-', '').isdigit() else _val)
except:
pass
if _conds:
where_extra += ' AND ' + ' AND '.join(_conds)
if field == '货品类型':
return jsonify({"code": 0, "data": _order_product_type_values('overseas_orders', '产品编码', where_extra, params)})
try:
hasEmpty = query('SELECT COUNT(*) AS cnt FROM ' + source + ' WHERE (`' + field + '` IS NULL OR CAST(`' + field + '` AS CHAR) = \'\')' + where_extra, params or None)
hasEmptyCnt = hasEmpty[0]['cnt'] if hasEmpty else 0
except Exception:
hasEmptyCnt = 0
sql = 'SELECT DISTINCT `' + field + '` AS val FROM ' + source + ' WHERE `' + field + '` IS NOT NULL AND CAST(`' + field + '` AS CHAR) != \'\'' + where_extra + ' ORDER BY val'
rows = query(sql, params or None)
vals = [r['val'] for r in rows]
if hasEmptyCnt > 0:
vals.append('__EMPTY__')
return jsonify({"code": 0, "data": vals})
@purchase_bp.route('/api/packaging/orders/column-values', methods=['GET', 'POST'])
@login_required
def packaging_orders_column_values():
"""获取包材订单指定列的唯一值(全量,支持联动筛选+搜索条件)"""
# 使用局部别名,避免后续维护时新增同名局部变量导致 json 被遮蔽。
import json as _json
field = request.args.get('field', '')
body = request.get_json(silent=True) if request.method == 'POST' else None
body = body if isinstance(body, dict) else {}
filter_str = request.args.get('order_filter', '') or ''
if body.get('order_filter') is not None:
filter_str = _json.dumps(body.get('order_filter') or {}, ensure_ascii=False)
search = request.args.get('search', '') or ''
status = request.args.get('status', '') or ''
supplier = request.args.get('supplier', '') or ''
orderer = request.args.get('orderer', '') or ''
if not field:
return jsonify({"code": 400, "msg": "缺少field参数"})
allowed_fields = {'编码','名称','货品类型','新旧品','单价','数量','合计','供应商','成品供应商','进度','状态','做货天数','仓库到货','下单备注','是否已做采购单','是否已做入库','采购下单人','旺店通编号','是否已录入旺店通','到货日期','包材开发'}
if field not in allowed_fields:
return jsonify({"code": 0, "data": []})
source = _packaging_order_source()
where_extra = ""
params = []
if search:
where_extra += " AND (`编码` LIKE %s OR `名称` LIKE %s OR `供应商` LIKE %s OR `进度` LIKE %s)"
kw = '%' + search + '%'
params.extend([kw, kw, kw, kw])
if status:
where_extra += " AND `状态` = %s"
params.append(status)
if supplier:
where_extra += " AND `供应商` LIKE %s"
params.append('%' + supplier + '%')
if orderer:
where_extra += " AND `采购下单人` LIKE %s"
params.append('%' + orderer + '%')
if filter_str:
try:
_filters = _json.loads(filter_str)
_conds = []
for _k, _v in _filters.items():
if _k == field:
continue
if not isinstance(_v, dict):
continue
_t = _v.get('type', '')
if _t == 'search':
_val = _v.get('value', '')
if _val:
_conds.append('`' + _k + '` LIKE %s')
params.append('%' + _val + '%')
elif _t == 'list':
_vals = _v.get('values', [])
if _vals:
_non_empty = [x for x in _vals if x != '__EMPTY__']
_list_parts = []
if _non_empty:
_list_parts.extend(_build_exact_list_parts(_k, _non_empty, params))
if '__EMPTY__' in _vals:
_list_parts.append("(`" + _k + "` IS NULL OR `" + _k + "` = '')")
if _list_parts:
_conds.append('(' + ' OR '.join(_list_parts) + ')')
elif _t == 'num':
_cond = _v.get('cond', '')
_val = _v.get('val', '')
if _cond and _val:
cond_map = {'eq': '=', 'neq': '!=', 'gt': '>', 'gte': '>=', 'lt': '<', 'lte': '<='}
if _cond == 'range':
parts = [part.strip() for part in str(_val).split(',') if part.strip()]
if len(parts) == 2:
try:
_conds.append('`' + _k + '` >= %s AND `' + _k + '` <= %s')
params.extend([float(parts[0]), float(parts[1])])
except ValueError:
pass
else:
_op = cond_map.get(_cond)
if _op:
_conds.append('`' + _k + '` ' + _op + ' %s')
params.append(float(_val) if _val.replace('.', '').replace('-', '').isdigit() else _val)
except:
pass
if _conds:
where_extra += ' AND ' + ' AND '.join(_conds)
if field == '货品类型':
return jsonify({"code": 0, "data": _order_product_type_values('packaging_orders', '编码', where_extra, params, '来源产品编码')})
try:
hasEmpty = query('SELECT COUNT(*) AS cnt FROM ' + source + ' WHERE (`' + field + '` IS NULL OR CAST(`' + field + '` AS CHAR) = \'\')' + where_extra, params or None)
hasEmptyCnt = hasEmpty[0]['cnt'] if hasEmpty else 0
except Exception:
hasEmptyCnt = 0
sql = 'SELECT DISTINCT `' + field + '` AS val FROM ' + source + ' WHERE `' + field + '` IS NOT NULL AND CAST(`' + field + '` AS CHAR) != \'\'' + where_extra + ' ORDER BY val'
rows = query(sql, params or None)
vals = [r['val'] for r in rows]
if hasEmptyCnt > 0:
vals.append('__EMPTY__')
return jsonify({"code": 0, "data": vals})