722 lines
26 KiB
Python
722 lines
26 KiB
Python
"""出库数据路由"""
|
||
from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template, send_file, Response
|
||
from services.db import query
|
||
from routes.auth import login_required, role_required, operation_required
|
||
from services.field_perms import filter_rows as _filter_field_rows
|
||
|
||
data_bp = Blueprint('data', __name__)
|
||
|
||
# 筛选项变化频率远低于页面访问频率。短时缓存仅用于筛选项,避免每次
|
||
# 打开出库明细都执行 5 次 DISTINCT 全表查询;数据更新后最多 60 秒刷新。
|
||
_data_filter_cache = {'expires_at': 0, 'data': None}
|
||
|
||
|
||
|
||
@data_bp.route('/data')
|
||
@login_required
|
||
def data_page():
|
||
if not role_required('data'):
|
||
return redirect(url_for('auth.index'))
|
||
return render_template('base.html', page='data_detail')
|
||
|
||
|
||
@data_bp.route('/data/summary')
|
||
@login_required
|
||
def data_summary_page():
|
||
if not role_required('data'):
|
||
return redirect(url_for('auth.index'))
|
||
return render_template('base.html', page='data_summary')
|
||
|
||
|
||
@data_bp.route('/data/detail')
|
||
@login_required
|
||
def data_detail_page():
|
||
if not role_required('data'):
|
||
return redirect(url_for('auth.index'))
|
||
return render_template('base.html', page='data_detail')
|
||
|
||
|
||
# ─── API: 数据查询 ───
|
||
@data_bp.route('/api/data/list')
|
||
@login_required
|
||
def data_list():
|
||
page = int(request.args.get('page', 1))
|
||
size = int(request.args.get('size', 50))
|
||
offset = (page - 1) * size
|
||
|
||
date_from = request.args.get('date_from', '')
|
||
date_to = request.args.get('date_to', '')
|
||
brand_filter = request.args.get('brand', '')
|
||
shop_filter = request.args.get('shop', '')
|
||
source_filter = request.args.get('source', '')
|
||
warehouse_filter = request.args.get('warehouse', '')
|
||
search = request.args.get('search', '')
|
||
|
||
where = ["货品编号 != '合计:'"]
|
||
params = []
|
||
|
||
if date_from:
|
||
where.append("日期 >= %s")
|
||
params.append(date_from)
|
||
if date_to:
|
||
where.append("日期 <= %s")
|
||
params.append(date_to)
|
||
if brand_filter:
|
||
where.append("品牌=%s")
|
||
params.append(brand_filter)
|
||
if shop_filter:
|
||
where.append("店铺=%s")
|
||
params.append(shop_filter)
|
||
if source_filter:
|
||
src_vals = {source_filter}
|
||
display_name = SOURCE_MAP_CN.get(source_filter)
|
||
if display_name:
|
||
src_vals.add(display_name)
|
||
else:
|
||
# Check if source_filter is a known display name (reverse lookup)
|
||
for code, name in SOURCE_MAP_CN.items():
|
||
if source_filter == name:
|
||
src_vals.add(code)
|
||
break
|
||
if len(src_vals) > 1:
|
||
placeholders = ', '.join(['%s'] * len(src_vals))
|
||
where.append(f"数据来源 IN ({placeholders})")
|
||
params.extend(sorted(src_vals))
|
||
else:
|
||
where.append("数据来源=%s")
|
||
params.append(source_filter)
|
||
if warehouse_filter:
|
||
where.append("仓库=%s")
|
||
params.append(warehouse_filter)
|
||
if search:
|
||
where.append("(货品编号 LIKE %s OR 货品名称 LIKE %s)")
|
||
params.extend([f"%{search}%", f"%{search}%"])
|
||
|
||
where_clause = " AND ".join(where)
|
||
|
||
total = query(f"SELECT COUNT(*) AS cnt FROM daily_outbound WHERE {where_clause}", params)[0]['cnt']
|
||
|
||
rows = query(f"""
|
||
SELECT id, 日期, 货品编号, 货品名称, 品牌, 仓库, 店铺,
|
||
一级类目, 二级类目, 三级类目, 货品数量, 货品成本, 数据来源
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
ORDER BY 日期 DESC, 货品数量 DESC, id DESC
|
||
LIMIT {size} OFFSET {offset}
|
||
""", params)
|
||
|
||
for row in rows:
|
||
if '日期' in row and row['日期']:
|
||
row['日期'] = row['日期'].strftime('%Y-%m-%d') if hasattr(row['日期'], 'strftime') else str(row['日期'])[:10]
|
||
|
||
_filter_field_rows('data_detail', rows, session.get('user_id'), session.get('role'),
|
||
page_view=bool(role_required('data_detail')),
|
||
page_edit=operation_required('data_detail', 'query'))
|
||
return jsonify({
|
||
"code": 0,
|
||
"data": {
|
||
"total": total,
|
||
"page": page,
|
||
"size": size,
|
||
"rows": rows
|
||
}
|
||
})
|
||
|
||
|
||
@data_bp.route('/api/data/export')
|
||
@login_required
|
||
def data_export():
|
||
"""导出历史出库明细数据,沿用出库明细页面当前筛选条件。"""
|
||
from io import BytesIO
|
||
import openpyxl
|
||
from openpyxl.styles import Font, Alignment, PatternFill
|
||
|
||
date_from = request.args.get('date_from', '')
|
||
date_to = request.args.get('date_to', '')
|
||
brand_filter = request.args.get('brand', '')
|
||
shop_filter = request.args.get('shop', '')
|
||
source_filter = request.args.get('source', '')
|
||
warehouse_filter = request.args.get('warehouse', '')
|
||
search = request.args.get('search', '')
|
||
selected_fields = [f for f in request.args.get('fields', '').split(',') if f]
|
||
|
||
field_defs = {
|
||
'date': ('日期', 14), 'product_code': ('货品编号', 22), 'product_name': ('货品名称', 30),
|
||
'brand': ('品牌', 16), 'quantity': ('货品数量', 12), 'unit_cost': ('成本价', 14),
|
||
'amount': ('金额', 14), 'warehouse': ('仓库', 18), 'shop': ('店铺', 18),
|
||
'source': ('数据来源', 16),
|
||
}
|
||
if not selected_fields:
|
||
selected_fields = list(field_defs)
|
||
selected_fields = [f for f in selected_fields if f in field_defs]
|
||
if not selected_fields:
|
||
return jsonify({'code': 400, 'msg': '请至少选择一个导出字段'}), 400
|
||
|
||
where = ["货品编号 != '合计:'"]
|
||
params = []
|
||
if date_from:
|
||
where.append("日期 >= %s"); params.append(date_from)
|
||
if date_to:
|
||
where.append("日期 <= %s"); params.append(date_to)
|
||
if brand_filter:
|
||
where.append("品牌=%s"); params.append(brand_filter)
|
||
if shop_filter:
|
||
where.append("店铺=%s"); params.append(shop_filter)
|
||
if source_filter:
|
||
src_vals = {source_filter}
|
||
display_name = SOURCE_MAP_CN.get(source_filter)
|
||
if display_name:
|
||
src_vals.add(display_name)
|
||
else:
|
||
for code, name in SOURCE_MAP_CN.items():
|
||
if source_filter == name:
|
||
src_vals.add(code)
|
||
break
|
||
placeholders = ', '.join(['%s'] * len(src_vals))
|
||
where.append(f"数据来源 IN ({placeholders})")
|
||
params.extend(sorted(src_vals))
|
||
if warehouse_filter:
|
||
where.append("仓库=%s"); params.append(warehouse_filter)
|
||
if search:
|
||
where.append("(货品编号 LIKE %s OR 货品名称 LIKE %s)")
|
||
params.extend([f"%{search}%", f"%{search}%"])
|
||
|
||
rows = query(f"""
|
||
SELECT 日期, 货品编号, 货品名称, 品牌, 货品数量, 货品成本,
|
||
仓库, 店铺, 数据来源
|
||
FROM daily_outbound
|
||
WHERE {' AND '.join(where)}
|
||
ORDER BY 日期 DESC, 货品数量 DESC
|
||
""", params)
|
||
|
||
headers = [field_defs[f][0] for f in selected_fields]
|
||
source_names = SOURCE_MAP_CN
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = '历史出库明细'
|
||
ws.append(headers)
|
||
for cell in ws[1]:
|
||
cell.font = Font(bold=True, color='FFFFFF')
|
||
cell.fill = PatternFill('solid', fgColor='1890FF')
|
||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||
for row in rows:
|
||
total_cost = float(row.get('货品成本') or 0)
|
||
quantity = float(row.get('货品数量') or 0)
|
||
values = []
|
||
for field in selected_fields:
|
||
key = field_defs[field][0]
|
||
if field == 'date':
|
||
value = row.get('日期', '')
|
||
value = value.strftime('%Y-%m-%d') if value and hasattr(value, 'strftime') else (str(value)[:10] if value else '')
|
||
elif field == 'quantity':
|
||
value = quantity
|
||
elif field == 'unit_cost':
|
||
value = round(total_cost / quantity, 4) if quantity else 0
|
||
elif field == 'amount':
|
||
value = total_cost
|
||
elif field == 'source':
|
||
value = source_names.get(row.get('数据来源'), row.get('数据来源') or '')
|
||
else:
|
||
value = row.get(key, '')
|
||
values.append(value if value is not None else '')
|
||
ws.append(values)
|
||
widths = [field_defs[f][1] for f in selected_fields]
|
||
for idx, width in enumerate(widths, 1):
|
||
ws.column_dimensions[openpyxl.utils.get_column_letter(idx)].width = width
|
||
ws.freeze_panes = 'A2'
|
||
ws.auto_filter.ref = ws.dimensions
|
||
output = BytesIO()
|
||
wb.save(output)
|
||
output.seek(0)
|
||
return send_file(output, as_attachment=True,
|
||
download_name='历史出库明细.xlsx',
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
|
||
|
||
SOURCE_MAP_CN = {
|
||
'wdt': '旺店通',
|
||
'jdbz': '京东泊紫',
|
||
'jdgj': '京东国际',
|
||
'jdjwt': '京东洁维塔',
|
||
'tmgj': '天猫国际',
|
||
'dymuto': '抖音M店',
|
||
'dymtuo': '抖音M店',
|
||
'jdsmj': '京东适美家',
|
||
}
|
||
|
||
|
||
# Reverse map: Chinese name → code, so stale Chinese-name entries in DB get normalized back
|
||
_SOURCE_MAP_REVERSE = {v: k for k, v in SOURCE_MAP_CN.items()}
|
||
|
||
|
||
def _dedup_sources(raw_sources):
|
||
"""Deduplicate sources by display name, keeping the raw DB value (prefers codes over
|
||
Chinese names since SQL sorts codes first), preserving order.
|
||
Also normalizes any Chinese-name entries back to canonical codes."""
|
||
seen = set()
|
||
deduped = []
|
||
for s in raw_sources:
|
||
# normalize: if a Chinese name is in DB, map it back to the code
|
||
code = _SOURCE_MAP_REVERSE.get(s)
|
||
if code is not None:
|
||
s = code
|
||
display = SOURCE_MAP_CN.get(s, s)
|
||
if display not in seen:
|
||
seen.add(display)
|
||
deduped.append(s) # keep the raw DB value so SQL filtering still works
|
||
return deduped
|
||
|
||
|
||
@data_bp.route('/api/data/dates')
|
||
@login_required
|
||
def data_dates():
|
||
import time
|
||
now = time.time()
|
||
if _data_filter_cache['data'] is not None and now < _data_filter_cache['expires_at']:
|
||
return jsonify({'code': 0, 'data': _data_filter_cache['data']})
|
||
|
||
dates = query("SELECT DISTINCT 日期 FROM daily_outbound ORDER BY 日期 DESC")
|
||
brands = query("SELECT DISTINCT 品牌 FROM daily_outbound ORDER BY 品牌")
|
||
shops = query("SELECT DISTINCT 店铺 FROM daily_outbound WHERE 店铺 IS NOT NULL AND 店铺 != '' ORDER BY 店铺")
|
||
sources = query("SELECT DISTINCT 数据来源 FROM daily_outbound WHERE 数据来源 IS NOT NULL AND 数据来源 != '' ORDER BY 数据来源")
|
||
warehouses = query("SELECT DISTINCT 仓库 FROM daily_outbound WHERE 仓库 IS NOT NULL AND 仓库 != '' ORDER BY 仓库")
|
||
data = {
|
||
"dates": [d['日期'].strftime('%Y-%m-%d') if hasattr(d['日期'], 'strftime') else d['日期'] for d in dates],
|
||
"brands": [b['品牌'] for b in brands],
|
||
"shops": [s['店铺'] for s in shops],
|
||
"sources": _dedup_sources([s['数据来源'] for s in sources]),
|
||
"warehouses": [w['仓库'] for w in warehouses]
|
||
}
|
||
_data_filter_cache.update({'expires_at': now + 60, 'data': data})
|
||
return jsonify({'code': 0, 'data': data})
|
||
|
||
|
||
@data_bp.route('/api/data/summary')
|
||
@login_required
|
||
def outbound_summary():
|
||
"""按货品编号+日期汇总出库量"""
|
||
page = int(request.args.get('page', 1))
|
||
size = int(request.args.get('size', 50))
|
||
offset = (page - 1) * size
|
||
|
||
date_from = request.args.get('date_from', '')
|
||
date_to = request.args.get('date_to', '')
|
||
search = request.args.get('search', '')
|
||
where = ["货品编号 != '' AND 货品编号 IS NOT NULL"]
|
||
params = []
|
||
|
||
if date_from:
|
||
where.append("DATE(日期) >= %s")
|
||
params.append(date_from)
|
||
if date_to:
|
||
# 日期选择器按自然日筛选,包含结束日期当天的全部记录。
|
||
where.append("DATE(日期) <= %s")
|
||
params.append(date_to)
|
||
if search:
|
||
where.append("(货品编号 LIKE %s OR 货品名称 LIKE %s)")
|
||
params.extend([f"%{search}%", f"%{search}%"])
|
||
|
||
where_clause = " AND ".join(where)
|
||
|
||
# 汇总查询
|
||
count_result = query(f"""
|
||
SELECT COUNT(DISTINCT CONCAT(日期, '_', 货品编号)) AS cnt
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
""", params)
|
||
total = count_result[0]['cnt'] if count_result else 0
|
||
|
||
rows = query(f"""
|
||
SELECT 日期, 货品编号, 货品名称, 品牌,
|
||
SUM(货品数量) AS total_qty,
|
||
ROUND(SUM(货品成本), 2) AS total_cost
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
GROUP BY 日期, 货品编号, 货品名称, 品牌
|
||
ORDER BY 日期 DESC, total_qty DESC
|
||
LIMIT {size} OFFSET {offset}
|
||
""", params)
|
||
|
||
for row in rows:
|
||
if '日期' in row and row['日期']:
|
||
row['日期'] = row['日期'].strftime('%Y-%m-%d') if hasattr(row['日期'], 'strftime') else str(row['日期'])[:10]
|
||
row['total_qty'] = int(row['total_qty']) if row['total_qty'] else 0
|
||
row['total_cost'] = float(row['total_cost']) if row['total_cost'] else 0
|
||
|
||
summary_result = query(f"""
|
||
SELECT COALESCE(SUM(货品数量), 0) AS total_qty,
|
||
COALESCE(ROUND(SUM(货品成本), 2), 0) AS total_cost
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
""", params)
|
||
summary = summary_result[0] if summary_result else {}
|
||
|
||
return jsonify({
|
||
"code": 0,
|
||
"data": {
|
||
"total": total,
|
||
"page": page,
|
||
"size": size,
|
||
"summary": {
|
||
"total_qty": int(summary.get('total_qty') or 0),
|
||
"total_cost": float(summary.get('total_cost') or 0)
|
||
},
|
||
"total_qty": int(summary.get('total_qty') or 0),
|
||
"total_cost": float(summary.get('total_cost') or 0),
|
||
"rows": rows
|
||
}
|
||
})
|
||
|
||
|
||
@data_bp.route('/api/data/summary-total')
|
||
@login_required
|
||
def outbound_summary_total():
|
||
"""返回数据汇总当前筛选条件下的全量合计,不受分页影响。"""
|
||
date_from = request.args.get('date_from', '')
|
||
date_to = request.args.get('date_to', '')
|
||
search = request.args.get('search', '')
|
||
where = ["货品编号 != '' AND 货品编号 IS NOT NULL"]
|
||
params = []
|
||
if date_from:
|
||
where.append("DATE(日期) >= %s")
|
||
params.append(date_from)
|
||
if date_to:
|
||
where.append("DATE(日期) <= %s")
|
||
params.append(date_to)
|
||
if search:
|
||
where.append("(货品编号 LIKE %s OR 货品名称 LIKE %s)")
|
||
params.extend([f"%{search}%", f"%{search}%"])
|
||
result = query(f"""
|
||
SELECT COALESCE(SUM(货品数量), 0) AS total_qty,
|
||
COALESCE(ROUND(SUM(货品成本), 2), 0) AS total_cost
|
||
FROM daily_outbound WHERE {' AND '.join(where)}
|
||
""", params)
|
||
row = result[0] if result else {}
|
||
return jsonify({'code': 0, 'data': {
|
||
'total_qty': int(row.get('total_qty') or 0),
|
||
'total_cost': float(row.get('total_cost') or 0)
|
||
}})
|
||
|
||
|
||
@data_bp.route('/api/data/summary-stats')
|
||
@login_required
|
||
def outbound_summary_stats():
|
||
"""出库汇总统计:总出库量 + 趋势(按月/日,可按SKU筛选)"""
|
||
search = request.args.get('search', '')
|
||
group_by = request.args.get('group_by', 'month') # month or day
|
||
month = request.args.get('month', '') # 钻取某月时传入 YYYY-MM
|
||
|
||
where = ["货品编号 != '' AND 货品编号 IS NOT NULL"]
|
||
params = []
|
||
|
||
if search:
|
||
where.append("(货品编号 LIKE %s OR 货品名称 LIKE %s)")
|
||
params.extend([f"%{search}%", f"%{search}%"])
|
||
|
||
where_clause = " AND ".join(where)
|
||
|
||
# 总出库量(不受钻取影响)
|
||
total = query(f"""
|
||
SELECT SUM(货品数量) AS total_out_qty,
|
||
COUNT(DISTINCT 货品编号) AS total_sku,
|
||
COUNT(DISTINCT 日期) AS total_days
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
""", params)
|
||
|
||
# 趋势数据
|
||
if group_by == 'day' and month:
|
||
# 钻取:查看某月的每日趋势
|
||
where.append("DATE_FORMAT(日期, '%%Y-%%m') = %s")
|
||
params.append(month)
|
||
where_clause = " AND ".join(where)
|
||
trend = query(f"""
|
||
SELECT 日期, SUM(货品数量) AS day_qty
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
GROUP BY 日期
|
||
ORDER BY 日期 ASC
|
||
""", params)
|
||
for row in trend:
|
||
if '日期' in row and row['日期']:
|
||
row['日期'] = row['日期'].strftime('%Y-%m-%d') if hasattr(row['日期'], 'strftime') else str(row['日期'])[:10]
|
||
row['day_qty'] = int(row['day_qty']) if row['day_qty'] else 0
|
||
else:
|
||
# 按月聚合
|
||
trend = query(f"""
|
||
SELECT DATE_FORMAT(日期, '%%Y-%%m') AS month, SUM(货品数量) AS month_qty
|
||
FROM daily_outbound
|
||
WHERE {where_clause}
|
||
GROUP BY DATE_FORMAT(日期, '%%Y-%%m')
|
||
ORDER BY month ASC
|
||
""", params)
|
||
for row in trend:
|
||
row['month'] = row.get('month') or ''
|
||
row['month_qty'] = int(row.get('month_qty') or 0)
|
||
|
||
stats = total[0] if total else {}
|
||
|
||
return jsonify({
|
||
"code": 0,
|
||
"data": {
|
||
"total_out_qty": int(stats.get('total_out_qty') or 0),
|
||
"total_sku": int(stats.get('total_sku') or 0),
|
||
"total_days": int(stats.get('total_days') or 0),
|
||
"group_by": group_by,
|
||
"month": month,
|
||
"trend": trend
|
||
}
|
||
})
|
||
|
||
|
||
@data_bp.route('/api/data/sku-trend')
|
||
@login_required
|
||
def outbound_sku_trend():
|
||
"""商品日出库趋势:按日聚合出库量,排除赠品/小样(可指定商品)"""
|
||
product_code = request.args.get('product_code', '')
|
||
search = request.args.get('search', '')
|
||
date_from = request.args.get('date_from', '')
|
||
date_to = request.args.get('date_to', '')
|
||
|
||
where = ["d.货品编号 != '' AND d.货品编号 IS NOT NULL"]
|
||
params = []
|
||
|
||
# 排除赠品、小样:products.product_type='赠品' 或 名称含赠品/小样
|
||
where.append("(p.product_type IS NULL OR p.product_type NOT IN ('赠品','小样'))")
|
||
where.append("(d.货品名称 NOT LIKE '%%赠品%%' AND d.货品名称 NOT LIKE '%%小样%%')")
|
||
|
||
if product_code:
|
||
where.append("d.货品编号=%s")
|
||
params.append(product_code)
|
||
if search:
|
||
where.append("(d.货品编号 LIKE %s OR d.货品名称 LIKE %s)")
|
||
params.extend([f"%{search}%", f"%{search}%"])
|
||
if date_from:
|
||
where.append("d.日期 >= %s")
|
||
params.append(date_from)
|
||
if date_to:
|
||
where.append("d.日期 <= %s")
|
||
params.append(date_to)
|
||
|
||
where_clause = " AND ".join(where)
|
||
|
||
trend = query(f"""
|
||
SELECT d.日期, SUM(d.货品数量) AS day_qty
|
||
FROM daily_outbound d
|
||
LEFT JOIN products p ON p.product_code = d.货品编号
|
||
WHERE {where_clause}
|
||
GROUP BY d.日期
|
||
ORDER BY d.日期 ASC
|
||
""", params)
|
||
|
||
for row in trend:
|
||
if '日期' in row and row['日期']:
|
||
row['日期'] = row['日期'].strftime('%Y-%m-%d') if hasattr(row['日期'], 'strftime') else str(row['日期'])[:10]
|
||
row['day_qty'] = int(row['day_qty']) if row['day_qty'] else 0
|
||
|
||
total_qty = sum(row['day_qty'] for row in trend)
|
||
return jsonify({
|
||
"code": 0,
|
||
"data": {
|
||
"total_qty": total_qty,
|
||
"product_code": product_code,
|
||
"trend": trend
|
||
}
|
||
})
|
||
|
||
|
||
@data_bp.route('/api/data/sku-detail')
|
||
@login_required
|
||
def outbound_sku_detail():
|
||
"""查看某个SKU在某天的出库明细(按仓库+店铺展开)"""
|
||
product_code = request.args.get('product_code', '')
|
||
date_str = request.args.get('date', '')
|
||
|
||
if not product_code or not date_str:
|
||
return jsonify({"code": 1, "msg": "缺少参数"})
|
||
|
||
rows = query("""
|
||
SELECT 仓库, 店铺, 数据来源,
|
||
SUM(货品数量) AS qty,
|
||
ROUND(SUM(货品成本), 2) AS cost
|
||
FROM daily_outbound
|
||
WHERE 货品编号 = %s AND 日期 = %s
|
||
GROUP BY 仓库, 店铺, 数据来源
|
||
ORDER BY qty DESC
|
||
""", (product_code, date_str))
|
||
|
||
for row in rows:
|
||
row['qty'] = int(row['qty']) if row['qty'] else 0
|
||
row['cost'] = float(row['cost']) if row['cost'] else 0
|
||
|
||
return jsonify({
|
||
"code": 0,
|
||
"data": {
|
||
"product_code": product_code,
|
||
"date": date_str,
|
||
"details": rows
|
||
}
|
||
})
|
||
|
||
|
||
@data_bp.route('/api/data/search_filters')
|
||
@login_required
|
||
def data_search_filters():
|
||
field = request.args.get('field', '')
|
||
q = request.args.get('q', '')
|
||
if not field or not q:
|
||
return jsonify({"code": 0, "data": []})
|
||
col_map = {'warehouse': '仓库', 'shop': '店铺', 'brand': '品牌'}
|
||
col = col_map.get(field, field)
|
||
kw = f'%{q}%'
|
||
rows = query(f"SELECT DISTINCT `{col}` FROM daily_outbound WHERE `{col}` LIKE %s AND `{col}` IS NOT NULL AND `{col}` != '' ORDER BY `{col}` LIMIT 20", [kw])
|
||
return jsonify({"code": 0, "data": [r[col] for r in rows]})
|
||
|
||
|
||
# ─── API: 出库明细导入 ───
|
||
OUTBOUND_IMPORT_HEADERS = [
|
||
'日期', '货品编号', '货品名称', '品牌', '仓库', '店铺',
|
||
'一级类目', '二级类目', '三级类目', '货品数量', '货品成本', '数据来源',
|
||
]
|
||
OUTBOUND_IMPORT_SAMPLE = [
|
||
'2026-09-15', 'POZ-XFS-7645-TM', '示例:泊紫汀兰青少年去屑止痒洗发水', '泊紫汀兰',
|
||
'1蝶雅广州新仓(蝶雅自营)', '抖音-泊紫汀兰Pozitilan母婴旗舰店',
|
||
'个人护理', '', '', '6', '44.0100', 'wdt',
|
||
]
|
||
|
||
|
||
def _resolve_outbound_source(val):
|
||
"""数据来源支持填写编码(wdt)或中文名(旺店通),统一存编码。"""
|
||
from routes.inventory import _import_text
|
||
s = _import_text(val)
|
||
if not s:
|
||
return 'wdt'
|
||
if s in SOURCE_MAP_CN:
|
||
return s
|
||
for code, name in SOURCE_MAP_CN.items():
|
||
if s == name:
|
||
return code
|
||
return s
|
||
|
||
|
||
@data_bp.route('/api/data/import_template')
|
||
@login_required
|
||
def data_import_template():
|
||
"""下载出库明细导入模板 CSV"""
|
||
if not operation_required('data_detail', 'import'):
|
||
return jsonify({"code": 403, "msg": "无导入权限"})
|
||
import csv
|
||
import io
|
||
output = io.StringIO()
|
||
writer = csv.writer(output)
|
||
writer.writerow(OUTBOUND_IMPORT_HEADERS)
|
||
writer.writerow(OUTBOUND_IMPORT_SAMPLE)
|
||
csv_data = output.getvalue()
|
||
output.close()
|
||
return Response(
|
||
csv_data,
|
||
mimetype='text/csv',
|
||
headers={'Content-Disposition': 'attachment; filename=outbound_import_template.csv'}
|
||
)
|
||
|
||
|
||
@data_bp.route('/api/data/import', methods=['POST'])
|
||
@login_required
|
||
def data_import():
|
||
"""导入出库明细 CSV:同「日期+数据来源+货品编号+仓库+店铺」先删后插,避免重复。"""
|
||
if not operation_required('data_detail', 'import'):
|
||
return jsonify({"code": 403, "msg": "无导入权限"})
|
||
|
||
if 'file' not in request.files:
|
||
return jsonify({"code": 400, "msg": "未上传文件"})
|
||
f = request.files['file']
|
||
if not f.filename:
|
||
return jsonify({"code": 400, "msg": "未选择文件"})
|
||
|
||
from routes.inventory import (
|
||
_parse_import_rows, _normalize_import_date, _import_int, _import_float,
|
||
_import_text, _is_invalid_product_code,
|
||
)
|
||
|
||
headers, data_rows = _parse_import_rows(f.read())
|
||
if headers is None:
|
||
return jsonify({"code": 400, "msg": "CSV文件至少需要表头+1行数据,请确认编码与格式正确"})
|
||
|
||
matched = sum(1 for h in OUTBOUND_IMPORT_HEADERS if h in headers)
|
||
if matched < 2:
|
||
return jsonify({
|
||
"code": 400,
|
||
"msg": "CSV列名不匹配\n预期列名:%s\n实际列名:%s" % (','.join(OUTBOUND_IMPORT_HEADERS), ','.join(headers))
|
||
})
|
||
|
||
def cell(row, name):
|
||
idx = headers.index(name) if name in headers else -1
|
||
return row[idx] if 0 <= idx < len(row) else ''
|
||
|
||
records = []
|
||
errors = []
|
||
for i, row in enumerate(data_rows, start=2):
|
||
code = _import_text(cell(row, '货品编号'))
|
||
if _is_invalid_product_code(code):
|
||
continue
|
||
date_val = _normalize_import_date(cell(row, '日期'))
|
||
if not date_val:
|
||
errors.append('第%d行:日期无法识别(需 YYYY-MM-DD)' % i)
|
||
continue
|
||
source = _resolve_outbound_source(cell(row, '数据来源'))
|
||
records.append((
|
||
code, _import_text(cell(row, '货品名称')), _import_text(cell(row, '品牌')),
|
||
_import_text(cell(row, '仓库')), _import_text(cell(row, '店铺')),
|
||
_import_text(cell(row, '一级类目')), _import_text(cell(row, '二级类目')),
|
||
_import_text(cell(row, '三级类目')), _import_int(cell(row, '货品数量')),
|
||
_import_float(cell(row, '货品成本')), date_val, source,
|
||
))
|
||
|
||
if not records:
|
||
msg = '没有可导入的数据' + (':' + ';'.join(errors[:5]) if errors else '')
|
||
return jsonify({"code": 400, "msg": msg})
|
||
|
||
from services.db import get_db
|
||
conn = get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
for rec in records:
|
||
cur.execute(
|
||
"DELETE FROM daily_outbound WHERE 日期=%s AND 数据来源=%s AND 货品编号=%s "
|
||
"AND 仓库=%s AND 店铺=%s",
|
||
(rec[10], rec[11], rec[0], rec[3], rec[4])
|
||
)
|
||
cur.executemany(
|
||
"INSERT INTO daily_outbound "
|
||
"(货品编号, 货品名称, 品牌, 仓库, 店铺, 一级类目, 二级类目, 三级类目, "
|
||
" 货品数量, 货品成本, 日期, 数据来源, 数据类型) "
|
||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'出库数据')",
|
||
records
|
||
)
|
||
conn.commit()
|
||
except Exception as e:
|
||
conn.rollback()
|
||
return jsonify({"code": 500, "msg": "导入失败:%s" % e})
|
||
finally:
|
||
conn.close()
|
||
|
||
try:
|
||
from services.cache import refresh_all_caches
|
||
refresh_all_caches()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
from services.db import add_operation_log
|
||
add_operation_log('出库明细导入', '导入出库明细 %d 条(跳过%d条)' % (len(records), len(errors)),
|
||
page='data_detail')
|
||
except Exception:
|
||
pass
|
||
|
||
msg = '✅ 成功导入 %d 条' % len(records)
|
||
if errors:
|
||
msg += ',%d 行未导入:%s' % (len(errors), ';'.join(errors[:5]))
|
||
if len(errors) > 5:
|
||
msg += ' 等'
|
||
return jsonify({"code": 0, "msg": msg, "imported": len(records), "failed": len(errors)})
|