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