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