"""采购路由"""
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
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,
compute_cloud_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 routes.planning import (_enrich_row_with_planning_data, _ensure_global_config,
_personnel_group_members, _personnel_group_for_value)
purchase_bp = Blueprint('purchase', __name__)
PACKAGING_ORDER_STATUSES = ('未完结', '已完结')
# 海外产品清单专属备货通知:名单是非敏感业务配置,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}×tamp={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 单换行不生效,改用
强制换行,字段逐行展示。"""
import logging
for webhook, info in notify_groups.items():
try:
blocks = [line.replace('\n', '
') 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 = '备货通知
' + '
'.join(blocks)
_send_ding_robot(webhook, secret, content)
continue
content = '📦 **备货通知**
' + '
'.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 _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 _order_table_with_product_type(table, code_field, fallback_code_field=None):
"""构造带产品信息货品类型的订单数据源,使用一次性关联避免逐行子查询。"""
join_condition = f"BINARY product_type_lookup.product_code = BINARY order_rows.`{code_field}`"
type_expr = 'product_type_lookup.product_type'
if fallback_code_field:
join_condition += f" OR BINARY fallback_product_type_lookup.product_code = BINARY 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"LEFT JOIN products product_type_lookup ON {join_condition.split(' OR ')[0]} "
f"LEFT JOIN products fallback_product_type_lookup ON {join_condition.split(' OR ')[1]}) {table}"
)
return (
f"(SELECT order_rows.*, COALESCE({type_expr}, '') AS `货品类型` FROM {table} order_rows "
f"LEFT JOIN products product_type_lookup ON {join_condition}) {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 = (
"LEFT JOIN products p_primary ON BINARY p_primary.product_code = "
"BINARY o.primary_code "
"LEFT JOIN products p_fallback ON BINARY p_fallback.product_code = "
"BINARY o.fallback_code"
)
type_expr = "COALESCE(p_primary.product_type, p_fallback.product_type, '')"
else:
join_sql = (
"LEFT JOIN products p ON BINARY p.product_code = BINARY 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."""
return """(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
LEFT JOIN products material_product
ON BINARY material_product.product_code = BINARY order_rows.`编码`
LEFT JOIN products finished_product
ON BINARY finished_product.product_code = BINARY order_rows.`来源产品编码`
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:
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,
(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']:
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))
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)
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。
existing_pending = query("SELECT id, bom_items FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
if existing_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:
import json
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:
import json
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))
# 已存在待下单行时按 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,
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,
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)
VALUES (%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
))
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():
"""更新待下单记录的备注等字段"""
denied = _require_purchase_operation('purchase', 'edit')
if denied:
return denied
data = request.json
product_code = data.get('product_code', '')
if not product_code:
return jsonify({"code": 400, "msg": "参数错误"})
current_rows = query(
"SELECT remark, bom_items FROM pending_orders WHERE product_code=%s AND status='pending' LIMIT 1",
(product_code,)
)
current = current_rows[0] if current_rows else {}
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 WHERE product_code=%s AND status='pending'", (remark, product_code))
bom_items = data.get('bom_items')
if bom_items is not None:
import json
bom_str = json.dumps(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}'
)
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='pending'", (bom_str, product_code))
# 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。
_sync_pending_pre_packaging(product_code, _normalize_pending_bom_items(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 FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
if not pending_rows:
continue
product_rows = query("SELECT brand, product_name FROM products WHERE product_code=%s AND deleted_at IS NULL 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]
# 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:
# 写入海外订单表(含全部字段,日期留空由用户自行填写)
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)
""", (
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', '')
))
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,确保刷新页面后确认下单仍能带上包材数量和备注。
request_bom = bom_data_map.get(pc)
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 = int(qty or 0)
for bm in bom_list:
if bm.get('material_code') == f'成品_{pc}' and bm.get('actual_quantity') is not None:
product_actual_qty = int(bm.get('actual_quantity') or 0)
break
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
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
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
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
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)
VALUES (%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
))
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('计划下单时间'))
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('计划下单时间'))
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 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 _ensure_arrival_batches_table():
"""Ensure arrival batch storage includes the defective quantity field."""
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,
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""")
_ARRIVAL_BATCHES_SCHEMA_READY = True
def _arrival_batch_quantities(data):
"""Parse and validate gross/defective arrival quantities."""
try:
arrival_qty = int(data.get('arrival_qty') or 0)
defective_qty = int(data.get('defective_qty') or 0)
except (TypeError, ValueError):
raise ValueError('到货数量和不良品数量必须为整数')
if arrival_qty < 0 or defective_qty < 0:
raise ValueError('到货数量和不良品数量不能为负数')
if defective_qty > arrival_qty:
raise ValueError('不良品数量不能大于到货数量')
return arrival_qty, defective_qty
@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": "订单类型错误"})
order_id_int = int(order_id)
_ensure_arrival_batches_table()
arrival_batches = list(query("""
SELECT id, arrival_date, arrival_qty, defective_qty, remark
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]
packaging_materials = []
if product_code:
bom_materials = query("""
SELECT b.material_code, b.material_name, b.quantity AS bom_qty
FROM bom b WHERE b.product_code=%s ORDER BY b.id
""", (product_code,))
for material in bom_materials:
material_code = material['material_code']
material_name = material['material_name'] or ''
if '内料' in material_name or '-NL' in material_code:
continue
package_rows = query("""
SELECT `数量` AS order_qty, `到货日期`, `进度`, `下单时间`
FROM packaging_orders WHERE `编码`=%s ORDER BY id DESC LIMIT 1
""", (material_code,))
package = package_rows[0] if package_rows else {}
packaging_materials.append({
'material_code': material_code,
'material_name': material_name,
'order_time': (package.get('下单时间') or '')[:16],
'order_qty': package.get('order_qty') or 0,
'arrival_qty': package.get('order_qty') or 0,
'arrival_date': (package.get('到货日期') or '')[:10],
'progress': package.get('进度') or '',
})
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_batch_quantities(data)
except ValueError as exc:
return jsonify({"code": 400, "msg": str(exc)})
row_id = query("""INSERT INTO arrival_batches
(order_id, order_type, arrival_date, arrival_qty, defective_qty, remark)
VALUES (%s,%s,%s,%s,%s,%s)""", (
order_id, order_type, data.get('arrival_date') or None,
arrival_qty, defective_qty, 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}})
@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()
try:
arrival_qty, defective_qty = _arrival_batch_quantities(data)
except ValueError as exc:
return jsonify({"code": 400, "msg": str(exc)})
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("""UPDATE arrival_batches
SET arrival_date=%s, arrival_qty=%s, defective_qty=%s, remark=%s
WHERE id=%s AND order_type=%s""", (
data.get('arrival_date') or None, arrival_qty, defective_qty,
data.get('remark') or '', 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"修改到货批次:到货日期={data.get('arrival_date') or '空'},数量={arrival_qty},不良品数量={defective_qty},备注={data.get('remark') or '空'}(批次ID={batch_id},订单ID={batch_rows[0]['order_id']})", page=page_name, field='到货批次及数量')
return jsonify({"code": 0, "msg": "更新成功"})
@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():
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
rows = []
for source, sql in (
('domestic', """
SELECT `货品编号` AS product_code, `进度`,
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
id
FROM domestic_orders
WHERE `状态`='未完结'
"""),
('overseas', """
SELECT `产品编码` AS product_code, `进度`,
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
id
FROM overseas_orders
WHERE `状态`='未完结'
"""),
):
for row in query(sql):
row['_source_order'] = 0 if source == 'domestic' else 1
rows.append(row)
def sort_key(row):
order_date = row.get('order_date')
if order_date and hasattr(order_date, 'strftime'):
date_text = order_date.strftime('%Y-%m-%d %H:%M:%S')
else:
date_text = str(order_date or '').strip()
return (0 if date_text else 1, date_text, row.get('_source_order', 0), row.get('id') or 0)
rows.sort(key=sort_key)
progress_map = {}
for row in rows:
product_code = str(row.get('product_code') or '').strip()
if product_code and product_code not in progress_map:
progress_map[product_code] = row.get('进度') or ''
return progress_map
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 = compute_cloud_inventory_cached()
from services.inventory_calc import compute_product_inventory_15
inv15_map = compute_product_inventory_15()
transit_map = compute_product_in_transit()
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['昨日销量'] = 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': 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, 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, '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')
))
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 status='ordered'", (backup_id,))
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]
@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
"""Update only order rows created by this exact backup order time."""
data = request.json
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": "参数错误"})
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:
query("""UPDATE pending_orders SET production_order_no=%s WHERE id=%s AND status='ordered'""",
(prod_no, backup_id))
else:
query("""UPDATE pending_orders SET production_order_no=%s
WHERE product_code=%s AND status='ordered' AND ordered_at=%s""",
(prod_no, product_code, ordered_at))
query("""UPDATE domestic_orders SET `旺店通生产单号`=%s
WHERE `货品编号`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
query("""UPDATE overseas_orders SET `旺店通生产单号`=%s
WHERE `产品编码`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
query("""UPDATE packaging_orders SET `生产单号`=%s
WHERE `来源产品编码`=%s AND `下单时间`=%s""", (prod_no, product_code, ordered_at))
add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}')
return jsonify({"code": 0, "msg": "保存成功"})
@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})