dy-dashboard/routes/products.py
2026-10-28 11:14:17 +08:00

3902 lines
178 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""产品管理路由"""
import os
import uuid
from datetime import datetime
from flask import (Blueprint, current_app, request, jsonify, session, redirect,
url_for, render_template, send_file)
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__)
PRODUCT_ATTACHMENT_FOLDER = os.path.join(
os.path.dirname(os.path.dirname(__file__)), 'var', 'product_attachments'
)
PRODUCT_ATTACHMENT_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
PRODUCT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
os.makedirs(PRODUCT_ATTACHMENT_FOLDER, exist_ok=True)
def _ensure_product_attachment_table():
"""Create the independent attachment store on first use in each environment."""
query("""
CREATE TABLE IF NOT EXISTS product_attachments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
stored_name VARCHAR(80) NOT NULL,
original_name VARCHAR(255) NOT NULL DEFAULT '',
uploaded_by INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_product_attachments_product (product_id),
UNIQUE KEY uk_product_attachments_stored_name (stored_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成品基础信息附件'
""")
def _product_attachment_is_image(content, extension):
signatures = {
'png': lambda value: value.startswith(b'\x89PNG\r\n\x1a\n'),
'jpg': lambda value: value.startswith(b'\xff\xd8\xff'),
'jpeg': lambda value: value.startswith(b'\xff\xd8\xff'),
'gif': lambda value: value.startswith((b'GIF87a', b'GIF89a')),
'webp': lambda value: len(value) >= 12 and value[:4] == b'RIFF' and value[8:12] == b'WEBP',
'bmp': lambda value: value.startswith(b'BM'),
}
return bool(content) and signatures.get(extension, lambda value: False)(content)
# 计划预测下单信息弹窗显示内料的货品名称关键字:海外品牌还要命中国家名才显示内料
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 _sync_bom_supplier_from_product(product_id=None, product_code=None):
"""包材供应商变更后同步 BOM 快照,避免物料清单继续显示旧名称。"""
if product_id:
where_sql = 'p.id=%s'
params = (product_id,)
elif product_code:
where_sql = "p.product_code=%s AND p.product_type IN ('包材','其他')"
params = (product_code,)
else:
return
rows = query(f"""
SELECT p.product_code, p.product_type,
COALESCE(NULLIF(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_sql}
ORDER BY p.id
LIMIT 1
""", params)
if not rows or rows[0].get('product_type') not in ('包材', '其他'):
return
query("UPDATE bom SET supplier=%s WHERE material_code=%s",
(rows[0].get('supplier_name') or '', rows[0]['product_code']))
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 == '__EMPTY__':
where.append("(p.brand IS NULL OR TRIM(p.brand)='')")
elif 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"
)
empty_brand_count = query(
f"SELECT COUNT(*) AS cnt FROM products WHERE {scope_clause}"
"(brand IS NULL OR TRIM(brand) = '')"
)[0]['cnt']
brand_values = [b['brand'] for b in brands]
if empty_brand_count:
brand_values.insert(0, '__EMPTY__')
return jsonify({
"code": 0,
"data": {
"brands": brand_values,
"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/products/attachments')
@login_required
def product_attachments_list():
if not role_required('products'):
return jsonify({"code": 403, "msg": "无产品信息查看权限"}), 403
try:
product_id = int(request.args.get('product_id', 0))
except (TypeError, ValueError):
product_id = 0
product = query(
"SELECT id, product_code, product_name FROM products WHERE id=%s AND deleted_at IS NULL",
(product_id,),
) if product_id else []
if not product:
return jsonify({"code": 404, "msg": "产品不存在或已被回收"}), 404
_ensure_product_attachment_table()
rows = query(
"""SELECT pa.id, pa.original_name, pa.created_at,
COALESCE(u.real_name, u.username, '') AS uploader
FROM product_attachments pa
LEFT JOIN dashboard_users u ON u.id=pa.uploaded_by
WHERE pa.product_id=%s ORDER BY pa.id DESC""",
(product_id,),
)
for row in rows:
value = row.get('created_at')
row['created_at'] = value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value or '')[:19]
row['url'] = f"/api/products/attachments/{row['id']}/file"
return jsonify({
"code": 0,
"data": {
"product": product[0],
"attachments": rows,
"can_edit": bool(_check_product_edit_perm()),
},
})
@products_bp.route('/api/products/attachments/upload', methods=['POST'])
@login_required
def product_attachments_upload():
if not _check_product_edit_perm():
return jsonify({"code": 403, "msg": "无产品信息编辑权限"}), 403
try:
product_id = int(request.form.get('product_id', 0))
except (TypeError, ValueError):
product_id = 0
product = query(
"SELECT id, product_code, product_name FROM products WHERE id=%s AND deleted_at IS NULL",
(product_id,),
) if product_id else []
if not product:
return jsonify({"code": 404, "msg": "产品不存在或已被回收"}), 404
uploads = [item for item in request.files.getlist('files') if item and item.filename]
if not uploads:
return jsonify({"code": 400, "msg": "请选择需要上传的图片"}), 400
if len(uploads) > 20:
return jsonify({"code": 400, "msg": "单次最多上传20张图片"}), 400
prepared = []
for upload in uploads:
original_name = os.path.basename(upload.filename.replace('\\', '/')).strip()[:255]
extension = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else ''
if extension not in PRODUCT_ATTACHMENT_EXTENSIONS:
return jsonify({"code": 400, "msg": f"{original_name} 不是支持的图片格式"}), 400
content = upload.stream.read(PRODUCT_ATTACHMENT_MAX_BYTES + 1)
if len(content) > PRODUCT_ATTACHMENT_MAX_BYTES:
return jsonify({"code": 400, "msg": f"{original_name} 超过10MB"}), 400
if not _product_attachment_is_image(content, extension):
return jsonify({"code": 400, "msg": f"{original_name} 的图片内容无效"}), 400
prepared.append((original_name, extension, content))
_ensure_product_attachment_table()
saved_paths = []
conn = get_db()
try:
with conn.cursor() as cur:
for original_name, extension, content in prepared:
stored_name = f'{uuid.uuid4().hex}.{extension}'
path = os.path.join(PRODUCT_ATTACHMENT_FOLDER, stored_name)
with open(path, 'wb') as target:
target.write(content)
saved_paths.append(path)
cur.execute(
"""INSERT INTO product_attachments
(product_id, stored_name, original_name, uploaded_by)
VALUES (%s,%s,%s,%s)""",
(product_id, stored_name, original_name, session.get('user_id')),
)
conn.commit()
except Exception:
conn.rollback()
for path in saved_paths:
try:
os.unlink(path)
except OSError:
pass
current_app.logger.exception('上传产品附件失败')
return jsonify({"code": 500, "msg": "上传失败,请稍后重试"}), 500
finally:
conn.close()
try:
add_operation_log(
'上传产品附件',
f"{product[0]['product_code']} 上传{len(prepared)}张图片",
page='产品信息管理', object_type='product', object_id=str(product_id),
)
except Exception:
current_app.logger.exception('产品附件已上传,操作日志写入失败')
return jsonify({"code": 0, "msg": f"已上传{len(prepared)}张图片"})
@products_bp.route('/api/products/attachments/<int:attachment_id>/file')
@login_required
def product_attachment_file(attachment_id):
if not role_required('products'):
return jsonify({"code": 403, "msg": "无产品信息查看权限"}), 403
_ensure_product_attachment_table()
rows = query(
"SELECT stored_name, original_name FROM product_attachments WHERE id=%s",
(attachment_id,),
)
if not rows:
return jsonify({"code": 404, "msg": "附件不存在"}), 404
stored_name = rows[0]['stored_name']
if os.path.basename(stored_name) != stored_name:
return jsonify({"code": 404, "msg": "附件不存在"}), 404
path = os.path.join(PRODUCT_ATTACHMENT_FOLDER, stored_name)
if not os.path.isfile(path):
return jsonify({"code": 404, "msg": "附件文件不存在"}), 404
return send_file(path, as_attachment=False, download_name=rows[0]['original_name'])
@products_bp.route('/api/products/attachments/<int:attachment_id>/delete', methods=['POST'])
@login_required
def product_attachment_delete(attachment_id):
if not _check_product_edit_perm():
return jsonify({"code": 403, "msg": "无产品信息编辑权限"}), 403
_ensure_product_attachment_table()
rows = query(
"""SELECT pa.stored_name, pa.original_name, p.product_code
FROM product_attachments pa JOIN products p ON p.id=pa.product_id
WHERE pa.id=%s""",
(attachment_id,),
)
if not rows:
return jsonify({"code": 404, "msg": "附件不存在或已删除"}), 404
query("DELETE FROM product_attachments WHERE id=%s", (attachment_id,))
path = os.path.join(PRODUCT_ATTACHMENT_FOLDER, rows[0]['stored_name'])
try:
if os.path.isfile(path):
os.unlink(path)
except OSError:
current_app.logger.warning('产品附件记录已删除,但文件清理失败: %s', path)
try:
add_operation_log(
'删除产品附件',
f"{rows[0]['product_code']} 删除图片 {rows[0]['original_name']}",
page='产品信息管理', object_type='product_attachment', object_id=str(attachment_id),
)
except Exception:
current_app.logger.exception('产品附件已删除,操作日志写入失败')
return jsonify({"code": 0, "msg": "附件已删除"})
@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', '')
product_id = data.get('product_id')
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'
]
existing_product = None
if product_id:
rows = query("SELECT id, product_code, product_name FROM products WHERE id=%s", (product_id,))
if not rows:
return jsonify({"code": 404, "msg": "产品记录不存在"})
existing_product = rows[0]
product_code = existing_product['product_code']
old_code = product_code
old_name = existing_product.get('product_name', '') if existing_product else ''
new_code = fields.get('product_code', old_code)
set_parts = []
params = []
for field, value in fields.items():
if field not in allowed_fields:
continue
# 品牌来自表格/剪贴板时可能带有尾部 Tab 或换行;只清理首尾空白,保留名称中的正常空格。
if field == 'brand' and isinstance(value, str):
value = value.strip()
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 product_id:
check_name = fields.get('product_name', old_name)
if new_code != old_code:
collision = query(
"SELECT id FROM products WHERE product_code=%s AND product_name=%s AND id<>%s",
(new_code, check_name, product_id),
)
if collision:
return jsonify({"code": 400, "msg": "目标货品编号和货品名称已存在"})
update_params = params[:-1] + [product_id]
query(f"UPDATE products SET {set_clause} WHERE id=%s", update_params)
if 'product_name' in fields and fields.get('product_name'):
name_value = fields['product_name']
if old_name:
query(
"UPDATE bom SET product_name=%s WHERE product_code=%s AND product_name=%s",
(name_value, old_code, old_name),
)
query(
"UPDATE bom SET material_name=%s WHERE material_code=%s AND material_name=%s",
(name_value, old_code, old_name),
)
if 'supplier_code' in fields or 'supplier_name_match' in fields:
_sync_bom_supplier_from_product(product_id=product_id)
add_operation_log('产品批量编辑', f'产品ID {product_id}: ' + ', '.join(
f'{k}={v}' for k, v in fields.items() if v is not None))
return jsonify({"code": 0, "msg": "更新成功"})
# 如果货品编号变更
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))
if 'supplier_code' in fields or 'supplier_name_match' in fields:
final_code = new_code if new_code != old_code and new_code else old_code
_sync_bom_supplier_from_product(product_code=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 AND m.product_type IN ('包材','其他')
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
AND COALESCE(TRIM(b.material_code), '') <> ''
"""
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 ''
# 复用计划预测列表/导出的唯一计算逻辑,避免详情弹窗另维护一套公式。
from routes.planning import _enrich_row_with_planning_data
sales = compute_product_sales_cached()
inv_map = compute_product_inventory_cached()
inv15_map = compute_product_inventory_15()
transit_map = compute_product_in_transit_cached()
cloud_inv_map = compute_cloud_inventory_cached()
ext_inv_map = compute_external_inventory_cached()
extended_sales = compute_product_sales_extended_cached()
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
from services.forecast import get_global_config
gc = get_global_config()
row = product
pc = row.get('product_code', '')
row = _enrich_row_with_planning_data(
row, sales, inv_map, inv15_map, transit_map, sp_map, gc,
cloud_inv_map=cloud_inv_map,
ext_inv_map=ext_inv_map,
extended_sales=extended_sales,
)
inv15_val = float(row.get('加工厂库存', 0) or 0)
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
cloud_sales = extended_sales.get('cloud', {}).get(pc, {})
cloud_inventory = row.get('云仓库存', 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.get('云仓日销量预测', 0)
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,
'总支撑天数': row.get('云仓支撑天数', 999),
'30天平均销量': cloud_30,
'极值判断': '-',
}
external_sales = extended_sales.get('external', {}).get(pc, {})
external_inventory = row.get('外仓库存', 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.get('外仓日销量预测', 0)
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,
'总支撑天数': row.get('外仓支撑天数', 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 AND m.product_type IN ('包材','其他'))
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 AND p.product_type NOT IN ('包材','其他')
LEFT JOIN products m ON b.material_code = m.product_code AND m.product_type IN ('包材','其他')
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 AND m.product_type IN ('包材','其他')
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, deleted_at FROM products WHERE product_code=%s AND product_name=%s",
(product_code, product_name),
)
if existing:
if not existing[0].get('deleted_at'):
return jsonify({"code": 400, "msg": f"货品编号 {product_code} 和货品名称“{product_name}”已存在"})
query("UPDATE products SET deleted_at=NULL WHERE id=%s", (existing[0]['id'],))
return jsonify({"code": 0, "msg": "同编码同名称产品已恢复"})
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') or '').strip(), (data.get('product_type') or '成品').strip(),
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
code_rows = query(
"SELECT id, product_name, deleted_at FROM products WHERE product_code=%s",
(pc,),
)
exact_rows = [r for r in code_rows if str(r.get('product_name') or '').strip() == pn] if pn else []
existing = None
if exact_rows:
existing = exact_rows[0]
if existing.get('deleted_at'):
query("UPDATE products SET deleted_at=NULL WHERE id=%s", (existing['id'],))
existing['deleted_at'] = None
elif len(code_rows) == 1 and not pn:
existing = code_rows[0]
elif len(code_rows) > 1 and not pn:
errors.append(f"第{i}行:货品编号 {pc} 存在多条不同名称记录,请填写货品名称以确定更新对象,已跳过")
continue
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(existing['id'])
query(f"UPDATE products SET {', '.join(set_parts)} WHERE id=%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 AND deleted_at IS NULL "
"AND product_type NOT IN ('包材','其他') ORDER BY id LIMIT 1",
(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 "
"AND deleted_at IS NULL AND product_type='包材' ORDER BY id LIMIT 1",
(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') or '').strip()
fields = data.get('fields', {})
if not code or not fields:
return jsonify({"code": 400, "msg": "参数不完整"})
allowed = ['supplier_code', '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:
if field == 'supplier_code':
value = (value or '').strip()
if not value:
return jsonify({"code": 400, "msg": "供应商编码不能为空"})
set_parts.append(f"{field}=%s")
params.append(value if value != '' else None)
if not set_parts:
return jsonify({"code": 400, "msg": "没有可更新的字段"})
new_code = next((value for field, value in fields.items() if field == 'supplier_code'), code)
new_code = (new_code or '').strip()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT id FROM suppliers WHERE supplier_code=%s FOR UPDATE", (code,))
if not cur.fetchone():
conn.rollback()
return jsonify({"code": 404, "msg": "供应商不存在"})
if new_code != code:
cur.execute("SELECT id FROM suppliers WHERE supplier_code=%s LIMIT 1", (new_code,))
if cur.fetchone():
conn.rollback()
return jsonify({"code": 400, "msg": f"供应商编码 [{new_code}] 已存在"})
set_clause = ", ".join(set_parts)
cur.execute(f"UPDATE suppliers SET {set_clause} WHERE supplier_code=%s", params + [code])
if new_code != code:
cur.execute("UPDATE products SET supplier_code=%s WHERE supplier_code=%s", (new_code, code))
cur.execute("UPDATE new_product_bom SET supplier_code=%s WHERE supplier_code=%s", (new_code, code))
conn.commit()
try:
add_operation_log('编辑供应商编码' if new_code != code else '编辑供应商',
f'供应商 {code}' + (f' 编码变更为 {new_code}' if new_code != code else ' 信息已更新'))
except Exception:
pass
return jsonify({"code": 0, "msg": "更新成功"})
except Exception as e:
conn.rollback()
return jsonify({"code": 500, "msg": str(e)})
finally:
conn.close()
@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()
contract_short_name = (data.get('contract_short_name') or '').strip()
if not code:
return jsonify({"code": 400, "msg": "供应商编码不能为空"})
if not name:
return jsonify({"code": 400, "msg": "供应商名称不能为空"})
if contract_short_name and contract_short_name != contract_short_name.upper():
return jsonify({"code": 400, "msg": "合同简称必须全大写"})
exists = query("SELECT id FROM suppliers WHERE supplier_code=%s LIMIT 1", (code,))
if exists:
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": "新增成功", "data": {"supplier_code": code}})
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 AND m.product_type IN ('包材','其他') "
"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 == '__EMPTY__':
where.append("(p.brand IS NULL OR TRIM(p.brand)='')")
elif 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 AND p.product_type NOT IN ('包材','其他')
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
# 特殊成品可能没有任何包材。用一条空物料占位记录保留 BOM 产品分组,
# 物料清单管理可以显示该成品并标记为“无BOM”,而下单弹窗会过滤掉空物料行。
if not materials:
existing_bom = query("SELECT id FROM bom WHERE product_code=%s LIMIT 1", (product_code,))
if not existing_bom:
query("""INSERT INTO bom
(product_code, product_name, material_code, material_name, quantity,
supplier, material_cost_price)
VALUES (%s,%s,'','',0,'',0)""",
(product_code, product_name))
return jsonify({"code": 0, "msg": "已创建无包材成品BOM"})
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)})