"""产品管理路由""" import os import uuid from datetime import datetime from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template from services.db import query, add_operation_log, get_db from services.product_type import get_product_type_cached 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_cloud_inventory_cached, compute_external_inventory_cached, compute_product_in_transit_cached, _determine_trend) from services.forecast import (is_new_product, get_product_grade, compute_daily_forecast, sales_predict_config_cached) from routes.auth import login_required, role_required, operation_required from services.field_perms import filter_rows as _filter_field_rows products_bp = Blueprint('products', __name__) # 计划预测下单信息弹窗显示内料的货品名称关键字:海外品牌还要命中国家名才显示内料 INNER_MATERIAL_NAME_KEYS = ('新西兰', '新加坡', '韩国') # BOM 的 supplier 字段历史上混入过产品类型(例如“原料”),不能作为供应商名称展示。 # 这些值只在 BOM 供应商字段中视为无效,随后回退到供应商主表或物料基础资料。 BOM_INVALID_SUPPLIER_VALUES = frozenset(('原料', '包材', '成品', '其他')) def _normalize_bom_supplier(value): """清理 BOM 供应商输入,避免把产品类型写入供应商字段。""" value = str(value or '').strip() return '' if value in BOM_INVALID_SUPPLIER_VALUES else value def _bom_material_supplier_sql(b_alias='b', material_alias='m', supplier_alias='ms'): """返回 BOM 物料供应商的统一 SQL 表达式。 优先使用 BOM 中明确填写的真实供应商;历史无效类型值(如“原料”) 视为空,再回退到供应商主表,最后回退到物料基础资料中的匹配名称。 """ invalid = ', '.join("'%s'" % value for value in sorted(BOM_INVALID_SUPPLIER_VALUES)) return ( f"COALESCE(NULLIF(CASE WHEN TRIM({b_alias}.supplier) IN ({invalid}) " f"THEN '' ELSE TRIM({b_alias}.supplier) END, ''), " f"NULLIF(TRIM({supplier_alias}.supplier_name), ''), " f"NULLIF(TRIM({material_alias}.supplier_name_match), ''), '')" ) def _check_product_edit_perm(): """统一检查产品编辑权限,角色默认权限与用户覆盖规则一致。""" return operation_required('products', 'edit') @products_bp.route('/products') @login_required def products_page(): if not role_required('products'): return redirect(url_for('auth.index')) return render_template('base.html', page='products') @products_bp.route('/brand-management') @login_required def brand_management_page(): if not role_required('products'): return redirect(url_for('auth.index')) return render_template('base.html', page='brand_management') @products_bp.route('/api/brand-management/list') @login_required def brand_management_list(): if not role_required('products'): return jsonify({"code": 403, "msg": "权限不足"}) search = (request.args.get('search') or '').strip() category = (request.args.get('category') or '').strip() page = max(int(request.args.get('page', 1)), 1) size = 10 # 产品表中的品牌 + 手动维护的品牌合并展示;产品数量不再作为页面字段。 product_rows = query("""SELECT TRIM(brand) AS brand FROM products WHERE deleted_at IS NULL AND brand IS NOT NULL AND TRIM(brand) <> '' GROUP BY TRIM(brand) ORDER BY TRIM(brand)""") saved_rows = query("SELECT brand, category, updated_at FROM brand_information ORDER BY brand") saved = {r['brand']: r for r in saved_rows} names = {r['brand'] for r in product_rows} names.update(saved.keys()) rows = [] for brand in sorted(names, key=lambda x: x.casefold()): if search and search.casefold() not in brand.casefold(): continue info = saved.get(brand) or {} cat = info.get('category') or '' if category == '未分类' and cat: continue if category in ('国内', '海外') and cat != category: continue updated = info.get('updated_at') rows.append({'brand': brand, 'category': cat, 'updated_at': updated.strftime('%Y-%m-%d %H:%M:%S') if updated and hasattr(updated, 'strftime') else (str(updated) if updated else '')}) total = len(rows) return jsonify({"code": 0, "data": rows[(page - 1) * size:page * size], "total": total, "page": page, "size": size}) @products_bp.route('/api/brand-management/create', methods=['POST']) @login_required def brand_management_create(): if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "当前账号没有编辑权限"}) data = request.get_json(silent=True) or {} brand = (data.get('brand') or '').strip() category = (data.get('category') or '').strip() if not brand: return jsonify({"code": 400, "msg": "品牌名称不能为空"}) if category not in ('', '国内', '海外'): return jsonify({"code": 400, "msg": "品牌分类只能是国内或海外"}) exists = query("SELECT 1 FROM brand_information WHERE brand=%s LIMIT 1", (brand,)) product_exists = query("SELECT 1 FROM products WHERE deleted_at IS NULL AND TRIM(brand)=%s LIMIT 1", (brand,)) if exists or product_exists: return jsonify({"code": 400, "msg": "该品牌已存在"}) query("INSERT INTO brand_information (brand, category) VALUES (%s, %s)", (brand, category)) add_operation_log('新增品牌', f"新增品牌「{brand}」", page='品牌信息管理', field='品牌名称') return jsonify({"code": 0, "msg": "新增成功"}) @products_bp.route('/api/brand-management/update', methods=['POST']) @login_required def brand_management_update(): if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "当前账号没有编辑权限"}) data = request.get_json(silent=True) or {} old_brand = (data.get('old_brand') or data.get('brand') or '').strip() new_brand = (data.get('new_brand') or old_brand).strip() category = data.get('category') category = (category if category is not None else '').strip() if not old_brand or not new_brand: return jsonify({"code": 400, "msg": "品牌名称不能为空"}) if category not in ('', '国内', '海外'): return jsonify({"code": 400, "msg": "品牌分类只能是国内或海外"}) if old_brand != new_brand: conflict = query("SELECT 1 FROM brand_information WHERE brand=%s LIMIT 1", (new_brand,)) product_conflict = query("SELECT 1 FROM products WHERE deleted_at IS NULL AND TRIM(brand)=%s LIMIT 1", (new_brand,)) if conflict or product_conflict: return jsonify({"code": 400, "msg": "新的品牌名称已存在"}) has_product = query("SELECT 1 FROM products WHERE deleted_at IS NULL AND TRIM(brand)=%s LIMIT 1", (old_brand,)) has_saved = query("SELECT 1 FROM brand_information WHERE brand=%s LIMIT 1", (old_brand,)) if not has_product and not has_saved: return jsonify({"code": 404, "msg": "未找到该品牌"}) if has_product and old_brand != new_brand: query("UPDATE products SET brand=%s WHERE deleted_at IS NULL AND TRIM(brand)=%s", (new_brand, old_brand)) if has_saved: query("UPDATE brand_information SET brand=%s, category=%s, updated_at=CURRENT_TIMESTAMP WHERE brand=%s", (new_brand, category, old_brand)) else: query("INSERT INTO brand_information (brand, category) VALUES (%s, %s)", (new_brand, category)) add_operation_log('编辑品牌', f"将品牌「{old_brand}」修改为「{new_brand}」", page='品牌信息管理', field='品牌名称') return jsonify({"code": 0, "msg": "保存成功"}) @products_bp.route('/api/brand-management/delete', methods=['POST']) @login_required def brand_management_delete(): """删除误添加且未被有效产品使用的品牌。""" if not operation_required('brand_management', 'delete'): return jsonify({"code": 403, "msg": "当前账号没有删除品牌权限"}) data = request.get_json(silent=True) or {} brand = (data.get('brand') or '').strip() if not brand: return jsonify({"code": 400, "msg": "品牌名称不能为空"}) product_rows = query( "SELECT COALESCE(NULLIF(TRIM(product_type), ''), '未分类') AS product_type, " "COUNT(*) AS cnt FROM products " "WHERE deleted_at IS NULL AND TRIM(brand)=%s " "GROUP BY COALESCE(NULLIF(TRIM(product_type), ''), '未分类') " "ORDER BY cnt DESC", (brand,), ) product_count = sum(int(row.get('cnt') or 0) for row in product_rows) if product_count: usage_counts = { (row.get('product_type') or '未分类'): int(row.get('cnt') or 0) for row in product_rows } usage_summary = '、'.join( f"{product_type}{count}条" for product_type, count in usage_counts.items() ) return jsonify({ "code": 409, "msg": f"品牌「{brand}」正在被产品信息管理基础表中的{product_count}条未回收记录使用" f"({usage_summary}),请先修改这些产品的品牌后再删除", "product_count": product_count, "usage_counts": usage_counts, }) saved = query("SELECT brand, category FROM brand_information WHERE brand=%s LIMIT 1", (brand,)) if not saved: return jsonify({"code": 404, "msg": "未找到可删除的品牌记录"}) query("DELETE FROM brand_information WHERE brand=%s", (brand,)) add_operation_log( '删除品牌', f"删除品牌「{brand}」", page='品牌信息管理', field='品牌名称', object_type='brand', object_id=brand, old_values=saved[0], ) return jsonify({"code": 0, "msg": "删除成功"}) @products_bp.route('/bom') @login_required def bom_page(): if not role_required('bom'): return redirect(url_for('auth.index')) return render_template('base.html', page='bom') @products_bp.route('/suppliers') @login_required def suppliers_page(): if not role_required('suppliers'): return redirect(url_for('auth.index')) return render_template('base.html', page='suppliers') # ─── API: 产品基础表 ─── @products_bp.route('/api/products/list') @login_required def products_list(): page = int(request.args.get('page', 1)) size = int(request.args.get('size', 50)) offset = (page - 1) * size search = request.args.get('search', '') unified_search = request.args.get('unified_search', '') prod_code = request.args.get('prod_code', '') prod_name = request.args.get('prod_name', '') supplier_filter = request.args.get('supplier', '') brand_filter = request.args.get('brand', '') category1_filter = request.args.get('category1', '') product_type_filter = request.args.get('product_type', '') sort_by = request.args.get('sort_by', 'id') sort_order = request.args.get('sort_order', 'desc') # Whitelist sort columns to prevent SQL injection allowed_sort = ['id', 'goods_code', 'product_code', 'product_name', 'brand', 'cost_price', 'category1', 'category2', 'category3', 'created_at', 'import_date'] if sort_by not in allowed_sort: sort_by = 'id' if sort_order.lower() not in ('asc', 'desc'): sort_order = 'desc' where = [] params = [] if search or unified_search: search_value = search or unified_search where.append("(p.product_name LIKE %s OR p.product_code LIKE %s)") params.extend([f"%{search_value}%", f"%{search_value}%"]) if prod_code: where.append("p.product_code LIKE %s") params.append(f"%{prod_code}%") if prod_name: where.append("p.product_name LIKE %s") params.append(f"%{prod_name}%") if supplier_filter: where.append("(s.supplier_name LIKE %s OR p.supplier_name_match LIKE %s)") params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"]) if brand_filter: where.append("TRIM(p.brand)=%s") params.append(brand_filter) if category1_filter: where.append("p.category1=%s") params.append(category1_filter) where_clause = " AND ".join(where) if where else "1=1" # 加载供应商列表用于自动匹配编码 _suppliers = query("SELECT supplier_code, supplier_name FROM suppliers") def _format_product_row(row): if 'created_at' in row and row['created_at']: row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_at'], 'strftime') else str(row['created_at'])[:19] if 'import_date' in row and row['import_date']: row['import_date'] = row['import_date'].strftime('%Y-%m-%d') if hasattr(row['import_date'], 'strftime') else str(row['import_date'])[:10] if 'cost_price' in row and row['cost_price'] is not None: row['cost_price'] = float(row['cost_price']) if row.get('logistics_time') is None: row['logistics_time'] = 0 if row.get('lead_time') is None: row['lead_time'] = 0 row['是否新品'] = is_new_product(row.get('created_at', '')) if not row.get('product_type'): row['product_type'] = get_product_type_cached(row.get('product_name', ''), row.get('brand'), row.get('product_type')) # 自动匹配供应商编码(如果supplier_code为空但有supplier_name_match) pname = (row.get('supplier_name_match') or '').strip() pcode = (row.get('supplier_code') or '').strip() if pname and (not pcode or pcode == ''): for s in _suppliers: sname = (s['supplier_name'] or '').strip() if pname in sname or sname in pname: row['supplier_code'] = s['supplier_code'] row['supplier_name_match'] = s['supplier_name'] try: query("UPDATE products SET supplier_code=%s, supplier_name_match=%s WHERE product_code=%s", (s['supplier_code'], s['supplier_name'], row['product_code'])) except: pass break return row # 如果有类型筛选,需要全量查出来再在 Python 中过滤 if product_type_filter: all_rows = query(f""" SELECT p.id, p.goods_code, p.product_code, p.product_name, p.brand, p.cost_price, p.product_type, p.specification, p.lead_time, p.logistics_time, p.min_order_qty, p.supplier_code, p.supplier_name_match, p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.developer, p.category1, p.category2, p.category3, p.created_at, p.import_date, p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, 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 {where_clause} AND p.deleted_at IS NULL ORDER BY p.id DESC """, params) filtered = [] for row in all_rows: row = _format_product_row(row) if product_type_filter == '非成品': # 非成品:排除成品 if row['product_type'] != '成品': filtered.append(row) elif product_type_filter == '非包材': # 非包材:排除包材和其他类 if row['product_type'] != '包材' and row['product_type'] != '其他': filtered.append(row) elif product_type_filter == '非成品专用': # 非成品专用:仅包材和其他 if row['product_type'] == '包材' or row['product_type'] == '其他': filtered.append(row) else: if row['product_type'] == product_type_filter: filtered.append(row) reverse = sort_order.lower() == 'desc' if sort_by in ['cost_price', 'id']: filtered.sort(key=lambda x: x.get(sort_by, 0) if x.get(sort_by) else 0, reverse=reverse) else: filtered.sort(key=lambda x: str(x.get(sort_by, '') or '').lower(), reverse=reverse) total = len(filtered) rows = filtered[offset:offset + size] else: total = query(f"""SELECT COUNT(*) AS cnt FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND p.deleted_at IS NULL""", params)[0]['cnt'] rows = query(f""" SELECT p.id, p.goods_code, p.product_code, p.product_name, p.brand, p.cost_price, p.product_type, p.specification, p.lead_time, p.logistics_time, p.min_order_qty, p.supplier_code, p.supplier_name_match, p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.developer, p.category1, p.category2, p.category3, p.created_at, p.import_date, p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, 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 {where_clause} AND p.deleted_at IS NULL ORDER BY p.{sort_by} {sort_order} LIMIT {size} OFFSET {offset} """, params) for row in rows: _format_product_row(row) _filter_field_rows('products', rows, session.get('user_id'), session.get('role'), page_view=bool(role_required('products')), page_edit=operation_required('products', 'edit')) return jsonify({ "code": 0, "data": { "total": total, "page": page, "size": size, "rows": rows } }) @products_bp.route('/api/products/filters') @login_required def products_filters(): scope = request.args.get('scope', 'all') if scope == 'active': scope_clause = 'deleted_at IS NULL AND ' elif scope == 'recycle': scope_clause = 'deleted_at IS NOT NULL AND ' else: scope_clause = '' brands = query( f"SELECT DISTINCT TRIM(brand) AS brand FROM products WHERE {scope_clause}" "brand IS NOT NULL AND TRIM(brand) != '' ORDER BY TRIM(brand)" ) categories = query( f"SELECT DISTINCT category1 FROM products WHERE {scope_clause}" "category1 IS NOT NULL AND category1 != '' ORDER BY category1" ) return jsonify({ "code": 0, "data": { "brands": [b['brand'] for b in brands], "categories": [c['category1'] for c in categories] } }) @products_bp.route('/api/products/detail') @login_required def products_detail(): pid = request.args.get('id', 0) row = query(""" 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 p.id=%s """, (pid,)) if not row: return jsonify({"code": 404, "msg": "未找到该产品"}) row = row[0] if 'created_at' in row and row['created_at']: row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_at'], 'strftime') else str(row['created_at'])[:19] if 'import_date' in row and row['import_date']: row['import_date'] = row['import_date'].strftime('%Y-%m-%d') if hasattr(row['import_date'], 'strftime') else str(row['import_date'])[:10] if 'cost_price' in row and row['cost_price'] is not None: row['cost_price'] = float(row['cost_price']) row['product_type'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) return jsonify({"code": 0, "data": row}) @products_bp.route('/api/product/update_fields', methods=['POST']) @login_required def product_update_fields(): """更新产品字段(仅管理员)""" if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json product_code = data.get('product_code', '') field = data.get('field', '') value = data.get('value', '') allowed_fields = ['lead_time', 'logistics_time', 'min_order_qty', 'developer'] if field not in allowed_fields: return jsonify({"code": 400, "msg": "不允许的字段"}) try: query(f"UPDATE products SET {field}=%s WHERE product_code=%s", (value, product_code)) add_operation_log('产品编辑', f'修改 {product_code} 的 {field} 为 {value}') return jsonify({"code": 0, "msg": "更新成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product/batch_update', methods=['POST']) @login_required def product_batch_update(): """批量更新产品字段(编辑弹窗用)""" if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json product_code = data.get('product_code', '') fields = data.get('fields', {}) if not product_code or not fields: return jsonify({"code": 400, "msg": "参数不完整"}) allowed_fields = [ 'lead_time', 'logistics_time', 'min_order_qty', 'developer', 'cost_price', 'brand', 'specification', 'product_type', 'category1', 'category2', 'category3', 'supplier_code', 'supplier_name_match', 'procurement_follower', 'procurement_planner', 'operator', 'platform', 'product_name', 'created_at', 'custom_field1', 'custom_field2', 'custom_field3', 'custom_field4', 'custom_field5', 'remark', 'product_code' ] old_code = product_code new_code = fields.get('product_code', old_code) set_parts = [] params = [] for field, value in fields.items(): if field not in allowed_fields: continue if value == '' or value is None: value = None # 数值字段范围校验 if field in ('lead_time', 'logistics_time') and value is not None: try: if int(value) > 999: return jsonify({"code": 400, "msg": f"{field} 数值不能超过 999"}) except (ValueError, TypeError): pass set_parts.append(f"{field}=%s") params.append(value) if not set_parts: return jsonify({"code": 400, "msg": "没有可更新的字段"}) set_clause = ", ".join(set_parts) params.append(old_code) try: # 如果货品编号变更 if new_code != old_code and new_code: # 检查目标编号是否已被其他产品使用 existing_owner = query("SELECT id FROM products WHERE product_code=%s AND product_code!=%s", (new_code, old_code)) if existing_owner: # 目标编号已存在 → 合并:更新关联表引用,删除旧产品 for table_name, col_name in [ ('bom', 'product_code'), ('pending_orders', 'product_code'), ('purchase_order_items', 'product_code'), ('plan_hidden', 'product_code'), ]: query(f"UPDATE IGNORE {table_name} SET {col_name}=%s WHERE {col_name}=%s", (new_code, old_code)) # 删除旧产品记录 query("DELETE FROM products WHERE product_code=%s", (old_code,)) # 更新已存在的目标产品(合并表单数据) target_params = params[:-1] # 参数(不含old_code) target_params.append(new_code) query(f"UPDATE products SET {set_clause} WHERE product_code=%s", target_params) change_info = f'{old_code}→{new_code}(已覆盖)' else: # 目标编号不存在,直接更新关联表和产品 for table_name, col_name in [ ('bom', 'product_code'), ('pending_orders', 'product_code'), ('purchase_order_items', 'product_code'), ('plan_hidden', 'product_code'), ]: query(f"UPDATE IGNORE {table_name} SET {col_name}=%s WHERE {col_name}=%s", (new_code, old_code)) query(f"UPDATE products SET {set_clause} WHERE product_code=%s", params) change_info = f'{old_code}→{new_code}' else: query(f"UPDATE products SET {set_clause} WHERE product_code=%s", params) change_info = old_code # 产品名称变更时同步更新BOM的名称 if 'product_name' in fields and fields['product_name']: new_name = fields['product_name'] final_code = new_code if new_code != old_code and new_code else old_code query("UPDATE bom SET product_name=%s WHERE product_code=%s", (new_name, final_code)) query("UPDATE bom SET material_name=%s WHERE material_code=%s", (new_name, final_code)) changed = ', '.join([f'{k}={v}' for k, v in fields.items() if v is not None]) add_operation_log('产品批量编辑', f'{change_info}: {changed}') return jsonify({"code": 0, "msg": "更新成功"}) except Exception as e: return jsonify({"code": 500, "msg": f"操作失败: {str(e)}"}) @products_bp.route('/api/product/full_detail') @login_required def product_full_detail(): code = request.args.get('code', '') if not code: return jsonify({"code": 400, "msg": "缺少货品编号"}) row = query(""" 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 p.product_code=%s """, (code,)) if not row: return jsonify({"code": 404, "msg": "未找到该产品"}) product = row[0] if product.get('created_at'): product['created_at'] = product['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(product['created_at'], 'strftime') else str(product['created_at'])[:19] if product.get('import_date'): product['import_date'] = product['import_date'].strftime('%Y-%m-%d') if hasattr(product['import_date'], 'strftime') else str(product['import_date'])[:10] if product.get('cost_price') is not None: product['cost_price'] = float(product['cost_price']) product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) sales = query(""" SELECT 日期, SUM(货品数量) AS 日销量 FROM daily_outbound WHERE 货品编号=%s GROUP BY 日期 ORDER BY 日期 ASC """, (code,)) sales_data = [] for s in sales: d = s['日期'].strftime('%Y-%m-%d') if hasattr(s['日期'], 'strftime') else str(s['日期'])[:10] sales_data.append({ 'date': d, 'qty': int(s['日销量']) if s['日销量'] else 0 }) return jsonify({ "code": 0, "data": { "product": product, "sales": sales_data } }) @products_bp.route('/api/product/detail_with_bom') @login_required def product_detail_with_bom(): """产品详情 + BOM物料清单 + 订单数量""" code = request.args.get('code', '') include_inner = request.args.get('include_inner', '').lower() in ('1', 'true', 'yes') if not code: return jsonify({"code": 400, "msg": "缺少货品编号"}) # 1. 产品基础信息 row = query(""" 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 p.product_code=%s """, (code,)) if not row: # BOM 管理列表可能包含仅存在于 bom 表、但产品基础资料尚未建立的历史产品。 # 编辑弹窗需要先读出这类产品的 BOM;保存接口会负责补建产品记录。 if include_inner: bom_product = query(""" SELECT product_code, MAX(product_name) AS product_name FROM bom WHERE product_code=%s GROUP BY product_code """, (code,)) if bom_product: product = { 'product_code': bom_product[0]['product_code'], 'product_name': bom_product[0].get('product_name') or code, 'brand': '', 'product_type': '成品', 'supplier_name_match': '', 'supplier_name': '', 'cost_price': 0, 'remark': '', } else: return jsonify({"code": 404, "msg": "未找到该产品"}) else: return jsonify({"code": 404, "msg": "未找到该产品"}) else: product = row[0] if product.get('created_at'): product['created_at'] = product['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(product['created_at'], 'strftime') else str(product['created_at'])[:19] if product.get('import_date'): product['import_date'] = product['import_date'].strftime('%Y-%m-%d') if hasattr(product['import_date'], 'strftime') else str(product['import_date'])[:10] if product.get('cost_price') is not None: product['cost_price'] = float(product['cost_price']) product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) product['批注'] = product.get('remark') or '' # 计划预测下单信息弹窗:include_inner=auto 时按品牌分类 + 货品名称自动决定是否包含内料。 # 海外品牌商品还要看货品名称是否含新西兰/新加坡/韩国,命中才显示内料(并同步到包材订单管理); # 其余海外品与国内品一致,保持原逻辑不含内料。 if request.args.get('include_inner', '').lower() == 'auto': _brand = (product.get('brand') or '').strip() _brand_rows = 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 [] _is_overseas_brand = bool(_brand_rows and (_brand_rows[0].get('category') or '').strip() == '海外') _product_name = product.get('product_name') or '' include_inner = _is_overseas_brand and any( _key in _product_name for _key in INNER_MATERIAL_NAME_KEYS ) # 2. 成品订单数量 order = query(""" SELECT COALESCE(SUM(order_qty), 0) AS total_order_qty, COALESCE(SUM(suggested_qty), 0) AS total_suggested_qty FROM purchase_order_items WHERE product_code=%s """, (code,)) order_data = order[0] if order else {'total_order_qty': 0, 'total_suggested_qty': 0} # 3. BOM物料清单 latest_inv = query("SELECT MAX(data_date) AS md FROM inventory_detail")[0]['md'] bom_supplier_expr = _bom_material_supplier_sql() bom_sql = f""" SELECT b.material_code, b.material_name, b.quantity AS bom_qty, m.cost_price AS material_cost, {bom_supplier_expr} AS material_supplier, COALESCE(inv.available_qty, 0) AS available_qty, COALESCE(inv.production_in_transit, 0) AS production_in_transit, COALESCE(inv.purchase_in_transit, 0) AS purchase_in_transit FROM bom b LEFT JOIN products m ON b.material_code = m.product_code LEFT JOIN suppliers ms ON m.supplier_code = ms.supplier_code LEFT JOIN ( SELECT product_code, SUM(available_qty) AS available_qty, SUM(production_in_transit) AS production_in_transit, SUM(purchase_in_transit) AS purchase_in_transit FROM inventory_detail WHERE data_date=%s GROUP BY product_code ) inv ON b.material_code = inv.product_code WHERE b.product_code=%s """ if not include_inner: bom_sql += """ AND b.material_name NOT LIKE '%%内料%%' AND b.material_code NOT LIKE '%%-NL%%' """ bom_sql += " ORDER BY b.id" bom_materials = query(bom_sql, (latest_inv, code)) # 补充 BOM 用量计算的订单数量 order_qty = int(order_data['total_order_qty'] or 0) # 从 bom_packaging 表读取下单前/后包材数据(独立于 pending_orders) pkg_rows = query(""" SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark FROM bom_packaging WHERE product_code=%s """, (code,)) pkg_map = {} for pr in pkg_rows: mc = str(pr['material_code'] or '').strip() pkg_map[mc] = { 'pre_pack_qty': int(pr['pre_pack_qty'] or 0), 'post_pack_qty': int(pr['post_pack_qty'] or 0), 'pkg_remark': pr.get('pkg_remark') or '', } for mat in bom_materials: mat['bom_qty'] = float(mat['bom_qty']) if mat['bom_qty'] else 0 mat['material_cost'] = float(mat['material_cost']) if mat['material_cost'] else 0 mat['required_qty'] = order_qty * mat['bom_qty'] mat['total_cost'] = round(mat['required_qty'] * mat['material_cost'], 2) mat['available_qty'] = int(mat['available_qty']) mat['production_in_transit'] = int(mat['production_in_transit']) mat['purchase_in_transit'] = int(mat['purchase_in_transit']) saved = pkg_map.get(str(mat['material_code'] or '').strip(), {}) mat['pre_pack_qty'] = int(saved.get('pre_pack_qty', 0) or 0) mat['post_pack_qty'] = int(saved.get('post_pack_qty', 0) or 0) mat['pkg_remark'] = saved.get('pkg_remark', '') or '' # 4. 销量数据 sales = query(""" SELECT 日期, SUM(货品数量) AS 日销量 FROM daily_outbound WHERE 货品编号=%s GROUP BY 日期 ORDER BY 日期 ASC """, (code,)) sales_data = [] for s in sales: d = s['日期'].strftime('%Y-%m-%d') if hasattr(s['日期'], 'strftime') else str(s['日期'])[:10] sales_data.append({ 'date': d, 'qty': int(s['日销量']) if s['日销量'] else 0 }) # 5. 在途订单(从各订单表查询未完结的订单,增加字段) orders_in_transit = query(""" SELECT '国内' AS 订单类型, 日期 AS 下单时间, 货品编号 AS 编码, 货品名称, 数量 AS 下单数量, COALESCE(未到货数, 数量 - COALESCE(实际到货数量, 0)) AS 未到数量, COALESCE(进度, '') AS 下单进度, COALESCE(预计到货日期, '') AS 预计到货时间, 金蝶入库 AS 入库单号 FROM domestic_orders WHERE 货品编号=%s AND COALESCE(状态, '') != '已完结' UNION ALL SELECT '海外' AS 订单类型, 计划下单时间 AS 下单时间, 产品编码 AS 编码, 货品名称, 数量 AS 下单数量, COALESCE(未到货数, 数量 - COALESCE(实际到货数量, 0)) AS 未到数量, COALESCE(进度, '') AS 下单进度, COALESCE(预计到货日期, '') AS 预计到货时间, '' AS 入库单号 FROM overseas_orders WHERE 产品编码=%s AND COALESCE(状态, '') != '已完结' UNION ALL SELECT '包材' AS 订单类型, 下单时间, 编码, 名称 AS 货品名称, 数量 AS 下单数量, 0 AS 未到数量, COALESCE(进度, '') AS 下单进度, COALESCE(到货日期, '') AS 预计到货时间, '' AS 入库单号 FROM packaging_orders WHERE 编码=%s AND COALESCE(状态, '') != '已完结' ORDER BY 下单时间 DESC """, (code, code, code)) for o in orders_in_transit: if o.get('下单时间') and hasattr(o.get('下单时间'), 'strftime'): o['下单时间'] = o['下单时间'].strftime('%Y-%m-%d') if o.get('预计到货日期') and hasattr(o.get('预计到货日期'), 'strftime'): o['预计到货日期'] = o['预计到货日期'].strftime('%Y-%m-%d') if o.get('未到货数') is None: o['未到货数'] = 0 return jsonify({ "code": 0, "data": { "product": product, "order": { "total_order_qty": order_qty, "total_suggested_qty": int(order_data['total_suggested_qty'] or 0) }, "bom_materials": bom_materials, "sales": sales_data, "orders_in_transit": orders_in_transit } }) @products_bp.route('/api/product/planning_detail') @login_required def product_planning_detail(): """产品详情 + 计划预测全量计算字段(用于弹窗展示)""" code = request.args.get('code', '') if not code: return jsonify({"code": 400, "msg": "缺少货品编号"}) # 产品基础信息 row = query(""" 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 p.product_code=%s """, (code,)) if not row: return jsonify({"code": 404, "msg": "未找到该产品"}) product = row[0] if product.get('created_at'): product['created_at'] = product['created_at'].strftime('%Y-%m-%d') if hasattr(product['created_at'], 'strftime') else str(product['created_at'])[:10] if product.get('cost_price') is not None: product['cost_price'] = float(product['cost_price']) if product.get('lead_time') is None: product['lead_time'] = 0 if product.get('logistics_time') is None: product['logistics_time'] = 0 # 字段映射:后端英文名 → 前端中文名 product['供应商名称'] = product.get('supplier_name', '') product['生产货期'] = product.get('lead_time', 0) product['物流时间'] = product.get('logistics_time', 0) product['创建时间'] = product.get('created_at', '') product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) product['批注'] = product.get('remark') or '' # 计算计划预测字段 sales = compute_product_sales_cached() inv_map = compute_product_inventory_cached() inv15_map = compute_product_inventory_15() transit_map = compute_product_in_transit_cached() sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} row = product pc = row.get('product_code', '') row['库存'] = inv_map.get(pc, 0) row['加工厂库存'] = inv15_map.get(pc, 0) t = transit_map.get(pc, {}) row['生产在途'] = t.get('生产在途', 0) row['采购在途'] = t.get('采购在途', 0) if pc in sales: s = sales[pc] for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: row[k] = s.get(k, 0) row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量']) else: for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: row[k] = 0 row['销售趋势'] = _determine_trend(0, 0, 0) row['产品分级'] = get_product_grade(row.get('30天平均销量', 0) * 30, row.get('product_name', ''), row.get('产品种类', '')) row['是否新品'] = is_new_product(row.get('created_at', '')) row['日销量预测'] = compute_daily_forecast(row, sp_map) # 支撑天数 from services.forecast import get_global_config gc = get_global_config() safety_days = int(gc.get('safety_stock_days', '7')) return_days = int(gc.get('suggested_return_days', '15')) forecast_val = row.get('日销量预测', 0) fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0 inv_val = float(row.get('库存', 0) or 0) inv15_val = float(row.get('加工厂库存', 0) or 0) prod_transit = float(row.get('生产在途', 0) or 0) purch_transit = float(row.get('采购在途', 0) or 0) if fcast == 0: row['库存可支撑天数'] = 999 row['库存工厂可支撑天数'] = 999 row['库存工厂待生产可支撑天数'] = 999 row['加工厂库存支撑天数'] = 999 row['总支撑天数'] = 999 else: row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999 row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999 row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999 row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999 total_inv = prod_transit + inv15_val row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999 lead_h = float(row.get('lead_time', 0) or 0) logis_h = float(row.get('logistics_time', 0) or 0) sup_days = row.get('库存可支撑天数', 999) sup_days_val = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else None if sup_days_val is not None and (lead_h + logis_h) > 0: row['库存健康指数'] = round(min(sup_days_val / (lead_h + logis_h), 999), 1) else: row['库存健康指数'] = 999 created = row.get('created_at', '') days_since_created = 999 try: if created: created_dt = datetime.strptime(created[:10], '%Y-%m-%d') if isinstance(created, str) else created days_since_created = (datetime.now() - created_dt).days except: pass sup_days_val_f = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else 999 if days_since_created < 90: row['风险把控'] = '新品' elif days_since_created > 180 and sup_days_val_f > 240: row['风险把控'] = '需启动清库存' elif days_since_created > 90 and sup_days_val_f > 90: row['风险把控'] = '呆滞品' else: row['风险把控'] = '正常' if fcast == 0 and inv_val > 0: row['建议下单数量'] = 0 else: row['建议下单数量'] = int(fcast * (safety_days + lead_h + logis_h)) row['实际下单数量'] = ((row['建议下单数量'] + 999) // 1000) * 1000 if row['建议下单数量'] > 0 else 0 total_transit = prod_transit + purch_transit if fcast > 0 and total_transit > 0: calc_val = fcast * return_days - inv_val round_up = max(0, int(calc_val * 10 + 0.99999) / 10) row['建议回货数量'] = round_up if round_up <= total_transit else total_transit else: row['建议回货数量'] = 0 # 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。 cloud_sales = compute_product_sales_extended_cached().get('cloud', {}).get(pc, {}) cloud_inventory = compute_cloud_inventory_cached().get(pc, 0) cloud_3 = cloud_sales.get('3天平均销量', 0) cloud_7 = cloud_sales.get('7天平均销量', 0) cloud_14 = cloud_sales.get('14天平均销量', 0) cloud_30 = cloud_sales.get('30天平均销量', 0) cloud_trend = _determine_trend(cloud_3, cloud_7, cloud_14) cloud_forecast_row = { '3天平均销量': cloud_3, '7天平均销量': cloud_7, '14天平均销量': cloud_14, '30天最高销量': cloud_sales.get('30天最高销量', 0), '销售趋势': cloud_trend, } cloud_forecast = compute_daily_forecast(cloud_forecast_row, sp_map) cloud_fcast = float(cloud_forecast) if cloud_forecast not in ('近期无销售', 0, '0') else 0 row['云仓数据'] = { '产品分级': row.get('产品分级', ''), '昨日销量': cloud_sales.get('昨日销量', 0), '销售趋势': cloud_trend, '库存': cloud_inventory, '加工厂库存': row.get('加工厂库存', 0), '生产在途': row.get('生产在途', 0), '采购在途': row.get('采购在途', 0), '日销量预测': cloud_forecast, '3天平均销量': cloud_3, '7天平均销量': cloud_7, '14天平均销量': cloud_14, '库存可支撑天数': round(float(cloud_inventory or 0) / cloud_fcast, 1) if cloud_fcast else 999, '加工厂库存支撑天数': round(inv15_val / cloud_fcast, 1) if cloud_fcast else 999, '总支撑天数': round((prod_transit + inv15_val) / cloud_fcast, 1) if cloud_fcast else 999, '30天平均销量': cloud_30, '极值判断': '-', } external_sales = compute_product_sales_extended_cached().get('external', {}).get(pc, {}) external_inventory = compute_external_inventory_cached().get(pc, 0) external_3 = external_sales.get('3天平均销量', 0) external_7 = external_sales.get('7天平均销量', 0) external_14 = external_sales.get('14天平均销量', 0) external_30 = external_sales.get('30天平均销量', 0) external_trend = _determine_trend(external_3, external_7, external_14) external_forecast_row = { '3天平均销量': external_3, '7天平均销量': external_7, '14天平均销量': external_14, '30天最高销量': external_sales.get('30天最高销量', 0), '销售趋势': external_trend, } external_forecast = compute_daily_forecast(external_forecast_row, sp_map) external_fcast = float(external_forecast) if external_forecast not in ('近期无销售', 0, '0') else 0 row['外仓数据'] = { '产品分级': row.get('产品分级', ''), '昨日销量': external_sales.get('昨日销量', 0), '销售趋势': external_trend, '库存': external_inventory, '加工厂库存': row.get('加工厂库存', 0), '生产在途': row.get('生产在途', 0), '采购在途': row.get('采购在途', 0), '日销量预测': external_forecast, '3天平均销量': external_3, '7天平均销量': external_7, '14天平均销量': external_14, '库存可支撑天数': round(float(external_inventory or 0) / external_fcast, 1) if external_fcast else 999, '加工厂库存支撑天数': round(inv15_val / external_fcast, 1) if external_fcast else 999, '总支撑天数': round((prod_transit + inv15_val) / external_fcast, 1) if external_fcast else 999, '30天平均销量': external_30, '极值判断': '-', } return jsonify({"code": 0, "data": row}) @products_bp.route('/api/bom/list') @login_required def bom_list(): """获取BOM列表""" page = int(request.args.get('page', 1)) size = int(request.args.get('size', 20)) offset = (page - 1) * size search = request.args.get('search', '') maint_filter = request.args.get('maint_filter', '') supplier_filter = request.args.get('supplier', '') where_parts = [] params = [] if search: where_parts.append('(b.product_code LIKE %s OR b.product_name LIKE %s OR b.material_code LIKE %s OR b.material_name LIKE %s)') params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%']) having_clause = '' if maint_filter == '有BOM': having_clause = 'HAVING SUM(CASE WHEN b.material_code IS NOT NULL AND b.material_code != \'\' THEN 1 ELSE 0 END) > 0' elif maint_filter == '无BOM': having_clause = 'HAVING SUM(CASE WHEN b.material_code IS NOT NULL AND b.material_code != \'\' THEN 1 ELSE 0 END) = 0' # 供应商筛选:优先使用BOM导入的供应商,未填写时回退到物料基础资料 supplier_join = '' supplier_where = '' if supplier_filter: supplier_join = ''' LEFT JOIN products m ON (b.material_code = m.product_code) LEFT JOIN suppliers ms ON (m.supplier_code = ms.supplier_code) ''' supplier_where = f"AND {_bom_material_supplier_sql()} LIKE %s" params.append(f'%{supplier_filter}%') where_sql = ('WHERE ' + ' AND '.join(where_parts) + supplier_where) if where_parts else ('WHERE 1=1 ' + supplier_where) if supplier_filter else '' # 总数查询 total = query(f""" SELECT COUNT(*) AS cnt FROM ( SELECT b.product_code FROM bom b {supplier_join} {where_sql} GROUP BY b.product_code {having_clause} ) AS t """, params)[0]['cnt'] product_codes = query(f""" SELECT b.product_code FROM bom b {supplier_join} {where_sql} GROUP BY b.product_code {having_clause} ORDER BY MAX(b.id) DESC LIMIT %s OFFSET %s """, params + [size, offset]) if not product_codes: return jsonify({"code": 0, "data": {"total": 0, "rows": [], "product_count": 0}}) codes = [p['product_code'] for p in product_codes] placeholders = ','.join(['%s'] * len(codes)) latest_inv = query("SELECT MAX(data_date) AS md FROM inventory_detail")[0]['md'] # 成品库存映射(按 product_code) prod_inv = {} prod_inv_rows = query(f""" SELECT product_code, SUM(available_qty) AS available_qty, SUM(production_in_transit) AS production_in_transit, SUM(purchase_in_transit) AS purchase_in_transit FROM inventory_detail WHERE data_date='{latest_inv}' AND product_code IN ({placeholders}) GROUP BY product_code """, codes) for r in prod_inv_rows: prod_inv[r['product_code']] = r bom_supplier_expr = _bom_material_supplier_sql() sql = f""" SELECT b.*, p.cost_price AS product_cost_price, p.supplier_name_match AS product_supplier, {bom_supplier_expr} AS material_supplier, COALESCE(m.cost_price, 0) AS material_cost_price, inv.available_qty AS mat_available_qty, inv.production_in_transit AS mat_prod_transit, inv.purchase_in_transit AS mat_purch_transit FROM bom b LEFT JOIN products p ON b.product_code = p.product_code LEFT JOIN products m ON b.material_code = m.product_code LEFT JOIN suppliers ms ON m.supplier_code = ms.supplier_code LEFT JOIN ( SELECT product_code, SUM(available_qty) AS available_qty, SUM(production_in_transit) AS production_in_transit, SUM(purchase_in_transit) AS purchase_in_transit FROM inventory_detail WHERE data_date='{latest_inv}' GROUP BY product_code ) inv ON b.material_code = inv.product_code WHERE b.product_code IN ({placeholders}) ORDER BY b.id DESC """ rows = query(sql, codes) # 为每行补充成品库存 for r in rows: pinv = prod_inv.get(r['product_code'], {}) r['product_available_qty'] = int(pinv.get('available_qty') or 0) r['product_production_in_transit'] = int(pinv.get('production_in_transit') or 0) r['product_purchase_in_transit'] = int(pinv.get('purchase_in_transit') or 0) return jsonify({"code": 0, "data": {"total": total, "rows": rows, "product_count": total}}) # ─── API: BOM供应商列表 ─── @products_bp.route('/api/bom/suppliers') @login_required def bom_suppliers(): """获取BOM中用到的所有物料供应商名称""" bom_supplier_expr = _bom_material_supplier_sql() rows = query(f""" SELECT DISTINCT {bom_supplier_expr} AS supplier_name FROM bom b LEFT JOIN products m ON b.material_code = m.product_code LEFT JOIN suppliers ms ON m.supplier_code = ms.supplier_code WHERE {bom_supplier_expr} <> '' ORDER BY supplier_name """) suppliers = [r['supplier_name'] for r in rows] return jsonify({"code": 0, "data": suppliers}) # ─── API: 产品新增 ─── @products_bp.route('/api/product/create', methods=['POST']) @login_required def product_create(): """新增产品""" if not operation_required('products', 'create') and not operation_required('products', 'add'): return jsonify({"code": 403, "msg": "没有新增产品权限"}) data = request.json or {} product_code = data.get('product_code', '').strip() product_name = data.get('product_name', '').strip() if not product_code or not product_name: return jsonify({"code": 400, "msg": "货品编号和货品名称不能为空"}) existing = query("SELECT id FROM products WHERE product_code=%s", (product_code,)) if existing: return jsonify({"code": 400, "msg": f"货品编号 {product_code} 已存在"}) try: query(""" INSERT INTO products (product_code, product_name, brand, product_type, lead_time, logistics_time, supplier_code, supplier_name_match, procurement_follower, procurement_planner, operator, platform, developer, created_at, import_date, custom_field1, custom_field2, custom_field3, custom_field4, custom_field5) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( product_code, product_name, data.get('brand', ''), data.get('product_type', '成品'), int(data.get('lead_time', 0)), min(int(data.get('logistics_time', 0)), 999), data.get('supplier_code', ''), data.get('supplier_name', ''), data.get('procurement_follower', ''), data.get('procurement_planner', ''), data.get('operator', ''), data.get('platform', ''), data.get('developer', ''), data.get('created_at', '').replace('T', ' ') if data.get('created_at') else None, datetime.now(), data.get('custom_field1', ''), data.get('custom_field2', ''), data.get('custom_field3', ''), data.get('custom_field4', ''), data.get('custom_field5', '') )) add_operation_log('产品创建', f'新建产品 {product_code} - {product_name}') return jsonify({"code": 0, "msg": "创建成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) # ─── API: 产品导出(CSV)─── @products_bp.route('/api/products/export') @login_required def products_export(): """导出产品数据为 CSV(支持选字段)""" product_type = request.args.get('product_type', '') fields_param = request.args.get('fields', '') from flask import Response from urllib.parse import quote import csv, io where = [] params = [] if product_type: if product_type == '非成品': where.append("p.product_type != '成品'") elif product_type == '非包材': where.append("p.product_type NOT IN ('包材','其他')") elif product_type == '非成品专用': where.append("p.product_type IN ('包材','其他')") else: where.append("p.product_type=%s") params.append(product_type) where_clause = " AND ".join(where) if where else "1=1" rows = query(f""" SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, p.cost_price, p.lead_time, p.logistics_time, p.min_order_qty, p.supplier_code, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name, p.procurement_follower, p.procurement_planner, p.operator, p.developer, p.platform, p.category1, p.category2, p.category3, p.created_at, p.import_date FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} ORDER BY p.id DESC """, params) # 字段映射:前端字段名 → 显示名 FIELD_MAP = [ ('product_code', '货品编号'), ('product_name', '货品名称'), ('product_type', '货品类型'), ('brand', '品牌'), ('supplier_code', '供应商编码'), ('supplier_name', '供应商名称'), ('lead_time', '货期(天)'), ('logistics_time', '物流时间(天)'), ('procurement_follower', '采购跟单'), ('procurement_planner', '采购计划'), ('platform', '平台'), ('operator', '运营'), ('developer', '开发专员'), ('created_at', '创建时间'), ('import_date', '导入日期'), ] # 解析选中的字段 if fields_param: selected_fields = [f.strip() for f in fields_param.split(',') if f.strip()] else: selected_fields = [f[0] for f in FIELD_MAP] # 按选中顺序过滤 headers = [] getters = [] for f, label in FIELD_MAP: if f in selected_fields: headers.append(label) getters.append(f) if not headers: return jsonify({"code": 400, "msg": "未选择导出字段"}) output = io.StringIO() writer = csv.writer(output) writer.writerow(headers) for r in rows: row_vals = [] for f in getters: val = r.get(f, '') if f == 'created_at' and val: val = val.strftime('%Y-%m-%d %H:%M:%S') if hasattr(val, 'strftime') else str(val)[:19] elif f == 'import_date' and val: val = val.strftime('%Y-%m-%d') if hasattr(val, 'strftime') else str(val)[:10] elif val is None: val = '' row_vals.append(val) writer.writerow(row_vals) name_part = '全部' if product_type == '成品': name_part = '成品' elif product_type in ('非包材',): name_part = '成品' elif product_type: name_part = '非成品' filename = f"{name_part}基础表_{datetime.now().strftime('%Y%m%d')}.csv" encoded = quote(filename) return Response( output.getvalue(), mimetype='text/csv; charset=utf-8-sig', headers={'Content-Disposition': f"attachment; filename*=UTF-8''{encoded}"} ) # ─── API: 产品导入模板下载 ─── @products_bp.route('/api/products/template') @login_required def products_template(): """下载导入模板 CSV(与当前字段一致) ?type=包材 时生成包材导入模板(含绑定成品编码、用量) """ from flask import Response, request from urllib.parse import quote import csv, io output = io.StringIO() writer = csv.writer(output) is_packaging = request.args.get('type', '') == '包材' if is_packaging: writer.writerow(['绑定成品编码', '货品编号', '货品名称', '用量', '产品类型', '成本价', '生产货期', '物流时间', '最低起订量', '供应商编号', '供应商名称']) writer.writerow(['DEMO-FG-001', 'BZ-DEMO001', '示例包材名称', '2', '包材', '5.00', '5', '2', '500', 'SUP001', '示例供应商']) filename = '包材导入模板.csv' else: writer.writerow(['货品编号', '货品名称', '品牌', '产品类型', '成本价', '生产货期', '物流时间', '最低起订量', '供应商编号', '供应商名称', '采购跟单', '采购计划', '运营专员', '开发专员', '归属平台', '创建时间']) writer.writerow(['DEMO001', '示例产品名称', '品牌A', '成品', '15.50', '7', '3', '100', 'SUP001', '示例供应商', '张三', '李四', '王五', '赵六', '天猫', '2026-07-15']) filename = '产品导入模板.csv' encoded = quote(filename) return Response( output.getvalue(), mimetype='text/csv; charset=utf-8-sig', headers={'Content-Disposition': f"attachment; filename*=UTF-8''{encoded}"} ) # ─── API: 产品导入(CSV上传)─── @products_bp.route('/api/products/import', methods=['POST']) @login_required def products_import(): """通过 CSV 导入产品""" if not operation_required('products', 'import'): return jsonify({"code": 403, "msg": "无操作权限"}) file = request.files.get('file') if not file: return jsonify({"code": 400, "msg": "请选择文件"}) import csv, io raw = file.read() # 尝试多种编码,兼容 Excel 导出的 GBK 等格式 for enc in ('utf-8-sig', 'gbk', 'gb2312', 'utf-8'): try: content = raw.decode(enc) break except (UnicodeDecodeError, LookupError): continue else: return jsonify({"code": 400, "msg": "无法识别文件编码,请确认 CSV 文件为 UTF-8 或 GBK 格式"}) reader = csv.DictReader(io.StringIO(content)) # 判断是否为包材导入(含 BOM 绑定列) has_bom_bind = '绑定成品编码' in (reader.fieldnames or []) field_map = { '货品编号': 'product_code', '货品名称': 'product_name', '品牌': 'brand', '产品类型': 'product_type', '成本价': 'cost_price', '生产货期': 'lead_time', '物流时间': 'logistics_time', '最低起订量': 'min_order_qty', '供应商编号': 'supplier_code', '供应商名称': 'supplier_name', '采购跟单': 'procurement_follower', '采购计划': 'procurement_planner', '运营专员': 'operator', '开发专员': 'developer', '归属平台': 'platform' } imported = 0 updated = 0 bom_count = 0 errors = [] # 已存在的货品编号按文件内容覆盖更新;空白单元格不改动原值(与供应商导入口径一致) def _as_text(v): return v def _as_float(v): try: return float(v) except (TypeError, ValueError): return 0 def _as_int(v): try: return int(float(v)) except (TypeError, ValueError): return 0 update_field_map = { '货品名称': ('product_name', _as_text), '品牌': ('brand', _as_text), '产品类型': ('product_type', _as_text), '成本价': ('cost_price', _as_float), '生产货期': ('lead_time', _as_int), '物流时间': ('logistics_time', _as_int), '最低起订量': ('min_order_qty', _as_int), '供应商编号': ('supplier_code', _as_text), '供应商名称': ('supplier_name_match', _as_text), '采购跟单': ('procurement_follower', _as_text), '采购计划': ('procurement_planner', _as_text), '运营专员': ('operator', _as_text), '开发专员': ('developer', _as_text), '归属平台': ('platform', _as_text), } try: for i, row in enumerate(reader, 2): pc = row.get('货品编号', '').strip() pn = row.get('货品名称', '').strip() if not pc: errors.append(f"第{i}行:货品编号为空,已跳过") continue existing = query("SELECT id FROM products WHERE product_code=%s", (pc,)) if existing: set_parts = [] set_params = [] for header, (col, conv) in update_field_map.items(): if header not in (reader.fieldnames or []): continue raw_val = (row.get(header) or '').strip() if raw_val == '': continue set_parts.append(f"{col}=%s") set_params.append(conv(raw_val)) if not set_parts: errors.append(f"第{i}行:货品编号 {pc} 已存在,但文件内无任何可更新内容,已跳过") continue set_params.append(pc) query(f"UPDATE products SET {', '.join(set_parts)} WHERE product_code=%s", set_params) updated += 1 else: if not pn: errors.append(f"第{i}行:货品编号 {pc} 不存在且未填写货品名称,无法新增,已跳过") continue query(""" INSERT INTO products (product_code, product_name, brand, product_type, cost_price, lead_time, logistics_time, min_order_qty, supplier_code, supplier_name_match, procurement_follower, procurement_planner, operator, platform, developer, created_at, import_date) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURDATE()) """, ( pc, pn, row.get('品牌', '').strip(), row.get('产品类型', '包材' if has_bom_bind else '成品').strip(), (lambda v: float(v) if v else 0)(row.get('成本价', '') or 0), (lambda v: int(float(v)) if v else 0)(row.get('生产货期', '') or 0), (lambda v: int(float(v)) if v else 0)(row.get('物流时间', '') or 0), (lambda v: int(float(v)) if v else 0)(row.get('最低起订量', '') or 0), row.get('供应商编号', '').strip(), row.get('供应商名称', '').strip(), row.get('采购跟单', '').strip(), row.get('采购计划', '').strip(), row.get('运营专员', '').strip(), row.get('归属平台', '').strip(), row.get('开发专员', '').strip(), row.get('创建时间', '').strip() or None )) imported += 1 # BOM 绑定:如果导入的是包材且有绑定成品编码 if has_bom_bind: bind_code = row.get('绑定成品编码', '').strip() qty = row.get('用量', '').strip() if bind_code: # 查成品是否存在(不存在也跳过,仅提示) prod_row = query("SELECT id, product_name FROM products WHERE product_code=%s", (bind_code,)) if not prod_row: errors.append(f"第{i}行:绑定的成品编码 {bind_code} 不存在,跳过 BOM 绑定") else: qty_val = float(qty) if qty else 1 # 查是否已有该 BOM 记录,有则更新,无则插入 existing_bom = query( "SELECT id FROM bom WHERE product_code=%s AND material_code=%s", (bind_code, pc)) # 获取包材成本价 mat_cost = float(row.get('成本价', 0)) if row.get('成本价') else 0 if existing_bom: query("UPDATE bom SET quantity=%s, material_cost_price=%s WHERE id=%s", (qty_val, mat_cost, existing_bom[0]['id'])) else: # 更新场景下文件可能未提供货品名称,回退到库中已有名称 mat_name = pn or (query("SELECT product_name FROM products WHERE product_code=%s", (pc,)) or [{}])[0].get('product_name', '') or '' query( "INSERT INTO bom (product_code, product_name, material_code, material_name, quantity, material_cost_price) VALUES (%s,%s,%s,%s,%s,%s)", (bind_code, prod_row[0]['product_name'], pc, mat_name, qty_val, mat_cost)) bom_count += 1 log_detail = f'CSV批量导入产品:新增{imported}条,更新{updated}条' if bom_count: log_detail += f',自动创建/更新BOM绑定{bom_count}条' add_operation_log('产品导入', log_detail) msg = f"导入完成:新增 {imported} 条,更新 {updated} 条" if bom_count: msg += f",自动绑定BOM {bom_count}条" if errors: msg += "。以下行已跳过:" + "; ".join(errors[:10]) if len(errors) > 10: msg += f"...(共{len(errors)}个错误)" return jsonify({"code": 0, "msg": msg}) except Exception as e: import traceback traceback.print_exc() return jsonify({"code": 500, "msg": f"导入失败:{str(e)}"}) # ─── API: BOM导入模板下载 ─── @products_bp.route('/api/bom/import_template') @login_required def bom_import_template(): """下载BOM导入模板CSV""" from flask import Response from urllib.parse import quote template_csv = '货品编号,货品名称,物料编号,物料名称,用量,供应商,成本价\n' encoded_filename = quote('物料清单导入模板.csv') return Response( template_csv, mimetype='text/csv', headers={'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"} ) @products_bp.route('/api/bom/import', methods=['POST']) @login_required def bom_import(): """导入BOM数据(按产品覆盖,非全量替换)""" data = request.json items = data.get('items', []) if not items: return jsonify({"code": 400, "msg": "数据为空"}) try: # 收集要更新的产品编码,只删除这些产品的旧BOM数据 product_codes = set(item.get('product_code', '') for item in items if item.get('product_code')) if product_codes: placeholders = ','.join(['%s'] * len(product_codes)) query(f"DELETE FROM bom WHERE product_code IN ({placeholders})", tuple(product_codes)) for item in items: query( "INSERT INTO bom (product_code, product_name, material_code, material_name, quantity, supplier, material_cost_price) VALUES (%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE product_name=VALUES(product_name), material_name=VALUES(material_name), quantity=VALUES(quantity), supplier=VALUES(supplier), material_cost_price=VALUES(material_cost_price)", (item.get('product_code', ''), item.get('product_name', ''), item.get('material_code', ''), item.get('material_name', ''), float(item.get('quantity', 1)), _normalize_bom_supplier(item.get('supplier', '')), float(item.get('cost_price', 0))) ) add_operation_log('BOM导入', f'导入BOM,共{len(items)}条') return jsonify({"code": 0, "msg": f"导入成功,共{len(items)}条"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) # ─── API: 供应商管理 ─── @products_bp.route('/api/suppliers/list') @login_required def suppliers_list(): page = int(request.args.get('page', 1)) size = int(request.args.get('size', 50)) offset = (page - 1) * size search = request.args.get('search', '') code = request.args.get('code', '') name = request.args.get('name', '') where = [] params = [] if search: where.append("(supplier_name LIKE %s OR short_name LIKE %s OR contract_short_name LIKE %s OR supplier_code LIKE %s OR production_type LIKE %s)") params.extend([f"%{search}%", f"%{search}%", f"%{search}%", f"%{search}%", f"%{search}%"]) if code: where.append("supplier_code LIKE %s") params.append(f"%{code}%") if name: where.append("supplier_name LIKE %s") params.append(f"%{name}%") where_clause = " AND ".join(where) if where else "1=1" total = query(f"SELECT COUNT(*) AS cnt FROM suppliers WHERE {where_clause}", params)[0]['cnt'] rows = query(f"SELECT * FROM suppliers WHERE {where_clause} ORDER BY supplier_code ASC LIMIT {size} OFFSET {offset}", params) # 日期转字符串,避免Flask序列化为 "Wed, 18 Mar 2026 00:00:00 GMT" 格式 for r in rows: if r.get('review_date') and hasattr(r['review_date'], 'isoformat'): r['review_date'] = r['review_date'].isoformat() return jsonify({"code": 0, "data": {"total": total, "page": page, "size": size, "rows": rows}}) @products_bp.route('/api/supplier/update', methods=['POST']) @login_required def supplier_update(): if not operation_required('suppliers', 'edit'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json code = data.get('supplier_code', '') fields = data.get('fields', {}) if not code or not fields: return jsonify({"code": 400, "msg": "参数不完整"}) allowed = ['supplier_name', 'production_type', 'short_name', 'contract_short_name', 'supplier_group', 'procurement_follower', 'procurement_planner', 'reviewer', 'review_date', 'data_status', 'disabled_status'] set_parts = [] params = [] for field, value in fields.items(): if field in allowed: set_parts.append(f"{field}=%s") params.append(value if value != '' else None) if not set_parts: return jsonify({"code": 400, "msg": "没有可更新的字段"}) set_clause = ", ".join(set_parts) params.append(code) try: query(f"UPDATE suppliers SET {set_clause} WHERE supplier_code=%s", params) return jsonify({"code": 0, "msg": "更新成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/brands/all') @login_required def brands_all(): """品牌下拉/模糊查询用:品牌信息管理维护的品牌 + 产品表中已存在的品牌""" rows = query("""SELECT TRIM(brand) AS brand FROM products WHERE deleted_at IS NULL AND brand IS NOT NULL AND TRIM(brand) <> '' GROUP BY TRIM(brand)""") saved = query("SELECT brand FROM brand_information") names = {r['brand'] for r in rows} | {(r.get('brand') or '').strip() for r in saved} names.discard('') return jsonify({"code": 0, "data": sorted(names, key=lambda x: x.casefold())}) @products_bp.route('/api/brands/overseas') @login_required def brands_overseas(): """新增产品弹窗用:海外品品牌清单(来源:品牌管理的品牌分类)""" rows = query("SELECT brand FROM brand_information WHERE category='海外' ORDER BY brand") return jsonify({"code": 0, "data": [r['brand'] for r in rows]}) @products_bp.route('/api/supplier/bound_fields') @login_required def supplier_bound_fields(): """新增产品弹窗用:按供应商解析跟单小组与采购计划。 优先取供应商管理里该供应商绑定的值;管理里未维护时回退到该供应商已有产品的对应值。 """ code = (request.args.get('supplier_code') or '').strip() name = (request.args.get('supplier_name') or '').strip() follower = '' planner = '' follower_source = '' planner_source = '' supplier = None if code: rows = query("SELECT supplier_name, procurement_follower, procurement_planner FROM suppliers WHERE supplier_code=%s", (code,)) supplier = rows[0] if rows else None if not supplier and name: rows = query("SELECT supplier_name, procurement_follower, procurement_planner FROM suppliers WHERE supplier_name=%s", (name,)) supplier = rows[0] if rows else None if not supplier and name: rows = query("SELECT supplier_name, procurement_follower, procurement_planner FROM suppliers WHERE supplier_name LIKE %s ORDER BY id LIMIT 1", (f"%{name}%",)) supplier = rows[0] if rows else None if supplier: bound_follower = (supplier.get('procurement_follower') or '').strip() bound_planner = (supplier.get('procurement_planner') or '').strip() if bound_follower: follower = bound_follower follower_source = 'supplier' if bound_planner: planner = bound_planner planner_source = 'supplier' # 回退查询要用供应商全称,避免传入片段名时匹配不到产品 name = (supplier.get('supplier_name') or '').strip() or name if name and (not follower or not planner): rows = query(""" SELECT procurement_follower, procurement_planner, COUNT(*) AS cnt FROM products WHERE supplier_name_match=%s AND ((procurement_follower IS NOT NULL AND procurement_follower<>'') OR (procurement_planner IS NOT NULL AND procurement_planner<>'')) GROUP BY procurement_follower, procurement_planner ORDER BY cnt DESC LIMIT 50 """, (name,)) # 分别取出现次数最多的非空值 follower_counts = {} planner_counts = {} for r in rows: f = (r.get('procurement_follower') or '').strip() p = (r.get('procurement_planner') or '').strip() if f: follower_counts[f] = follower_counts.get(f, 0) + int(r.get('cnt') or 0) if p: planner_counts[p] = planner_counts.get(p, 0) + int(r.get('cnt') or 0) if not follower and follower_counts: follower = max(follower_counts, key=follower_counts.get) follower_source = 'product_fallback' if not planner and planner_counts: planner = max(planner_counts, key=planner_counts.get) planner_source = 'product_fallback' return jsonify({"code": 0, "data": { "follower": follower, "follower_source": follower_source, "planner": planner, "planner_source": planner_source, "supplier_name": name, }}) @products_bp.route('/api/supplier/follower_lookup') @login_required def supplier_follower_lookup(): """兼容旧调用:仅返回跟单小组""" payload = supplier_bound_fields().get_json()['data'] return jsonify({"code": 0, "data": { "follower": payload['follower'], "source": payload['follower_source'], "supplier_name": payload['supplier_name'], }}) @products_bp.route('/api/supplier/delete', methods=['POST']) @login_required def supplier_delete(): """删除供应商""" if not operation_required('suppliers', 'delete'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json or {} code = data.get('supplier_code', '').strip() if not code: return jsonify({"code": 400, "msg": "缺少供应商编码"}) try: query("DELETE FROM suppliers WHERE supplier_code=%s", (code,)) return jsonify({"code": 0, "msg": f"供应商 [{code}] 已删除"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/suppliers/export') @login_required def suppliers_export(): """导出供应商数据为xlsx""" from flask import send_file import openpyxl from openpyxl.styles import Font, Alignment, Border, Side, PatternFill import tempfile search = request.args.get('search', '') code = request.args.get('code', '') name = request.args.get('name', '') where = [] params = [] if search: where.append("(supplier_name LIKE %s OR supplier_code LIKE %s OR production_type LIKE %s)") params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) if code: where.append("supplier_code LIKE %s") params.append(f"%{code}%") if name: where.append("supplier_name LIKE %s") params.append(f"%{name}%") where_clause = " AND ".join(where) if where else "1=1" rows = query(f"SELECT * FROM suppliers WHERE {where_clause} ORDER BY supplier_code ASC", params) wb = openpyxl.Workbook() ws = wb.active ws.title = "供应商列表" # 样式 header_font = Font(name='微软雅黑', bold=True, size=11, color='FFFFFF') header_fill = PatternFill(start_color='1890FF', end_color='1890FF', fill_type='solid') header_align = Alignment(horizontal='center', vertical='center') thin_border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) cell_align = Alignment(horizontal='center', vertical='center') headers = ['序号', '供应商编码', '供应商名称', '做货类型', '简称', '合同简称', '供应商分组', '审核人', '审核日期', '数据状态', '禁用状态'] for col_idx, h in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=h) cell.font = header_font cell.fill = header_fill cell.alignment = header_align cell.border = thin_border for i, r in enumerate(rows, 1): row_data = [ i, r.get('supplier_code', ''), r.get('supplier_name', ''), r.get('production_type', '') or '-', r.get('short_name', '') or '-', r.get('contract_short_name', '') or '-', r.get('supplier_group', '') or '-', r.get('reviewer', '') or '-', (r.get('review_date') or '-') if r.get('review_date') else '-', r.get('data_status', '') or '-', r.get('disabled_status', '') or '-', ] for col_idx, val in enumerate(row_data, 1): cell = ws.cell(row=i + 1, column=col_idx, value=str(val)) cell.alignment = cell_align cell.border = thin_border # 调整列宽 col_widths = [6, 16, 36, 20, 12, 12, 16, 10, 14, 12, 12] for col_idx, w in enumerate(col_widths, 1): ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = w # 冻结首行 ws.freeze_panes = 'A2' tmp = tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) wb.save(tmp.name) tmp.close() return send_file( tmp.name, as_attachment=True, download_name='供应商列表.xlsx', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) @products_bp.route('/api/suppliers/import_template') @login_required def suppliers_import_template(): """下载供应商导入模板""" from flask import send_file import openpyxl import tempfile wb = openpyxl.Workbook() ws = wb.active ws.title = "供应商导入模板" headers = ['供应商名称', '简称', '合同简称', '跟单小组', '采购计划'] for col_idx, h in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=h) cell.font = openpyxl.styles.Font(name='微软雅黑', bold=True, size=11, color='FFFFFF') cell.fill = openpyxl.styles.PatternFill(start_color='1890FF', end_color='1890FF', fill_type='solid') cell.alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center') cell.border = openpyxl.styles.Border( left=openpyxl.styles.Side(style='thin'), right=openpyxl.styles.Side(style='thin'), top=openpyxl.styles.Side(style='thin'), bottom=openpyxl.styles.Side(style='thin')) # 添加示例行 examples = [ ['广州市靓鑫精细化工有限公司', '靓鑫', 'LX', '跟单一组', '陈瑜'], ['广州市巧能塑料制品有限公司', '巧能', 'QN', '跟单二组', '李艳艳'], ] for i, ex in enumerate(examples, 2): for col_idx, val in enumerate(ex, 1): cell = ws.cell(row=i, column=col_idx, value=val) cell.alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center') cell.border = openpyxl.styles.Border( left=openpyxl.styles.Side(style='thin'), right=openpyxl.styles.Side(style='thin'), top=openpyxl.styles.Side(style='thin'), bottom=openpyxl.styles.Side(style='thin')) ws.column_dimensions['A'].width = 36 ws.column_dimensions['B'].width = 14 ws.column_dimensions['C'].width = 14 ws.column_dimensions['D'].width = 16 ws.column_dimensions['E'].width = 16 ws.freeze_panes = 'A2' tmp = tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) wb.save(tmp.name) tmp.close() return send_file( tmp.name, as_attachment=True, download_name='供应商导入模板.xlsx', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) @products_bp.route('/api/suppliers/import', methods=['POST']) @login_required def suppliers_import(): """导入xlsx更新供应商简称和合同简称""" import openpyxl if 'file' not in request.files: return jsonify({"code": 400, "msg": "未上传文件"}) f = request.files['file'] if not f.filename: return jsonify({"code": 400, "msg": "文件名为空"}) try: wb = openpyxl.load_workbook(f) ws = wb.active except Exception as e: return jsonify({"code": 400, "msg": f"无法读取Excel文件:{str(e)}"}) if ws.max_row < 2: return jsonify({"code": 400, "msg": "Excel文件中没有数据"}) # 读取表头确定列位置 headers = {} for col_idx in range(1, ws.max_column + 1): val = str(ws.cell(row=1, column=col_idx).value or '').strip() headers[val] = col_idx name_col = headers.get('供应商名称') or headers.get('供应商') short_col = headers.get('简称') or headers.get('供应商简称') contract_col = headers.get('合同简称') follower_col = headers.get('跟单小组') or headers.get('采购跟单') planner_col = headers.get('采购计划') if not name_col: return jsonify({"code": 400, "msg": "未找到供应商名称列,表头需包含'供应商名称'"}) if not short_col and not contract_col and not follower_col and not planner_col: return jsonify({"code": 400, "msg": "未找到简称、合同简称、跟单小组或采购计划列"}) # 全量加载供应商 all_suppliers = query("SELECT id, supplier_code, supplier_name, short_name, contract_short_name FROM suppliers") name_map = {} for s in all_suppliers: key = (s['supplier_name'] or '').strip() name_map[key] = s # 预校验:合同简称必须全大写 if contract_col: for row_idx in range(2, ws.max_row + 1): v = ws.cell(row=row_idx, column=contract_col).value if v is not None: val = str(v).strip() if val and val != val.upper(): supplier_name = str(ws.cell(row=row_idx, column=name_col).value or '').strip() return jsonify({"code": 400, "msg": f"合同简称必须全大写(行{row_idx},供应商「{supplier_name}」的合同简称为「{val}」)"}) updated = [] not_found = [] skipped = [] for row_idx in range(2, ws.max_row + 1): supplier_name = str(ws.cell(row=row_idx, column=name_col).value or '').strip() if not supplier_name: continue short_val = None contract_val = None follower_val = None planner_val = None if short_col: v = ws.cell(row=row_idx, column=short_col).value short_val = str(v).strip() if v is not None else None if contract_col: v = ws.cell(row=row_idx, column=contract_col).value contract_val = str(v).strip() if v is not None else None if follower_col: v = ws.cell(row=row_idx, column=follower_col).value follower_val = str(v).strip() if v is not None else None if planner_col: v = ws.cell(row=row_idx, column=planner_col).value planner_val = str(v).strip() if v is not None else None if not short_val and not contract_val and not follower_val and not planner_val: skipped.append(supplier_name) continue match = name_map.get(supplier_name) if not match: not_found.append(supplier_name) continue # 更新 update_fields = [] update_params = [] if short_val: update_fields.append("short_name=%s") update_params.append(short_val) if contract_val: update_fields.append("contract_short_name=%s") update_params.append(contract_val) if follower_val: update_fields.append("procurement_follower=%s") update_params.append(follower_val) if planner_val: update_fields.append("procurement_planner=%s") update_params.append(planner_val) if update_fields: update_params.append(match['id']) query(f"UPDATE suppliers SET {', '.join(update_fields)} WHERE id=%s", update_params) updated.append(supplier_name) return jsonify({"code": 0, "data": { "updated": len(updated), "not_found": len(not_found), "skipped": len(skipped), "not_found_list": not_found[:20], "updated_list": updated, "message": f"成功更新 {len(updated)} 条" + (f",{len(not_found)} 条未匹配" if not_found else "") + (f",{len(skipped)} 条跳过(无数据)" if skipped else "") }}) @products_bp.route('/api/supplier/create', methods=['POST']) @login_required def supplier_create(): """新增供应商""" if not operation_required('suppliers', 'create'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json or {} code = (data.get('supplier_code') or '').strip() name = (data.get('supplier_name') or '').strip() if not code or not name: return jsonify({"code": 400, "msg": "供应商编码和名称不能为空"}) exist = query("SELECT COUNT(*) AS c FROM suppliers WHERE supplier_code=%s", (code,)) if exist and exist[0]['c'] > 0: return jsonify({"code": 400, "msg": f"供应商编码 [{code}] 已存在"}) try: query("""INSERT INTO suppliers (supplier_code, supplier_name, production_type, short_name, contract_short_name, supplier_group, procurement_follower, procurement_planner, reviewer, review_date, data_status, disabled_status) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (code, name, data.get('production_type', '') or None, data.get('short_name', '') or None, data.get('contract_short_name', '') or None, data.get('supplier_group', '') or None, data.get('procurement_follower', '') or None, data.get('procurement_planner', '') or None, data.get('reviewer', '') or None, data.get('review_date', '') or None, data.get('data_status', '') or None, data.get('disabled_status', '') or None)) try: add_operation_log('新增供应商', f'新增供应商 {code} - {name}') except: pass return jsonify({"code": 0, "msg": "新增成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product/delete', methods=['POST']) @login_required def product_delete(): """软删除产品(移入回收站)""" if not operation_required('products', 'delete'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json code = data.get('product_code', '') if not code: return jsonify({"code": 400, "msg": "参数错误"}) try: query("UPDATE products SET deleted_at=NOW() WHERE product_code=%s", (code,)) # 级联删除:物料清单 # 先查出成品绑定的包材(需在 BOM DELETE 之前查询) materials = query("SELECT material_code FROM bom WHERE product_code=%s", (code,)) query("DELETE FROM bom WHERE product_code=%s", (code,)) query("DELETE FROM bom WHERE material_code=%s", (code,)) # 级联删除:成品绑定的包材产品(软删除) for m in materials: query("UPDATE products SET deleted_at=NOW() WHERE product_code=%s", (m['material_code'],)) # 级联删除:计划预测隐藏记录 query("DELETE FROM plan_hidden WHERE product_code=%s", (code,)) return jsonify({"code": 0, "msg": "已移入回收站"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product/recycle', methods=['POST']) @login_required def product_recycle(): """将成品及其BOM包材移入回收站,并删除该成品的BOM。""" if not operation_required('products', 'delete'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json or {} code = (data.get('product_code') or '').strip() if not code: return jsonify({"code": 400, "msg": "缺少货品编号"}) conn = get_db() try: conn.begin() with conn.cursor() as cursor: cursor.execute( "SELECT product_name FROM products WHERE product_code=%s AND deleted_at IS NULL", (code,) ) product = cursor.fetchone() if not product: conn.rollback() return jsonify({"code": 404, "msg": "未找到可回收的产品"}) cursor.execute( "SELECT DISTINCT b.material_code FROM bom b " "JOIN products m ON m.product_code=b.material_code " "WHERE b.product_code=%s AND b.material_code IS NOT NULL " "AND b.material_code != '' AND m.product_type IN ('包材', '其他')", (code,) ) material_codes = [row['material_code'] for row in cursor.fetchall()] cursor.execute("UPDATE products SET deleted_at=NOW() WHERE product_code=%s", (code,)) if material_codes: placeholders = ','.join(['%s'] * len(material_codes)) cursor.execute( f"UPDATE products SET deleted_at=NOW() WHERE product_code IN ({placeholders})", material_codes ) cursor.execute("DELETE FROM bom WHERE product_code=%s", (code,)) conn.commit() # 计划预测通过 deleted_at 过滤;保留用户原有的手工隐藏状态。 try: add_operation_log( '产品回收', f"回收产品 {code} - {product.get('product_name', '')}," f"同步回收包材 {len(material_codes)} 个并删除BOM" ) except Exception: pass return jsonify({ "code": 0, "msg": f"已回收成品及{len(material_codes)}个关联包材,并删除该成品BOM", "data": {"recycled_materials": len(material_codes)} }) except Exception as e: conn.rollback() return jsonify({"code": 500, "msg": str(e)}) finally: conn.close() @products_bp.route('/api/products/recycle_bin', methods=['GET']) @login_required def products_recycle_bin(): """获取回收站产品列表""" page = int(request.args.get('page', 1)) size = int(request.args.get('size', 50)) offset = (page - 1) * size search = request.args.get('search', '') prod_code = request.args.get('prod_code', '') prod_name = request.args.get('prod_name', '') unified_search = request.args.get('search', '') supplier_filter = request.args.get('supplier', '') brand_filter = request.args.get('brand', '') product_type = request.args.get('product_type', '') where = ["p.deleted_at IS NOT NULL"] params = [] if search: where.append("(p.product_name LIKE %s OR p.product_code LIKE %s)") params.extend([f"%{search}%", f"%{search}%"]) if prod_code: where.append("p.product_code LIKE %s") params.append(f"%{prod_code}%") if prod_name: where.append("p.product_name LIKE %s") params.append(f"%{prod_name}%") if supplier_filter: where.append("(p.supplier_name_match LIKE %s OR s.supplier_name LIKE %s)") params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"]) if brand_filter: where.append("TRIM(p.brand)=%s") params.append(brand_filter) if product_type: where.append("p.product_type=%s") params.append(product_type) where_clause = " AND ".join(where) total = query(f"""SELECT COUNT(*) AS cnt FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause}""", params)[0]['cnt'] rows = query(f""" SELECT p.id, p.goods_code, p.product_code, p.product_name, p.brand, p.cost_price, p.product_type, p.specification, p.lead_time, p.logistics_time, p.supplier_code, p.supplier_name_match, p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.developer, p.category1, p.category2, p.category3, p.created_at, p.import_date, p.deleted_at, p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5 FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} ORDER BY p.deleted_at DESC LIMIT {size} OFFSET {offset} """, params) for row in rows: if row.get('logistics_time') is None: row['logistics_time'] = 0 if row.get('lead_time') is None: row['lead_time'] = 0 if 'created_at' in row and row['created_at']: row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_at'], 'strftime') else str(row['created_at'])[:19] if 'import_date' in row and row['import_date']: row['import_date'] = row['import_date'].strftime('%Y-%m-%d') if hasattr(row['import_date'], 'strftime') else str(row['import_date'])[:10] if row.get('deleted_at'): row['deleted_at'] = row['deleted_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['deleted_at'], 'strftime') else str(row['deleted_at'])[:19] if not row.get('product_type'): row['product_type'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) return jsonify({ "code": 0, "data": { "total": total, "page": page, "size": size, "rows": rows } }) @products_bp.route('/api/product/restore', methods=['POST']) @login_required def product_restore(): """从回收站恢复产品""" if not operation_required('products', 'edit'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json code = data.get('product_code', '') if not code: return jsonify({"code": 400, "msg": "参数错误"}) try: query("UPDATE products SET deleted_at=NULL WHERE product_code=%s", (code,)) add_operation_log('产品恢复', f'恢复产品 {code}') return jsonify({"code": 0, "msg": "已恢复到成品基础信息表"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product/batch_restore', methods=['POST']) @login_required def product_batch_restore(): """批量恢复回收站产品。""" if not operation_required('products', 'edit'): return jsonify({"code": 403, "msg": "无操作权限"}) data = request.json or {} codes = list(dict.fromkeys(str(code).strip() for code in data.get('product_codes', []) if str(code).strip())) if not codes: return jsonify({"code": 400, "msg": "请选择需要恢复的产品"}) if len(codes) > 500: return jsonify({"code": 400, "msg": "单次最多恢复500个产品"}) try: placeholders = ','.join(['%s'] * len(codes)) rows = query( f"SELECT product_code FROM products WHERE deleted_at IS NOT NULL AND product_code IN ({placeholders})", codes ) valid_codes = [row['product_code'] for row in rows] if not valid_codes: return jsonify({"code": 404, "msg": "所选产品均不在回收站中"}) valid_placeholders = ','.join(['%s'] * len(valid_codes)) query( f"UPDATE products SET deleted_at=NULL WHERE deleted_at IS NOT NULL AND product_code IN ({valid_placeholders})", valid_codes ) add_operation_log('产品批量恢复', f"批量恢复 {len(valid_codes)} 个产品:{','.join(valid_codes)}") return jsonify({"code": 0, "msg": f"已恢复{len(valid_codes)}个产品", "data": {"restored": len(valid_codes)}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/bom/update_materials', methods=['POST']) @login_required def bom_update_materials(): if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json materials = data.get('materials', []) if not materials: return jsonify({"code": 400, "msg": "参数不完整"}) allowed = ['material_name'] try: for m in materials: code = m.get('material_code', '') fields = m.get('fields', {}) if not code: continue set_parts = [] params = [] for field, value in fields.items(): if field in allowed: set_parts.append(f"{field}=%s") params.append(value if value != '' else None) if set_parts: set_clause = ", ".join(set_parts) params.append(code) query(f"UPDATE bom SET {set_clause} WHERE material_code=%s", params) # Also update the main product info in products table main_product_code = data.get('product_code', '') main_fields = data.get('main_fields', {}) if main_product_code and main_fields: product_allowed = ['product_name', 'cost_price', 'supplier_name'] set_parts = [] params = [] for field, value in main_fields.items(): if field in product_allowed: set_parts.append(f"{field}=%s") params.append(value if value != '' else None) if set_parts: set_clause = ", ".join(set_parts) params.append(main_product_code) query(f"UPDATE products SET {set_clause} WHERE product_code=%s", params) return jsonify({"code": 0, "msg": "更新成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/bom/create', methods=['POST']) @login_required def bom_create(): """新增BOM条目""" if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json or {} product_code = data.get('product_code', '').strip() material_code = data.get('material_code', '').strip() material_name = data.get('material_name', '').strip() quantity = float(data.get('quantity', 1)) if not product_code or not material_code: return jsonify({"code": 400, "msg": "货品编号和物料编号不能为空"}) existing = query("SELECT id FROM bom WHERE product_code=%s AND material_code=%s", (product_code, material_code)) if existing: return jsonify({"code": 400, "msg": "该BOM条目已存在"}) try: prod = query("SELECT product_name FROM products WHERE product_code=%s", (product_code,)) product_name = prod[0]['product_name'] if prod else product_code mat = query("SELECT product_name FROM products WHERE product_code=%s", (material_code,)) if not material_name and mat: material_name = mat[0]['product_name'] query("INSERT INTO bom (product_code, product_name, material_code, material_name, quantity) VALUES (%s,%s,%s,%s,%s)", (product_code, product_name, material_code, material_name, quantity)) return jsonify({"code": 0, "msg": "创建成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/bom/export') @login_required def bom_export(): """导出产品BOM状态(含有无BOM标记)""" rows = query(""" SELECT t.product_code, t.product_name, t.has_bom, p.product_type, p.supplier_name_match FROM ( SELECT b.product_code, MAX(b.product_name) as product_name, CASE WHEN SUM(CASE WHEN b.material_code IS NOT NULL AND b.material_code != '' THEN 1 ELSE 0 END) > 0 THEN '有' ELSE '无' END AS has_bom FROM bom b GROUP BY b.product_code ) t LEFT JOIN products p ON t.product_code = p.product_code ORDER BY t.product_code """) import csv, io output = io.StringIO() writer = csv.writer(output) writer.writerow(['货品编号', '货品名称', '有无BOM', '类型', '供应商']) for r in rows: writer.writerow([ r['product_code'], r['product_name'], r['has_bom'], r['product_type'] or '', r['supplier_name_match'] or '' ]) csv_data = output.getvalue() output.close() from flask import Response return Response( csv_data, mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=bom_export.csv'} ) @products_bp.route('/api/bom/delete', methods=['POST']) @login_required def bom_delete(): """删除成品BOM数据(含所有物料行)""" if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json or {} product_code = data.get('product_code', '').strip() if not product_code: return jsonify({"code": 400, "msg": "缺少成品编号"}) try: query("DELETE FROM bom WHERE product_code=%s", (product_code,)) return jsonify({"code": 0, "msg": f"已删除 [{product_code}] 的物料清单"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/bom/create_with_materials', methods=['POST']) @login_required def bom_create_with_materials(): """新建成品BOM并自动创建包材产品""" if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json or {} product_code = data.get('product_code', '').strip() materials = data.get('materials', []) if not product_code: return jsonify({"code": 400, "msg": "缺少成品编号"}) created = 0 total_cost = 0 # 自动创建成品产品(如果不存在) existing_product = query("SELECT id, product_name, product_type FROM products WHERE product_code=%s", (product_code,)) if not existing_product: prod_supplier = data.get('product_supplier', '').strip() query("""INSERT INTO products (product_code, product_name, brand, product_type, supplier_name_match, import_date) VALUES (%s,%s,'','成品',%s,NOW())""", (product_code, data.get('product_name', product_code), prod_supplier)) product_name = data.get('product_name', product_code) else: product_name = existing_product[0]['product_name'] or product_code for m in materials: mat_code = m.get('material_code', '').strip() mat_name = m.get('material_name', '').strip() qty = float(m.get('quantity', 1)) cost_price = float(m.get('cost_price', 0)) if m.get('cost_price') else 0 sup_code = m.get('supplier_code', '').strip() sup_name = _normalize_bom_supplier(m.get('supplier_name', '')) mat_type = m.get('product_type', '包材').strip() or '包材' if not mat_code: continue # 累加成本(成本价 × 用量) total_cost += qty * cost_price # 自动创建/更新包材产品 existing = query("SELECT id FROM products WHERE product_code=%s", (mat_code,)) if not existing: query("""INSERT INTO products (product_code, product_name, brand, product_type, supplier_code, supplier_name_match, import_date) VALUES (%s,%s,'',%s,%s,%s,NOW())""", (mat_code, mat_name or mat_code, mat_type, sup_code, sup_name)) else: # 已有物料更新供应商和名称 update_parts = [] update_params = [] if sup_name: update_parts.append("supplier_name_match=%s") update_params.append(sup_name) if sup_code: update_parts.append("supplier_code=%s") update_params.append(sup_code) if mat_name: update_parts.append("product_name=%s") update_params.append(mat_name) if mat_type: update_parts.append("product_type=%s") update_params.append(mat_type) if update_parts: update_params.append(mat_code) query(f"UPDATE products SET {', '.join(update_parts)} WHERE product_code=%s", update_params) # 新增/更新 BOM(含成本价) bom_exists = query("SELECT id FROM bom WHERE product_code=%s AND material_code=%s", (product_code, mat_code)) if bom_exists: query("UPDATE bom SET quantity=%s, material_name=%s, supplier=%s, material_cost_price=%s WHERE product_code=%s AND material_code=%s", (qty, mat_name, sup_name, cost_price, product_code, mat_code)) else: query("""INSERT INTO bom (product_code, product_name, material_code, material_name, quantity, supplier, material_cost_price) VALUES (%s,%s,%s,%s,%s,%s,%s)""", (product_code, product_name, mat_code, mat_name, qty, sup_name, cost_price)) created += 1 # 更新成品成本价 = 所有物料成本之和 if created > 0: query("UPDATE products SET cost_price=%s WHERE product_code=%s", (total_cost, product_code)) return jsonify({"code": 0, "msg": f"绑定完成,共处理{created}条BOM,成品成本价:¥{total_cost:.2f}"}) @products_bp.route('/api/product/sync_bom', methods=['POST']) @login_required def product_sync_bom(): """将成品BOM包材数据同步到物料清单管理(bom表)""" data = request.json or {} product_code = data.get('product_code', '').strip() if not product_code: return jsonify({"code": 400, "msg": "缺少成品编号"}) # 获取当前产品已有的BOM数据 bom_materials = query(""" SELECT material_code, material_name, quantity AS bom_qty FROM bom WHERE product_code=%s """, (product_code,)) if not bom_materials: return jsonify({"code": 0, "msg": "暂无包材数据可同步", "synced": 0}) prod = query("SELECT product_name FROM products WHERE product_code=%s", (product_code,)) product_name = prod[0]['product_name'] if prod else product_code synced = 0 for m in bom_materials: mat_code = m['material_code'] mat_name = m['material_name'] or mat_code qty = float(m['bom_qty']) if m['bom_qty'] else 1 # 确保物料已作为包材产品存在 existing = query("SELECT id FROM products WHERE product_code=%s", (mat_code,)) if not existing: query("""INSERT INTO products (product_code, product_name, brand, product_type, import_date) VALUES (%s,%s,'','包材',NOW())""", (mat_code, mat_name)) # 确保BOM条目存在 bom_exists = query("SELECT id FROM bom WHERE product_code=%s AND material_code=%s", (product_code, mat_code)) if bom_exists: query("UPDATE bom SET quantity=%s, material_name=%s WHERE product_code=%s AND material_code=%s", (qty, mat_name, product_code, mat_code)) else: query("""INSERT INTO bom (product_code, product_name, material_code, material_name, quantity) VALUES (%s,%s,%s,%s,%s)""", (product_code, product_name, mat_code, mat_name, qty)) synced += 1 return jsonify({"code": 0, "msg": f"同步完成,更新{len(bom_materials)}条,新增{synced}条", "synced": synced}) @products_bp.route('/api/products/match_supplier', methods=['POST']) @login_required def products_match_supplier(): """批量匹配包材产品的供应商名称,补全全称和编码""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) product_type = request.json.get('product_type', '') if request.json else '' if not product_type: return jsonify({"code": 400, "msg": "缺少产品类型"}) # 获取所有供应商 suppliers = query("SELECT supplier_code, supplier_name FROM suppliers") if not suppliers: return jsonify({"code": 0, "msg": "供应商列表为空", "matched": 0}) # 获取指定类型的产品(如果supplier_name_match与supplier对不上则更新) products = query(""" SELECT product_code, supplier_code, supplier_name_match FROM products WHERE product_type=%s AND deleted_at IS NULL """, (product_type,)) matched = 0 for p in products: pname = (p.get('supplier_name_match') or '').strip() if not pname: continue best = None # 模糊匹配供应商名称 for s in suppliers: sname = (s['supplier_name'] or '').strip() if not sname: continue # 如果产品供应商名包含在供应商全称中,或供应商全称包含产品供应商名 if pname in sname or sname in pname: best = s break if best: # 如果已是最佳匹配则跳过 if p.get('supplier_code') == best['supplier_code'] and p.get('supplier_name_match') == best['supplier_name']: continue query("UPDATE products SET supplier_code=%s, supplier_name_match=%s WHERE product_code=%s", (best['supplier_code'], best['supplier_name'], p['product_code'])) matched += 1 # 也匹配 supplier_code 为空但有 supplier_name_match 的包材产品 unmatched = query(""" SELECT product_code, supplier_name_match FROM products WHERE product_type=%s AND deleted_at IS NULL AND (supplier_code IS NULL OR supplier_code='') """, (product_type,)) for p in unmatched: pname = (p.get('supplier_name_match') or '').strip() if not pname: continue for s in suppliers: sname = (s['supplier_name'] or '').strip() if pname in sname or sname in pname: query("UPDATE products SET supplier_code=%s, supplier_name_match=%s WHERE product_code=%s", (s['supplier_code'], s['supplier_name'], p['product_code'])) matched += 1 break return jsonify({"code": 0, "msg": f"匹配完成,共更新{matched}条"}) @products_bp.route('/api/product/permanent_delete', methods=['POST']) @login_required def product_permanent_delete(): """从回收站彻底删除产品(仅管理员)""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json code = data.get('product_code', '') if not code: return jsonify({"code": 400, "msg": "参数错误"}) try: product = query( "SELECT product_name FROM products WHERE product_code=%s AND deleted_at IS NOT NULL", (code,) ) if not product: return jsonify({"code": 404, "msg": "该产品不在回收站中,无法彻底删除"}) # 只删除当前产品及其关联记录,不连带删除其他产品。 query("DELETE FROM bom WHERE product_code=%s OR material_code=%s", (code, code)) query("DELETE FROM plan_hidden WHERE product_code=%s", (code,)) query("DELETE FROM products WHERE product_code=%s AND deleted_at IS NOT NULL", (code,)) add_operation_log('产品彻底删除', f"彻底删除产品 {code} - {product[0].get('product_name', '')}") return jsonify({"code": 0, "msg": "已彻底删除,无法恢复"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product/batch_permanent_delete', methods=['POST']) @login_required def product_batch_permanent_delete(): """批量彻底删除回收站产品(仅管理员)。""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json or {} codes = list(dict.fromkeys(str(code).strip() for code in data.get('product_codes', []) if str(code).strip())) if not codes: return jsonify({"code": 400, "msg": "请选择需要删除的产品"}) if len(codes) > 500: return jsonify({"code": 400, "msg": "单次最多删除500个产品"}) try: placeholders = ','.join(['%s'] * len(codes)) rows = query( f"SELECT product_code FROM products WHERE deleted_at IS NOT NULL AND product_code IN ({placeholders})", codes ) valid_codes = [row['product_code'] for row in rows] if not valid_codes: return jsonify({"code": 404, "msg": "所选产品均不在回收站中"}) valid_placeholders = ','.join(['%s'] * len(valid_codes)) query( f"DELETE FROM bom WHERE product_code IN ({valid_placeholders}) OR material_code IN ({valid_placeholders})", valid_codes + valid_codes ) query(f"DELETE FROM plan_hidden WHERE product_code IN ({valid_placeholders})", valid_codes) query( f"DELETE FROM products WHERE deleted_at IS NOT NULL AND product_code IN ({valid_placeholders})", valid_codes ) add_operation_log('产品批量彻底删除', f"批量彻底删除 {len(valid_codes)} 个产品:{','.join(valid_codes)}") return jsonify({"code": 0, "msg": f"已彻底删除{len(valid_codes)}个产品,无法恢复", "data": {"deleted": len(valid_codes)}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product_categories/list') @login_required def product_categories_list(): if not operation_required('product_category', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """获取产品类目列表(按一级/二级/三级分组,最新在前)""" try: rows = query( "SELECT id, category1, category2, category3, category4 FROM product_categories ORDER BY id DESC" ) # 按一级类目分组(保持插入顺序,最新一组在前) grouped = {} for r in rows: c1 = r['category1'] or '' grouped.setdefault(c1, []).append({ 'id': r['id'], 'category1': c1, 'category2': r['category2'] or '', 'category3': r['category3'] or '', 'category4': r['category4'] or '' }) result = [] for c1 in grouped.keys(): items = grouped[c1] # 二级类目分组(保持插入顺序,最新在前) subs = {} for it in items: c2 = it['category2'] or '' subs.setdefault(c2, []).append(it) result.append({ 'category1': c1, 'count': len(items), 'children': [ {'category2': c2, 'count': len(sub), 'items': sub} for c2, sub in subs.items() ] }) return jsonify({"code": 0, "data": result, "total": len(rows)}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product_categories/update', methods=['POST']) @login_required def product_categories_update(): if not operation_required('product_category', 'edit'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """编辑保存类目(四列可改)""" data = request.get_json(silent=True) or {} cid = data.get('id') c1 = (data.get('category1') or '').strip() c2 = (data.get('category2') or '').strip() c3 = (data.get('category3') or '').strip() c4 = (data.get('category4') or '').strip() if not cid: return jsonify({"code": 400, "msg": "缺少类目ID"}) if not c1: return jsonify({"code": 400, "msg": "一级类目不能为空"}) try: cur = query( "UPDATE product_categories SET category1=%s, category2=%s, category3=%s, category4=%s WHERE id=%s", (c1, c2, c3, c4, cid) ) return jsonify({"code": 0, "msg": "保存成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product_categories/create', methods=['POST']) @login_required def product_categories_create(): if not operation_required('product_category', 'create'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """新增类目(四列)""" data = request.get_json(silent=True) or {} c1 = (data.get('category1') or '').strip() c2 = (data.get('category2') or '').strip() c3 = (data.get('category3') or '').strip() c4 = (data.get('category4') or '').strip() if not c1: return jsonify({"code": 400, "msg": "一级类目不能为空"}) try: # 新建前判断四级类目是否已存在(按完整名称:一级/二级/三级/四级组合) exists = query( "SELECT id FROM product_categories WHERE category1=%s AND category2=%s AND category3=%s AND category4=%s LIMIT 1", (c1, c2, c3, c4) ) if exists: full_name = '/'.join([x for x in (c1, c2, c3, c4) if x]) return jsonify({"code": 400, "msg": f"该四级类目已存在(完整名称:{full_name}),请勿重复新增"}) cur = query( "INSERT INTO product_categories (category1, category2, category3, category4) VALUES (%s, %s, %s, %s)", (c1, c2, c3, c4) ) return jsonify({"code": 0, "msg": "新增成功", "data": {"id": cur.lastrowid if hasattr(cur, 'lastrowid') else None}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/product_categories/delete', methods=['POST']) @login_required def product_categories_delete(): if not operation_required('product_category', 'delete'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """删除类目""" data = request.get_json(silent=True) or {} cid = data.get('id') if not cid: return jsonify({"code": 400, "msg": "缺少类目ID"}) try: query("DELETE FROM product_categories WHERE id=%s", (cid,)) return jsonify({"code": 0, "msg": "删除成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) # ─── 新品管理 API ─── NEW_PRODUCT_FIELDS = ['category1', 'category2', 'category3', 'product_type', 'brand', 'product_code', 'product_name', 'barcode', 'capacity', 'effect_img', 'remark', 'total_cost', 'total_payment', 'procurement_planner', 'product_dev', 'package_dev', 'designer', 'operator', 'upgrade_img', 'confirm_img', 'quote_inner_img', 'quote_pack_img', 'freight_included'] _NEW_PRODUCT_FREIGHT_READY = False def _ensure_new_product_freight_column(): """兼容旧环境:幂等补充新品是否含运字段。""" global _NEW_PRODUCT_FREIGHT_READY if _NEW_PRODUCT_FREIGHT_READY: return rows = query("SELECT COUNT(*) AS cnt FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='new_products' AND column_name='freight_included'") if not rows or int(rows[0].get('cnt') or 0) == 0: query("ALTER TABLE new_products ADD COLUMN freight_included VARCHAR(10) NOT NULL DEFAULT '' COMMENT '是否含运' AFTER quote_pack_img") _NEW_PRODUCT_FREIGHT_READY = True def _is_inner_material(material_name, material_code): """内料判定:名称含「内料」或编码含 -NL(与系统其他位置口径一致)。""" return ('内料' in (material_name or '')) or ('-NL' in (material_code or '')) def resolve_new_product_planner(product_id): """按品牌与 BOM 内料供应商匹配新品采购计划。 第一层:品牌为海外品时不动(留空或保留人工维护值); 第二层:取该新品 BOM 中内料物料的供应商,匹配供应商管理里绑定的采购计划。 匹配到时更新,匹配不到不覆盖原值。 """ rows = query("SELECT brand FROM new_products WHERE id=%s", (product_id,)) if not rows: return None brand = (rows[0].get('brand') or '').strip() if brand: cat = query("SELECT category FROM brand_information WHERE brand=%s LIMIT 1", (brand,)) if cat and (cat[0].get('category') or '').strip() == '海外': return None materials = query(""" SELECT material_code, material_name, supplier_code, supplier FROM new_product_bom WHERE product_id=%s ORDER BY sort_order, id """, (product_id,)) for m in materials: if not _is_inner_material(m.get('material_name'), m.get('material_code')): continue supplier_code = (m.get('supplier_code') or '').strip() supplier_name = (m.get('supplier') or '').strip() hit = None if supplier_code: r = query("SELECT procurement_planner FROM suppliers WHERE supplier_code=%s", (supplier_code,)) hit = r[0] if r else None if not hit and supplier_name: r = query("SELECT procurement_planner FROM suppliers WHERE supplier_name=%s", (supplier_name,)) hit = r[0] if r else None if not hit and supplier_name: r = query("SELECT procurement_planner FROM suppliers WHERE supplier_name LIKE %s ORDER BY id LIMIT 1", (f"%{supplier_name}%",)) hit = r[0] if r else None planner = (hit.get('procurement_planner') or '').strip() if hit else '' if planner: query("UPDATE new_products SET procurement_planner=%s WHERE id=%s", (planner, product_id)) return planner return None def _sync_new_product_to_product_info(conn, product_id): """在同一事务中同步新品成品、包材和 BOM,不删除目标表已有记录。""" with conn.cursor() as cursor: cursor.execute("SELECT * FROM new_products WHERE id=%s FOR UPDATE", (product_id,)) product = cursor.fetchone() if not product: raise ValueError("新品数据不存在,请刷新后重试") product_code = (product.get('product_code') or '').strip() product_name = (product.get('product_name') or '').strip() if not product_code or not product_name: raise ValueError("产品编码和产品名称不能为空") cursor.execute(""" SELECT material_code, material_name, supplier_code, supplier, lead_time, unit_price, quantity, usage_quantity, common_code, common_name FROM new_product_bom WHERE product_id=%s ORDER BY sort_order, id """, (product_id,)) source_materials = cursor.fetchall() materials_by_code = {} for material in source_materials: # 物料编号/物料名称对应新品成品管理BOM里的物料编码/物料名称, # 通用物料编码/名称不参与物料清单的编号名称映射。 material_code = (material.get('material_code') or '').strip() if not material_code: continue material_name = (material.get('material_name') or '').strip() materials_by_code[material_code] = { 'material_code': material_code, 'material_name': material_name, 'supplier_code': (material.get('supplier_code') or '').strip(), 'supplier_name': (material.get('supplier') or '').strip(), 'lead_time': int(material.get('lead_time') or 0), 'unit_price': float(material.get('unit_price') or 0), # 新品BOM“数量”是新品采购数量;同步到现有BOM的用量使用独立字段。 'quantity': float(material.get('usage_quantity')) if material.get('usage_quantity') is not None else 1.0, } materials = list(materials_by_code.values()) # ─── 计算成品同步属性(供应商取内料、货期=内料货期+其他物料货期最大值、成本价=物料成本价之和)─── inner_material = None other_leads = [] total_material_cost = 0.0 for m in materials: total_material_cost += m['unit_price'] if '内料' in (m['material_name'] or ''): inner_material = m else: other_leads.append(m['lead_time']) if inner_material: prod_supplier_code = inner_material['supplier_code'] prod_supplier_name = inner_material['supplier_name'] prod_lead_time = inner_material['lead_time'] + (max(other_leads) if other_leads else 0) else: prod_supplier_code = next((m['supplier_code'] for m in materials if m['supplier_code']), '') prod_supplier_name = next((m['supplier_name'] for m in materials if m['supplier_name']), '') prod_lead_time = max((m['lead_time'] for m in materials), default=0) prod_cost_price = round(total_material_cost, 4) all_codes = [product_code] + [material['material_code'] for material in materials] placeholders = ','.join(['%s'] * len(all_codes)) cursor.execute( f"SELECT product_code, product_name FROM products WHERE product_code IN ({placeholders})", tuple(all_codes) ) existing_product_rows = cursor.fetchall() existing_product_names = { row['product_code']: row.get('product_name') or '' for row in existing_product_rows } existing_product_codes = set(existing_product_names) cursor.execute( "SELECT material_code FROM bom WHERE product_code=%s", (product_code,) ) existing_bom_codes = {row['material_code'] for row in cursor.fetchall()} cursor.execute(""" INSERT INTO products (product_code, product_name, specification, brand, product_type, category1, category2, category3, operator, developer, cost_price, lead_time, supplier_code, supplier_name_match, created_at, import_date, deleted_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURDATE(),NULL) ON DUPLICATE KEY UPDATE product_name=VALUES(product_name), specification=IF(VALUES(specification)='', specification, VALUES(specification)), brand=IF(VALUES(brand)='', brand, VALUES(brand)), product_type=VALUES(product_type), category1=IF(VALUES(category1)='', category1, VALUES(category1)), category2=IF(VALUES(category2)='', category2, VALUES(category2)), category3=IF(VALUES(category3)='', category3, VALUES(category3)), operator=IF(VALUES(operator)='', operator, VALUES(operator)), developer=IF(VALUES(developer)='', developer, VALUES(developer)), cost_price=IF(VALUES(cost_price)>0, VALUES(cost_price), cost_price), lead_time=IF(VALUES(lead_time)>0, VALUES(lead_time), lead_time), supplier_code=IF(VALUES(supplier_code)='', supplier_code, VALUES(supplier_code)), supplier_name_match=IF(VALUES(supplier_name_match)='', supplier_name_match, VALUES(supplier_name_match)), deleted_at=NULL """, ( product_code, product_name, (product.get('capacity') or '').strip(), (product.get('brand') or '').strip(), (product.get('product_type') or '成品').strip(), (product.get('category1') or '').strip(), (product.get('category2') or '').strip(), (product.get('category3') or '').strip(), (product.get('operator') or '').strip(), (product.get('product_dev') or '').strip(), prod_cost_price, prod_lead_time, prod_supplier_code, prod_supplier_name, product.get('created_at'), )) for material in materials: material_code = material['material_code'] material_name = material['material_name'] or existing_product_names.get(material_code) or material_code cursor.execute(""" INSERT INTO products (product_code, product_name, product_type, cost_price, lead_time, supplier_code, supplier_name_match, developer, import_date, deleted_at) VALUES (%s,%s,'包材',%s,%s,%s,%s,%s,CURDATE(),NULL) ON DUPLICATE KEY UPDATE product_name=VALUES(product_name), product_type='包材', cost_price=IF(VALUES(cost_price)>0, VALUES(cost_price), cost_price), lead_time=IF(VALUES(lead_time)>0, VALUES(lead_time), lead_time), supplier_code=IF(VALUES(supplier_code)='', supplier_code, VALUES(supplier_code)), supplier_name_match=IF(VALUES(supplier_name_match)='', supplier_name_match, VALUES(supplier_name_match)), developer=IF(VALUES(developer)='', developer, VALUES(developer)), import_date=CURDATE(), deleted_at=NULL """, ( material_code, material_name, material['unit_price'], material['lead_time'], material['supplier_code'], material['supplier_name'], (product.get('package_dev') or '').strip(), )) cursor.execute(""" INSERT INTO bom (product_code, product_name, material_code, material_name, supplier, quantity, material_cost_price) VALUES (%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE product_name=VALUES(product_name), material_name=VALUES(material_name), supplier=IF(VALUES(supplier)='', supplier, VALUES(supplier)), quantity=VALUES(quantity), material_cost_price=IF(VALUES(material_cost_price)>0, VALUES(material_cost_price), material_cost_price), updated_at=CURRENT_TIMESTAMP """, ( product_code, product_name, material_code, material_name, material['supplier_name'], material['quantity'], material['unit_price'], )) material_codes = {material['material_code'] for material in materials} return { 'product_created': 0 if product_code in existing_product_codes else 1, 'product_updated': 1 if product_code in existing_product_codes else 0, 'materials_total': len(materials), 'materials_created': len(material_codes - existing_product_codes), 'materials_updated': len(material_codes & existing_product_codes), 'bom_total': len(materials), 'bom_created': len(material_codes - existing_bom_codes), 'bom_updated': len(material_codes & existing_bom_codes), } @products_bp.route('/api/new_products/list') @login_required def new_products_list(): _ensure_new_product_freight_column() if not operation_required('product_new', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """获取新品列表(最新在前),支持编码、名称和BOM供应商筛选""" product_code = (request.args.get('product_code') or '').strip() product_name = (request.args.get('product_name') or '').strip() unified_search = (request.args.get('search') or '').strip() supplier = (request.args.get('supplier') or '').strip() where = [] params = [] if unified_search: where.append("(product_code LIKE %s OR product_name LIKE %s)") params.extend([f"%{unified_search}%", f"%{unified_search}%"]) if product_code: where.append("product_code LIKE %s") params.append(f"%{product_code}%") if product_name: where.append("product_name LIKE %s") params.append(f"%{product_name}%") if supplier: where.append("EXISTS (SELECT 1 FROM new_product_bom npb WHERE npb.product_id = new_products.id AND (npb.supplier LIKE %s OR npb.supplier_code LIKE %s))") params.extend([f"%{supplier}%", f"%{supplier}%"]) where_clause = " AND ".join(where) if where else "1=1" try: rows = query("SELECT id, " + ",".join(NEW_PRODUCT_FIELDS) + " FROM new_products WHERE " + where_clause + " ORDER BY id DESC", tuple(params)) return jsonify({"code": 0, "data": rows, "total": len(rows)}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) def _new_product_image_file(image_value, project_root): """把新品图片字段中的静态资源地址解析为本地文件。""" raw_value = str(image_value or '').strip() if not raw_value: return None # 图片字段通常保存 /static/...,同时兼容历史记录中的相对路径、绝对路径和带域名地址。 if raw_value.startswith(('http://', 'https://')): return None if raw_value.startswith('/static/'): local_path = os.path.join(project_root, raw_value.lstrip('/')) elif raw_value.startswith('static/'): local_path = os.path.join(project_root, raw_value) elif os.path.isabs(raw_value): local_path = raw_value else: local_path = os.path.join(project_root, raw_value) return local_path if os.path.isfile(local_path) else None @products_bp.route('/api/new_products/export', methods=['POST']) @login_required def new_products_export(): _ensure_new_product_freight_column() """导出新品成品管理数据,字段由前端选择。""" if not operation_required('product_new', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) data = request.get_json(silent=True) or {} fields = data.get('fields') or [] code_filter = (data.get('product_code') or '').strip() name_filter = (data.get('product_name') or '').strip() unified_search = (data.get('search') or '').strip() supplier_filter = (data.get('supplier') or '').strip() field_map = { 'category1': ('category1', '一级类目'), 'category2': ('category2', '二级类目'), 'category3': ('category3', '三级类目'), 'product_type': ('product_type', '类别'), 'brand': ('brand', '品牌'), 'product_code': ('product_code', '产品编码'), 'product_name': ('product_name', '产品名称'), 'barcode': ('barcode', '条形码'), 'capacity': ('capacity', '容量'), 'remark': ('remark', '成品备注'), 'total_cost': ('total_cost', '产品总成本'), 'total_payment': ('total_payment', '产品总货款'), 'effect_img': ('effect_img', '产品效果图(内外包)'), 'upgrade_img': ('upgrade_img', '升级前的'), 'confirm_img': ('confirm_img', '确版内料图凭证'), 'quote_inner_img': ('quote_inner_img', '内料报价单'), 'quote_pack_img': ('quote_pack_img', '包材报价单'), 'freight_included': ('freight_included', '是否含运'), 'product_dev': ('product_dev', '产品开发'), 'package_dev': ('package_dev', '包材开发'), 'designer': ('designer', '设计师'), 'operator': ('operator', '负责运营') } selected = [f for f in fields if f in field_map] if not selected: return jsonify({"code": 400, "msg": "请至少选择一个导出字段"}) where = [] params = [] if unified_search: where.append('(product_code LIKE %s OR product_name LIKE %s)'); params.extend([f'%{unified_search}%', f'%{unified_search}%']) if code_filter: where.append('product_code LIKE %s'); params.append(f'%{code_filter}%') if name_filter: where.append('product_name LIKE %s'); params.append(f'%{name_filter}%') if supplier_filter: where.append("EXISTS (SELECT 1 FROM new_product_bom npb WHERE npb.product_id=new_products.id AND (npb.supplier LIKE %s OR npb.supplier_code LIKE %s))") params.extend([f'%{supplier_filter}%', f'%{supplier_filter}%']) where_clause = ' AND '.join(where) if where else '1=1' try: rows = query( 'SELECT ' + ','.join(f'{field_map[f][0]} AS `{f}`' for f in selected) + f' FROM new_products WHERE {where_clause} ORDER BY id DESC', params) import io import openpyxl from openpyxl.drawing.image import Image as ExcelImage from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter wb = openpyxl.Workbook(); ws = wb.active; ws.title = '新品成品管理' headers = [field_map[f][1] for f in selected] ws.append(headers) thin = Side(style='thin', color='D9D9D9') for cell in ws[1]: cell.font = Font(bold=True, color='FFFFFF'); cell.fill = PatternFill('solid', fgColor='1890FF') cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) image_fields = {'effect_img', 'upgrade_img', 'confirm_img', 'quote_inner_img', 'quote_pack_img'} project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) for row_index, row in enumerate(rows, 2): row_has_embedded_image = False vals = [] for f in selected: value = row.get(f, '') if value is None: value = '' if f in ('total_cost', 'total_payment') and value != '': try: value = f'{float(value):.3f}' except (TypeError, ValueError): pass # 图片字段改为 Excel 内嵌图片,不再把 /static/... 路径作为普通文本写入。 vals.append('' if f in image_fields else value) ws.append(vals) for col_index, f in enumerate(selected, 1): if f not in image_fields or not row.get(f): continue image_paths = [str(p).strip() for p in str(row[f]).split(',') if str(p).strip()] for image_path in image_paths: local_path = _new_product_image_file(image_path, project_root) if not local_path: # 原图不在测试环境时不输出静态路径,避免把不可用地址误当成图片内容。 cell = ws.cell(row=row_index, column=col_index) cell.value = '图片缺失' continue try: image = ExcelImage(local_path) max_width, max_height = 100, 72 ratio = min(max_width / image.width, max_height / image.height, 1) image.width = max(1, int(image.width * ratio)) image.height = max(1, int(image.height * ratio)) image.anchor = f'{get_column_letter(col_index)}{row_index}' ws.add_image(image) row_has_embedded_image = True break except Exception: continue if row_has_embedded_image: ws.row_dimensions[row_index].height = 60 for row in ws.iter_rows(): for cell in row: cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) for i, header in enumerate(headers, 1): ws.column_dimensions[get_column_letter(i)].width = 15 if selected[i - 1] in image_fields else min(max(len(str(header)) * 1.8, 12), 32) output = io.BytesIO(); wb.save(output); output.seek(0) from flask import send_file return send_file(output, as_attachment=True, download_name='新品成品管理.xlsx', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/new_products/create', methods=['POST']) @login_required def new_products_create(): _ensure_new_product_freight_column() if not operation_required('product_new', 'create'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """新增新品(全部字段)""" data = request.get_json(silent=True) or {} vals = {f: (data.get(f) or '').strip() for f in NEW_PRODUCT_FIELDS} if not vals['product_name'] and not vals['product_code']: return jsonify({"code": 400, "msg": "产品名称或产品编码至少填写一项"}) try: placeholders = ",".join(["%s"] * len(NEW_PRODUCT_FIELDS)) query( "INSERT INTO new_products (" + ",".join(NEW_PRODUCT_FIELDS) + ") VALUES (" + placeholders + ")", tuple(vals[f] for f in NEW_PRODUCT_FIELDS) ) return jsonify({"code": 0, "msg": "新增成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/new_products/update', methods=['POST']) @login_required def new_products_update(): _ensure_new_product_freight_column() if not operation_required('product_new', 'edit'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """编辑保存新品""" data = request.get_json(silent=True) or {} pid = data.get('id') if not pid: return jsonify({"code": 400, "msg": "缺少ID"}) vals = {f: (data.get(f) or '').strip() for f in NEW_PRODUCT_FIELDS} try: sets = ",".join([f + "=%s" for f in NEW_PRODUCT_FIELDS]) query( "UPDATE new_products SET " + sets + " WHERE id=%s", tuple(vals[f] for f in NEW_PRODUCT_FIELDS) + (pid,) ) return jsonify({"code": 0, "msg": "保存成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/new_products/delete', methods=['POST']) @login_required def new_products_delete(): if not operation_required('product_new', 'delete'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """删除新品""" data = request.get_json(silent=True) or {} pid = data.get('id') if not pid: return jsonify({"code": 400, "msg": "缺少ID"}) try: query("DELETE FROM new_products WHERE id=%s", (pid,)) return jsonify({"code": 0, "msg": "删除成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) NEW_UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads', 'new_products') os.makedirs(NEW_UPLOAD_FOLDER, exist_ok=True) @products_bp.route('/api/new_products/upload_image', methods=['POST']) @login_required def new_products_upload_image(): if not operation_required('product_new', 'edit'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """上传新品图片(产品效果图/升级前内料图/确版内料图凭证)""" file = request.files.get('file') if not file or not file.filename: return jsonify({"code": 400, "msg": "请选择文件"}) ext = os.path.splitext(file.filename)[1].lower() if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'): return jsonify({"code": 400, "msg": "仅支持图片格式(jpg/png/gif/webp/bmp)"}) safe_name = f"{uuid.uuid4().hex}{ext}" file.save(os.path.join(NEW_UPLOAD_FOLDER, safe_name)) return jsonify({"code": 0, "msg": "上传成功", "data": {"url": f"/static/uploads/new_products/{safe_name}"}}) @products_bp.route('/api/new_products/sync', methods=['POST']) @login_required def new_products_sync(): if not operation_required('product_new', 'sync'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """同步新品成品信息、包材信息和 BOM 到产品信息管理。""" if not _check_product_edit_perm(): return jsonify({"code": 403, "msg": "无产品信息编辑权限"}) data = request.get_json(silent=True) or {} product_id = data.get('product_id') if not product_id: return jsonify({"code": 400, "msg": "缺少新品ID"}) try: product_id = int(product_id) except (TypeError, ValueError): return jsonify({"code": 400, "msg": "新品ID格式错误"}) conn = get_db() try: conn.begin() result = _sync_new_product_to_product_info(conn, product_id) conn.commit() add_operation_log( '新品资料同步', f"新品ID {product_id}:成品{result['product_created']}条新增/{result['product_updated']}条更新," f"包材{result['materials_created']}条新增/{result['materials_updated']}条更新," f"BOM{result['bom_created']}条新增/{result['bom_updated']}条更新" ) return jsonify({"code": 0, "msg": "同步成功", "data": result}) except ValueError as error: conn.rollback() return jsonify({"code": 400, "msg": str(error)}) except Exception as error: conn.rollback() return jsonify({"code": 500, "msg": f"同步失败:{error}"}) finally: conn.close() @products_bp.route('/api/new_products/category_search') @login_required def new_products_category_search(): if not operation_required('product_new', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """三级类目模糊搜索:根据三级类目匹配对应一级/二级类目""" q = (request.args.get('q') or '').strip() if not q: return jsonify({"code": 0, "data": []}) rows = query( "SELECT DISTINCT category1, category2, category3 FROM product_categories WHERE category3 LIKE %s ORDER BY category3 LIMIT 20", (f"%{q}%",) ) return jsonify({"code": 0, "data": rows}) def _tax_included_value(value): """新品BOM税点展示规则:税点非0=是,税点为0或空=否。""" raw = str(value or '').strip().replace('%', '') try: return '是' if float(raw or 0) != 0 else '否' except (TypeError, ValueError): return '否' @products_bp.route('/api/new_products/bom') @login_required def new_products_bom(): if not operation_required('product_new', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """新品BOM清单:查独立的新品BOM表 new_product_bom(不复用现有物料清单 bom 表)""" pid = (request.args.get('product_id') or '').strip() if not pid: return jsonify({"code": 400, "msg": "缺少新品ID"}) try: rows = query(""" SELECT id, material_code, material_name, usage_quantity, supplier_code, supplier, lead_time, tax_rate, unit_price, quantity, amount, payment_method, can_order, remark, common_code, common_name, order_date FROM new_product_bom WHERE product_id=%s ORDER BY sort_order, id """, (int(pid),)) data = [] for r in rows: data.append({ 'id': r['id'], 'material_code': r['material_code'] or '', 'material_name': r['material_name'] or '', 'usage_quantity': float(r['usage_quantity']) if r['usage_quantity'] is not None else 1.0, 'supplier_code': r['supplier_code'] or '', 'supplier': r['supplier'] or '', 'lead_time': r['lead_time'] if r['lead_time'] is not None else '', 'tax_rate': r['tax_rate'] or '', 'tax_included': _tax_included_value(r['tax_rate']), 'unit_price': float(r['unit_price'] or 0), 'quantity': float(r['quantity'] or 0), 'amount': float(r['amount'] or 0), 'payment_method': r['payment_method'] or '', 'can_order': r['can_order'] or '', 'remark': r['remark'] or '', 'common_code': r['common_code'] or '', 'common_name': r['common_name'] or '', 'order_date': str(r['order_date']) if r['order_date'] else '' }) return jsonify({"code": 0, "data": data}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/new_products/bom/save', methods=['POST']) @login_required def new_products_bom_save(): if not operation_required('product_new', 'edit'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """保存新品BOM清单(全量替换该新品的物料行)""" data = request.get_json(silent=True) or {} pid = data.get('product_id') rows = data.get('rows') or [] if not pid: return jsonify({"code": 400, "msg": "缺少新品ID"}) try: pid = int(pid) # 过滤全空行(物料编码和包材明细都为空则跳过) valid = [] for r in rows: if not (r.get('material_code') or '').strip() and not (r.get('material_name') or '').strip(): continue valid.append(r) query("DELETE FROM new_product_bom WHERE product_id=%s", (pid,)) total_cost = 0.0 total_payment = 0.0 for i, r in enumerate(valid): unit_price = float(r.get('unit_price') or 0) quantity = float(r.get('quantity') or 0) raw_usage_quantity = str(r.get('usage_quantity') if r.get('usage_quantity') is not None else '').strip() usage_quantity = float(raw_usage_quantity) if raw_usage_quantity else 1.0 amount = round(unit_price * quantity, 2) total_cost += unit_price total_payment += amount order_date = (r.get('order_date') or '').strip() or None query(""" INSERT INTO new_product_bom (product_id, material_code, material_name, usage_quantity, supplier_code, supplier, lead_time, tax_rate, unit_price, quantity, amount, payment_method, can_order, remark, common_code, common_name, order_date, sort_order) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, (pid, (r.get('material_code') or '').strip(), (r.get('material_name') or '').strip(), usage_quantity, (r.get('supplier_code') or '').strip(), (r.get('supplier') or '').strip(), int(r.get('lead_time') or 0), (r.get('tax_rate') or '').strip(), unit_price, quantity, amount, (r.get('payment_method') or '').strip(), (r.get('can_order') or '').strip(), (r.get('remark') or '').strip(), (r.get('common_code') or '').strip(), (r.get('common_name') or '').strip(), order_date, i)) # 同步更新新品表:产品总成本=物料单价之和,产品总货款=物料货款之和(保留3位小数) total_cost = round(total_cost, 3) total_payment = round(total_payment, 3) query("UPDATE new_products SET total_cost=%s, total_payment=%s WHERE id=%s", (f"{total_cost:.3f}", f"{total_payment:.3f}", pid)) # 绑定BOM后按品牌与内料供应商匹配采购计划(海外品不动,由人工维护) matched_planner = resolve_new_product_planner(pid) return jsonify({"code": 0, "msg": "保存成功", "data": {"count": len(valid), "total_cost": total_cost, "total_payment": total_payment, "procurement_planner": matched_planner or ''}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) def _new_packaging_where(product_filter='', material_filter='', supplier_filter=''): """新品包材列表与导出共用同一套筛选条件,确保两处数据口径一致。""" where = [] params = [] if product_filter: where.append('(p.product_code LIKE %s OR p.product_name LIKE %s)') params.extend([f'%{product_filter}%', f'%{product_filter}%']) if material_filter: where.append('(b.material_code LIKE %s OR b.material_name LIKE %s OR b.common_code LIKE %s OR b.common_name LIKE %s)') params.extend([f'%{material_filter}%'] * 4) if supplier_filter: where.append('(b.supplier LIKE %s OR b.supplier_code LIKE %s)') params.extend([f'%{supplier_filter}%', f'%{supplier_filter}%']) return (' AND '.join(where) if where else '1=1'), params @products_bp.route('/api/new_products/packaging') @login_required def new_products_packaging(): """新品包材管理:直接读取新品BOM弹窗保存的全部物料字段。""" if not operation_required('product_packaging', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) page = max(int(request.args.get('page', 1)), 1) size = min(max(int(request.args.get('size', 50)), 1), 200) offset = (page - 1) * size product_filter = (request.args.get('product') or '').strip() material_filter = (request.args.get('material') or '').strip() supplier_filter = (request.args.get('supplier') or '').strip() where_clause, params = _new_packaging_where(product_filter, material_filter, supplier_filter) try: total = query(f""" SELECT COUNT(*) AS cnt FROM new_product_bom b JOIN new_products p ON p.id = b.product_id WHERE {where_clause} """, params)[0]['cnt'] rows = query(f""" SELECT p.product_code AS finished_product_code, p.product_name AS finished_product_name, b.material_code, b.material_name, b.usage_quantity, b.supplier_code, b.supplier, b.lead_time, b.tax_rate, b.unit_price, b.quantity, b.amount, b.payment_method, b.can_order, b.remark, b.common_code, b.common_name, b.order_date FROM new_product_bom b JOIN new_products p ON p.id = b.product_id WHERE {where_clause} ORDER BY p.id DESC, b.sort_order ASC, b.id ASC LIMIT {size} OFFSET {offset} """, params) for row in rows: for key in ('unit_price', 'quantity', 'amount'): if row.get(key) is not None: row[key] = float(row[key]) row['usage_quantity'] = float(row['usage_quantity']) if row['usage_quantity'] is not None else 1.0 if row.get('order_date'): row['order_date'] = row['order_date'].isoformat() if hasattr(row['order_date'], 'isoformat') else str(row['order_date'])[:10] row['tax_included'] = _tax_included_value(row.get('tax_rate')) _filter_field_rows('product_packaging', rows, session.get('user_id'), session.get('role'), page_view=operation_required('product_packaging', 'view'), page_edit=operation_required('product_packaging', 'edit')) return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/new_products/packaging/export', methods=['POST']) @login_required def new_products_packaging_export(): """导出新品包材管理数据,字段由前端选择。""" if not operation_required('product_packaging', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) data = request.get_json(silent=True) or {} fields = data.get('fields') or [] product_filter = (data.get('product') or '').strip() material_filter = (data.get('material') or '').strip() supplier_filter = (data.get('supplier') or '').strip() where_clause, params = _new_packaging_where(product_filter, material_filter, supplier_filter) field_map = { 'finished_product_code': ('p.product_code', '成品编码'), 'finished_product_name': ('p.product_name', '成品名称'), 'material_code': ('b.material_code', '物料编码'), 'material_name': ('b.material_name', '物料名称'), 'usage_quantity': ('b.usage_quantity', '用量'), 'supplier_code': ('b.supplier_code', '供应商编码'), 'supplier': ('b.supplier', '供应商'), 'lead_time': ('b.lead_time', '货期/天'), 'tax_rate': ('b.tax_rate', '税点'), # PyMySQL 会先对 SQL 做 %-格式化,SQL 字面量百分号必须写成 %%。 'tax_included': ("CASE WHEN CAST(REPLACE(TRIM(COALESCE(b.tax_rate, '')), '%%', '') AS DECIMAL(12,4)) <> 0 THEN '是' ELSE '否' END", '是否含税'), 'unit_price': ('b.unit_price', '单价/元'), 'quantity': ('b.quantity', '数量'), 'amount': ('b.amount', '货款/元'), 'payment_method': ('b.payment_method', '结款方式'), 'can_order': ('b.can_order', '是否可下单'), 'remark': ('b.remark', '下单备注'), 'common_code': ('b.common_code', '通用物料编码'), 'common_name': ('b.common_name', '通用物料名称'), 'order_date': ('b.order_date', '下单日期') } selected = [f for f in fields if f in field_map] if not selected: return jsonify({"code": 400, "msg": "请至少选择一个导出字段"}) try: rows = query( 'SELECT ' + ','.join(f'{field_map[f][0]} AS `{f}`' for f in selected) + f' FROM new_product_bom b JOIN new_products p ON p.id=b.product_id' + f' WHERE {where_clause} ORDER BY p.id DESC,b.sort_order ASC,b.id ASC', tuple(params) ) import io import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter wb = openpyxl.Workbook(); ws = wb.active; ws.title = '新品包材管理' headers = [field_map[f][1] for f in selected]; ws.append(headers) thin = Side(style='thin', color='D9D9D9') for cell in ws[1]: cell.font = Font(bold=True, color='FFFFFF'); cell.fill = PatternFill('solid', fgColor='1890FF') cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) for row in rows: vals = [] for f in selected: value = row.get(f, '') if value is None: value = '' if f in ('unit_price', 'quantity', 'amount') and value != '': try: value = float(value) except (TypeError, ValueError): pass vals.append(value) ws.append(vals) for row in ws.iter_rows(): for cell in row: cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) for i, header in enumerate(headers, 1): ws.column_dimensions[get_column_letter(i)].width = min(max(len(str(header)) * 1.8, 12), 32) output = io.BytesIO(); wb.save(output); output.seek(0) from flask import send_file return send_file(output, as_attachment=True, download_name='新品包材管理.xlsx', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @products_bp.route('/api/new_products/brands') @login_required def new_products_brands(): if not operation_required('product_new', 'view'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) """品牌列表(现有品牌下拉)""" rows = query("SELECT DISTINCT brand FROM products WHERE brand IS NOT NULL AND brand != '' ORDER BY brand") return jsonify({"code": 0, "data": [r['brand'] for r in rows]}) @products_bp.route('/api/product/remark/save', methods=['POST']) @login_required def product_remark_save(): """保存产品批注(下单信息弹窗批注编辑框用,权限与下单信息编辑一致)""" if not operation_required('purchase', 'edit'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"}) data = request.get_json(silent=True) or {} product_code = (data.get('product_code') or '').strip() remark = (data.get('remark') or '').strip() if not product_code: return jsonify({"code": 400, "msg": "缺少货品编号"}) query("UPDATE products SET remark=%s WHERE product_code=%s", (remark, product_code)) return jsonify({"code": 0, "msg": "保存成功"}) @products_bp.route('/api/bom_packaging/save', methods=['POST']) @login_required def bom_packaging_save(): """保存下单前/后包材数据到 bom_packaging 表""" if not (operation_required('planning', 'edit') or operation_required('purchase', 'edit')): return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑计划预测或待下单信息权限"}) data = request.get_json(silent=True) if not data: return jsonify({"code": 400, "msg": "缺少请求数据"}) product_code = data.get('product_code', '').strip() materials = data.get('materials', []) if not product_code or not materials: return jsonify({"code": 400, "msg": "缺少产品编号或物料数据"}) try: for m in materials: mc = (m.get('material_code') or '').strip() if not mc: continue pre_pack = int(m.get('pre_pack_qty') or 0) # 下单后包材由确认下单时按滚动逻辑计算,前端传入值不允许覆盖。 pkg_remark = str(m.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, mc, pre_pack, pkg_remark, pre_pack, pkg_remark)) return jsonify({"code": 0, "msg": "保存成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)})