commit b46717065da81a7236cb62c63f4a9cce9824ae38 Author: Codex Date: Wed Oct 7 15:17:15 2026 +0800 cloud baseline before actual quantity fix diff --git a/routes/purchase.py b/routes/purchase.py new file mode 100644 index 0000000..edb2c7b --- /dev/null +++ b/routes/purchase.py @@ -0,0 +1,4355 @@ +"""采购路由""" +import json +import os +import re +import unicodedata +from datetime import datetime +from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template +from services.db import query, execute, add_operation_log +from services.field_perms import filter_rows as filter_field_rows +from services.sales_calc import (compute_product_sales_cached, + compute_product_sales_extended_cached) +from services.inventory_calc import (compute_product_inventory_cached, + compute_product_inventory_15, + compute_product_in_transit, + compute_cloud_inventory_cached, + _determine_trend) +from services.forecast import (compute_daily_forecast, sales_predict_config_cached, + get_global_config) +from routes.auth import login_required, role_required, operation_required +from routes.products import INNER_MATERIAL_NAME_KEYS +from routes.planning import (_enrich_row_with_planning_data, _ensure_global_config, + _personnel_group_members, _personnel_group_for_value) + +purchase_bp = Blueprint('purchase', __name__) + +PACKAGING_ORDER_STATUSES = ('未完结', '已完结') + + +# 海外产品清单专属备货通知:名单是非敏感业务配置,Webhook/加签只从运行环境读取, +# 不写入代码、仓库或普通配置文件,避免与采购跟单群机器人混用。 +OVERSEAS_NOTIFY_NAMES_FILE = os.path.join( + os.path.dirname(os.path.dirname(__file__)), 'config', 'overseas_product_notify_names.json' +) +OVERSEAS_NOTIFY_WEBHOOK_FILE = '/etc/dy-dashboard/credentials/stocking-webhook' +OVERSEAS_NOTIFY_SECRET_FILE = '/etc/dy-dashboard/credentials/stocking-secret' +OVERSEAS_NOTIFY_GROUP_KEY = '__overseas_product_notify__' + + +def _normalize_overseas_product_name(value): + """标准化海外产品清单名称,仅处理格式差异,不做模糊截断匹配。""" + text = unicodedata.normalize('NFKC', str(value or '')).casefold() + return re.sub(r'\s+', '', text) + + +def _read_overseas_notify_credential(path): + """读取服务器侧受限凭据文件;明文不进入日志或应用响应。""" + try: + with open(path, 'r', encoding='utf-8') as handle: + return handle.read().strip() + except (OSError, UnicodeError): + return '' + + +def _load_overseas_notify_names(): + """加载海外产品名称名单;文件缺失/格式错误时安全降级为空名单。""" + try: + with open(OVERSEAS_NOTIFY_NAMES_FILE, 'r', encoding='utf-8') as handle: + payload = json.load(handle) + names = payload.get('names', []) if isinstance(payload, dict) else payload + return { + _normalize_overseas_product_name(name) + for name in names + if _normalize_overseas_product_name(name) + } + except (OSError, TypeError, ValueError) as exc: + import logging + logging.warning('[DING_NOTIFY] 海外产品清单加载失败: %s', exc) + return set() + + +OVERSEAS_NOTIFY_NAMES = _load_overseas_notify_names() + + +def _is_overseas_notify_product(product_name): + """按海外产品清单标准化名称判断,未匹配时不触发专属通知。""" + return _normalize_overseas_product_name(product_name) in OVERSEAS_NOTIFY_NAMES + + + +def _require_purchase_operation(page, operation): + """统一校验下单相关接口的操作权限。""" + if not operation_required(page, operation): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + return None + + +def _sync_backup_orderer(product_code, ordered_at, field, value): + """将订单管理中的下单人同步到对应的下单备份记录。""" + if not product_code or not ordered_at or field not in ('product_orderer', 'packaging_orderer'): + return 0 + return query( + f"UPDATE pending_orders SET `{field}`=%s WHERE product_code=%s " + "AND status IN ('ordered','rolled_back') AND ordered_at=%s", + (value or '', product_code, ordered_at) + ) + + +def _latest_domestic_order_values(product_code): + """读取同一货品编号最近一笔国内订单的单价和仓库。""" + if not product_code: + return {} + rows = query( + "SELECT `单价`, `仓库` FROM domestic_orders " + "WHERE `货品编号`=%s ORDER BY id DESC LIMIT 1", + (product_code,) + ) + return rows[0] if rows else {} + + +def _latest_packaging_order_values(material_code): + """读取同一包材编码最近一笔订单的单价和到货仓库。""" + if not material_code: + return {} + rows = query( + "SELECT `单价`, `仓库到货` FROM packaging_orders " + "WHERE `编码`=%s ORDER BY id DESC LIMIT 1", + (material_code,) + ) + return rows[0] if rows else {} + + +def _supplier_maps(): + """构建供应商别名→全称,以及全称→简称映射。""" + rows = query("SELECT supplier_name, short_name, contract_short_name FROM suppliers") + full_name_map = {} + short_name_map = {} + for supplier in rows: + full_name = (supplier.get('supplier_name') or '').strip() + if not full_name: + continue + short_name = (supplier.get('short_name') or '').strip() + short_name_map[full_name] = short_name or full_name + for alias in (full_name, short_name, supplier.get('contract_short_name')): + alias = (alias or '').strip() + if alias: + full_name_map[alias] = full_name + return full_name_map, short_name_map + + +def _supplier_full_name_map(): + """兼容旧调用:构建供应商全称及简称到全称的映射。""" + return _supplier_maps()[0] + + +def _packaging_supplier_full_name(material_code, fallback='', source_product_code=''): + """解析包材供应商全称,BOM/名称字段优先于可能已失步的供应商编码。 + + 产品导入或人工编辑历史上可能只更新 ``supplier_name_match``,留下旧的 + ``supplier_code``。下单时若无条件按编码取供应商,会把正确名称覆盖成旧供应商。 + 因此优先级固定为:当前成品 BOM 供应商 > 物料供应商名称 > 请求回退值 > 编码绑定。 + """ + material_code = str(material_code or '').strip() + source_product_code = str(source_product_code or '').strip() + fallback = str(fallback or '').strip() + if not material_code: + return fallback + + rows = [] + if source_product_code: + rows = query( + """SELECT NULLIF(b.supplier, '') AS bom_supplier, + NULLIF(p.supplier_name_match, '') AS product_supplier, + NULLIF(s_code.supplier_name, '') AS code_supplier + FROM bom b + LEFT JOIN products p ON p.product_code=b.material_code + LEFT JOIN suppliers s_code ON s_code.supplier_code=p.supplier_code + WHERE b.product_code=%s AND b.material_code=%s + ORDER BY b.id DESC LIMIT 1""", + (source_product_code, material_code) + ) + if not rows: + rows = query( + """SELECT NULL AS bom_supplier, + NULLIF(p.supplier_name_match, '') AS product_supplier, + NULLIF(s_code.supplier_name, '') AS code_supplier + FROM products p + LEFT JOIN suppliers s_code ON s_code.supplier_code=p.supplier_code + WHERE p.product_code=%s LIMIT 1""", + (material_code,) + ) + + row = rows[0] if rows else {} + full_name_map, _ = _supplier_maps() + for candidate in ( + row.get('bom_supplier'), + row.get('product_supplier'), + fallback, + row.get('code_supplier'), + ): + candidate = str(candidate or '').strip() + if candidate: + return full_name_map.get(candidate, candidate) + return '' + + +# ════════════════════════════════════════════════════════════════ +# 确认下单 → 钉钉群备货通知(采购跟单组别 → 对应钉钉群) +# 2026-08-24 旭东确认:群映射如下,通知格式为备货通知(货品编号/货品名称/成品数量/包材数量) +# ════════════════════════════════════════════════════════════════ +FOLLOWER_GROUP_MAP = { + '跟单一组': {'group_name': '采购1组(方/余/卢/陈/李)', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=0f7c5e134dc165eb30d580a4da3e1e141ba45c68958c4b35505202a38d6e232c', 'secret': 'SEC8a6886f1dd64cdcb3ad03373f17038c1c482ed7834218ad72ab895ae279fa8cf'}, + '跟单二组': {'group_name': '采购2组(刘/闫/许/邓/范)', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=f8314992a2a7ae29d1875ce97becadf2f6fc78cf6234083192eb9625675b3ece', 'secret': 'SEC3f83f35be412b6a977f3f9ac72a6d8e5f850010255adfb04e26029ee89332105'}, + '跟单三组': {'group_name': '采购3组(蔡/张/邓/胡/李/张)', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=55df3fb01e5ab317d0ae37a14f28e1de20a981bc2839892b7c9f39446184b4e0', 'secret': 'SECa76a2139a41e16abbf799352526b3215e9843d780cbfd95bf8ca4865c1c1f26b'}, + '现货小组': {'group_name': '现货采购群', 'webhook': 'https://oapi.dingtalk.com/robot/send?access_token=8fd43253139d757ae4d14b71363ea4b40b2e8cc7165160227071d9d077e41088', 'secret': 'SEC14e42f8451d0fab4a572ffbb7b277b83857febc4e4478683fc1dc9518e91c5e9'}, +} + + +def _resolve_follower_group(follower): + """将采购跟单字段值解析为组别(组名或成员姓名列表 → 组名),解析不到返回 None。""" + if not follower: + return None + follower = str(follower).strip() + if follower in FOLLOWER_GROUP_MAP: + return follower + try: + groups = _personnel_group_members() + label = _personnel_group_for_value(follower, groups) + if label and label in FOLLOWER_GROUP_MAP: + return label + except Exception as e: + import logging + logging.warning(f'[DING_NOTIFY] 组别解析异常: {e}') + return None + + +def _send_ding_robot(webhook_url, secret, content): + """通过钉钉机器人 Webhook 发送 markdown 消息;失败仅记日志,不影响主流程。 + 支持可选加签(secret 为空则跳过验签)。""" + import hashlib + import hmac + import base64 + import time + import json + import urllib.parse + import urllib.request + import logging + try: + payload = { + "msgtype": "markdown", + "markdown": {"title": "备货通知", "text": content}, + } + url = webhook_url + if secret: + timestamp = str(round(time.time() * 1000)) + string_to_sign = f'{timestamp}\n{secret}' + hmac_code = hmac.new(secret.encode('utf-8'), string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest() + sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) + url = f'{webhook_url}×tamp={timestamp}&sign={sign}' + req = urllib.request.Request( + url, data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read().decode('utf-8')) + ok = result.get('errcode') == 0 + if not ok: + logging.warning(f'[DING_NOTIFY] 机器人返回错误 errcode={result.get("errcode")} errmsg={result.get("errmsg")}') + return ok + except Exception as e: + logging.warning(f'[DING_NOTIFY] 群消息发送异常: {e}') + return False + + +def _notify_purchase_ready(notify_groups): + """后台线程:按群聚合发送备货通知(每条商品一段,群内聚合为一条消息)。 + 钉钉机器人 markdown 单换行不生效,改用
强制换行,字段逐行展示。""" + import logging + for webhook, info in notify_groups.items(): + try: + blocks = [line.replace('\n', '
') for line in info['lines']] + if info.get('kind') == 'overseas_product': + # 专属机器人必须单独配置;缺失时不回退到任何现有采购群机器人。 + webhook = _read_overseas_notify_credential(OVERSEAS_NOTIFY_WEBHOOK_FILE) + secret = _read_overseas_notify_credential(OVERSEAS_NOTIFY_SECRET_FILE) + if not webhook: + logging.warning('[DING_NOTIFY] 海外产品专属机器人未配置,跳过通知') + continue + content = '备货通知

' + '
'.join(blocks) + _send_ding_robot(webhook, secret, content) + continue + content = '📦 **备货通知**

' + '

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