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

1423 lines
71 KiB
Python
Raw 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.

"""库存路由"""
from datetime import date, datetime, timedelta
import io
import json
import os
import re
import secrets
import zipfile
from xml.etree import ElementTree as ET
from decimal import Decimal
from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template, Response, send_file
from services.db import query, query_iter, get_db
from routes.auth import login_required, role_required, operation_required
from services.field_perms import filter_rows as _filter_field_rows
inventory_bp = Blueprint('inventory', __name__)
STAGNANT_UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads', 'stagnant_inventory')
def _ensure_stagnant_schema():
os.makedirs(STAGNANT_UPLOAD_FOLDER, exist_ok=True)
query("""CREATE TABLE IF NOT EXISTS stagnant_inventory_detail (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
sheet_name VARCHAR(100) NOT NULL,
product_property VARCHAR(20) NOT NULL,
product_code VARCHAR(255) DEFAULT '',
product_name VARCHAR(500) DEFAULT '',
image_path VARCHAR(500) DEFAULT '',
stock_qty DECIMAL(20,4) DEFAULT 0,
unit_cost DECIMAL(20,6) DEFAULT 0,
total_cost DECIMAL(20,6) DEFAULT 0,
cleared_qty DECIMAL(20,4) DEFAULT 0,
batch VARCHAR(100) DEFAULT '第六批',
data_json JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_stagnant_property (product_property),
KEY idx_stagnant_code (product_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
query("""CREATE TABLE IF NOT EXISTS stagnant_inventory_fields (
id INT AUTO_INCREMENT PRIMARY KEY,
field_name VARCHAR(255) NOT NULL UNIQUE,
sort_order INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
query("""CREATE TABLE IF NOT EXISTS stagnant_inventory_snapshots (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
snapshot_month CHAR(7) NOT NULL,
product_key VARCHAR(700) NOT NULL,
data_json JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_stagnant_snapshot_product (snapshot_month, product_key),
KEY idx_stagnant_snapshot_month (snapshot_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
try:
query("ALTER TABLE stagnant_inventory_detail ADD COLUMN batch VARCHAR(100) DEFAULT '第六批'")
except Exception:
pass
query("UPDATE stagnant_inventory_detail SET batch='第六批' WHERE batch IS NULL OR batch=''")
def capture_stagnant_month_snapshot(snapshot_date=None, force=False):
"""在自然月最后一天固化明细;force 仅供月末任务和测试使用。"""
_ensure_stagnant_schema()
snapshot_date = snapshot_date or date.today()
next_day = snapshot_date + timedelta(days=1)
if not force and next_day.month == snapshot_date.month:
return {'captured': False, 'reason': 'not_month_end', 'month': snapshot_date.strftime('%Y-%m')}
month = snapshot_date.strftime('%Y-%m')
if query('SELECT id FROM stagnant_inventory_snapshots WHERE snapshot_month=%s LIMIT 1', (month,)):
return {'captured': False, 'reason': 'already_exists', 'month': month}
rows = query('SELECT product_code, product_name, data_json FROM stagnant_inventory_detail ORDER BY id')
for row in rows:
key = str(row.get('product_code') or '') + '||' + str(row.get('product_name') or '')
query('INSERT INTO stagnant_inventory_snapshots (snapshot_month,product_key,data_json) VALUES (%s,%s,%s)',
(month, key, row.get('data_json') or '{}'))
return {'captured': True, 'month': month, 'rows': len(rows)}
def _stagnant_month_metrics(source_rows):
"""返回当前清单相对上月快照的状态和月清率。"""
months = query('SELECT DISTINCT snapshot_month FROM stagnant_inventory_snapshots ORDER BY snapshot_month DESC')
current_month = date.today().strftime('%Y-%m')
previous = next((row['snapshot_month'] for row in months if row['snapshot_month'] < current_month), None)
old = query('SELECT product_key,data_json FROM stagnant_inventory_snapshots WHERE snapshot_month=%s',
(previous,)) if previous else []
old_active = {r['product_key'] for r in old if _stagnant_json_stock(r.get('data_json')) > 0}
current_stock = {}
for row in source_rows:
key = str(row.get('product_code') or '') + '||' + str(row.get('product_name') or '')
current_stock[key] = _stagnant_json_stock(row.get('data_json'))
from services.stagnant_lists import classify_month
return classify_month(current_stock, old_active)
def _stagnant_json_stock(raw):
if isinstance(raw, str):
try: raw = json.loads(raw or '{}')
except (TypeError, ValueError): raw = {}
return _stagnant_number((raw or {}).get('合计库存'))
def _ensure_stagnant_plan_schema():
query("""CREATE TABLE IF NOT EXISTS stagnant_inventory_plans (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
plan_no VARCHAR(40) NOT NULL UNIQUE,
plan_name VARCHAR(200) NOT NULL,
batch VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
owner_name VARCHAR(100) DEFAULT '',
plan_date DATE NULL,
note TEXT,
created_by BIGINT NULL,
created_by_name VARCHAR(100) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_stagnant_plan_batch (batch),
KEY idx_stagnant_plan_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
query("""CREATE TABLE IF NOT EXISTS stagnant_inventory_plan_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
plan_id BIGINT NOT NULL,
source_detail_id BIGINT NULL,
product_property VARCHAR(20) DEFAULT '',
product_code VARCHAR(255) DEFAULT '',
product_name VARCHAR(500) DEFAULT '',
responsible_unit VARCHAR(200) DEFAULT '',
original_qty DECIMAL(20,4) DEFAULT 0,
original_amount DECIMAL(20,6) DEFAULT 0,
cleared_snapshot DECIMAL(20,4) DEFAULT 0,
strategy_type VARCHAR(100) DEFAULT '',
planned_qty DECIMAL(20,4) DEFAULT 0,
target_price DECIMAL(20,6) DEFAULT 0,
target_date DATE NULL,
actual_qty DECIMAL(20,4) DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
note TEXT,
updated_by BIGINT NULL,
updated_by_name VARCHAR(100) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_stagnant_plan_source (plan_id, source_detail_id),
KEY idx_stagnant_plan_items_plan (plan_id),
CONSTRAINT fk_stagnant_plan_items_plan FOREIGN KEY (plan_id) REFERENCES stagnant_inventory_plans(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
query("""CREATE TABLE IF NOT EXISTS stagnant_inventory_plan_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
plan_id BIGINT NOT NULL,
item_id BIGINT NULL,
action VARCHAR(40) NOT NULL,
actor_id BIGINT NULL,
actor_name VARCHAR(100) DEFAULT '',
old_values JSON NULL,
new_values JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_stagnant_plan_logs (plan_id,created_at),
CONSTRAINT fk_stagnant_plan_logs_plan FOREIGN KEY (plan_id) REFERENCES stagnant_inventory_plans(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
def _stagnant_plan_permission(operation):
if not operation_required('stagnant_inventory_plans', operation):
return jsonify({'code': 403, 'msg': '没有方案管理权限'}), 403
return None
def _stagnant_plan_log(plan_id, action, item_id=None, old_values=None, new_values=None):
query("""INSERT INTO stagnant_inventory_plan_logs
(plan_id,item_id,action,actor_id,actor_name,old_values,new_values)
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
(plan_id,item_id,action,session.get('user_id'),str(session.get('username') or '')[:100],
json.dumps(old_values,ensure_ascii=False,default=str) if old_values is not None else None,
json.dumps(new_values,ensure_ascii=False,default=str) if new_values is not None else None))
def _stagnant_number(value):
try:
if value in (None, ''):
return 0.0
return float(str(value).replace(',', '').strip())
except (TypeError, ValueError):
return 0.0
def _parse_stagnant_workbook(raw):
"""读取两个业务 Sheet,并将 WPS DISPIMG 图片映射为媒体文件。"""
import openpyxl
workbook = openpyxl.load_workbook(io.BytesIO(raw), data_only=False)
value_workbook = openpyxl.load_workbook(io.BytesIO(raw), data_only=True)
business = [ws for ws in workbook.worksheets if ws.title != 'WpsReserved_CellImgList'][:2]
rows = []
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
ns = {'e':'http://www.wps.cn/officeDocument/2017/etCustomData', 'xdr':'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing', 'r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'a':'http://schemas.openxmlformats.org/drawingml/2006/main', 'rel':'http://schemas.openxmlformats.org/package/2006/relationships'}
image_map = {}
try:
root = ET.fromstring(archive.read('xl/cellimages.xml'))
rel_root = ET.fromstring(archive.read('xl/_rels/cellimages.xml.rels'))
rel_map = {x.attrib['Id']: x.attrib['Target'] for x in rel_root}
for cell_image in root.findall('e:cellImage', ns):
name = cell_image.find('.//xdr:cNvPr', ns)
blip = cell_image.find('.//a:blip', ns)
if name is not None and blip is not None:
target = rel_map.get(blip.attrib.get('{%s}embed' % ns['r']))
if target:
image_map[name.attrib.get('name')] = target.replace('xl/', '')
except (KeyError, ET.ParseError):
image_map = {}
for sheet_index, ws in enumerate(business):
value_ws = value_workbook[ws.title]
headers = [str(ws.cell(1, c).value or '').strip() for c in range(1, ws.max_column + 1)]
headers = [h or ('字段' + str(i + 1)) for i, h in enumerate(headers)]
property_name = '国内品' if sheet_index == 0 else '海外品'
for row_no in range(2, ws.max_row + 1):
values = {}
for c in range(1, ws.max_column + 1):
raw_value = ws.cell(row_no, c).value
if isinstance(raw_value, str) and raw_value.startswith('='):
cached_value = value_ws.cell(row_no, c).value
raw_value = cached_value if cached_value is not None else raw_value
values[headers[c - 1]] = raw_value
if not any(v is not None and str(v).strip() for v in values.values()):
continue
image_path = ''
image_value = str(values.get('图片') or '')
match = re.search(r'DISPIMG\("([^"]+)"', image_value)
if match and match.group(1) in image_map:
target = image_map[match.group(1)]
try:
ext = os.path.splitext(target)[1].lower() or '.jpg'
safe_code = re.sub(r'[^A-Za-z0-9_.-]+', '_', str(values.get('货品编码') or 'row_%s' % row_no))
filename = '%s_%s%s' % (property_name, safe_code, ext)
dest = os.path.join(STAGNANT_UPLOAD_FOLDER, filename)
with open(dest, 'wb') as output:
output.write(archive.read('xl/' + target))
image_path = '/static/uploads/stagnant_inventory/' + filename
except KeyError:
pass
rows.append({'sheet_name': ws.title, 'product_property': property_name,
'batch': str(values.get('批次') or '第六批').strip(),
'product_code': str(values.get('货品编码') or '').strip(),
'product_name': str(values.get('货品名称') or '').strip(),
'image_path': image_path,
'stock_qty': _stagnant_number(values.get('合计库存') or values.get('总成品库存')),
'unit_cost': _stagnant_number(values.get('单价成本')),
'total_cost': _stagnant_number(values.get('总成本')),
'cleared_qty': _stagnant_number(values.get('08.29-09.27出库汇总')),
'data_json': json.dumps(values, ensure_ascii=False, default=str)})
return rows
def _stagnant_workbook_fields(raw):
import openpyxl
workbook = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=False)
sheets = [ws for ws in workbook.worksheets if ws.title != 'WpsReserved_CellImgList'][:2]
fields = []
for ws in sheets:
for index, cell in enumerate(ws[1], 1):
label = str(cell.value or '').strip() or ('字段' + str(index))
if label not in fields:
fields.append(label)
return _sort_stagnant_fields(fields)
def _sort_stagnant_fields(fields):
fields = list(fields)
outbound = '08.29-09.27出库汇总'
warehouses, qianyu, other = [], [], []
for field in fields:
match = re.match(r'\s*0*(\d+)', field)
if match and '仓' in field:
number = int(match.group(1))
if number == 35 and '千羽' in field:
qianyu.append(field)
else:
warehouses.append((number, field))
else:
other.append(field)
warehouses.sort(key=lambda item: item[0])
preferred = [
'产品属性', '产品类型', '货品编码', '货品名称', '图片', '组员', '组长', '主管', '平台',
'建档时间', '建档天数', '单价成本', '总成本', '总成品库存', '合计库存', '可支撑天数',
'生产在途量', '采购在途', '是否已盘点', '过期时间'
]
ordered = [field for field in preferred if field in other]
ordered.extend(field for field in other if field not in ordered and field != outbound)
if outbound in fields:
ordered.append(outbound)
return ordered + [field for _, field in warehouses] + qianyu
def _export_cell(value):
"""导出单元格取值:把 Decimal/时间差等转成 openpyxl 可安全写入的类型,避免类型绑定报错。"""
if value is None:
return ''
if isinstance(value, Decimal):
return float(value)
if isinstance(value, timedelta):
return str(value)
if isinstance(value, (str, int, float, bool, date, datetime)):
return value
return str(value)
@inventory_bp.route('/inventory')
@inventory_bp.route('/inventory/finished')
@inventory_bp.route('/inventory/packaging')
@login_required
def inventory_page():
sub = request.path.split('/')[-1]
page = 'inventory_finished' if sub == 'finished' else 'inventory_packaging' if sub == 'packaging' else 'inventory'
if not role_required(page):
return redirect(url_for('auth.index'))
return render_template('base.html', page=page)
# ─── API: 库存明细 ───
@inventory_bp.route('/api/inventory/list')
@login_required
def inventory_list():
page = int(request.args.get('page', 1))
size = int(request.args.get('size', 50))
offset = (page - 1) * size
prod_code_filter = request.args.get('prod_code', '')
prod_name_filter = request.args.get('prod_name', '')
search = request.args.get('search', '')
brand_filter = request.args.get('brand', '')
category1_filter = request.args.get('category1', '')
stock_only = request.args.get('stock_only', '0')
date_filter = request.args.get('date', '')
warehouse_filter = request.args.get('warehouse', '')
product_type_filter = request.args.get('product_type', '')
where = []
where_detail = []
params = []
if not date_filter:
last_date = query("SELECT MAX(data_date) AS md FROM inventory_detail")[0]['md']
if last_date:
date_filter = str(last_date)
if date_filter:
where.append("data_date=%s")
where_detail.append("d.data_date=%s")
params.append(date_filter)
if warehouse_filter:
where.append("(warehouse_code=%s OR warehouse_name LIKE %s)")
where_detail.append("(d.warehouse_code=%s OR w.warehouse_name LIKE %s)")
params.extend([warehouse_filter, f"%{warehouse_filter}%"])
if prod_code_filter:
cond = "(product_code LIKE %s OR goods_code LIKE %s)"
where.append(cond)
where_detail.append(cond.replace("product", "d.product").replace("goods_code", "d.goods_code"))
params.extend([f"%{prod_code_filter}%", f"%{prod_code_filter}%"])
if prod_name_filter:
cond = "product_name LIKE %s"
where.append(cond)
where_detail.append(cond.replace("product", "d.product"))
params.append(f"%{prod_name_filter}%")
if search:
cond = "(product_name LIKE %s OR product_code LIKE %s OR goods_code LIKE %s)"
where.append(cond)
where_detail.append(cond.replace("product", "d.product").replace("goods_code", "d.goods_code"))
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if brand_filter:
where.append("brand=%s")
where_detail.append("d.brand=%s")
params.append(brand_filter)
if category1_filter:
where.append("category1=%s")
where_detail.append("d.category1=%s")
params.append(category1_filter)
if stock_only == '1':
where.append("available_qty > 0")
where_detail.append("d.available_qty > 0")
# 内料不进入任何库存分类或库存成本统计。
where_detail.append("d.product_code NOT LIKE %s AND d.product_name NOT LIKE %s")
params.extend(['%-NL%', '%内料%'])
# 库存数据独立于产品主数据;产品类型筛选仅用于分类,未匹配编码按仓库20号归包材,其余归成品。
join_type = "LEFT JOIN products p ON d.product_code = p.product_code"
if product_type_filter:
if product_type_filter == '成品':
where_detail.append("(p.product_type IN ('成品','清库存','现货','赠品') OR (p.product_code IS NULL AND (LPAD(d.warehouse_code, 2, '0') != '20' OR d.product_code IN ('POZ-WPH-0137','(AVA-YS-3252)','(UO-AJS-3362)','(WPK-CBZT-9707)','【UOUONE】LHJ0385'))))")
elif product_type_filter == '包材':
where_detail.append("((p.product_type = '包材' AND p.product_name NOT LIKE %s) OR (p.product_code IS NULL AND LPAD(d.warehouse_code, 2, '0') = '20' AND d.product_code NOT IN ('POZ-WPH-0137','(AVA-YS-3252)','(UO-AJS-3362)','(WPK-CBZT-9707)','【UOUONE】LHJ0385')))" )
params.append('%内料%')
else:
where_detail.append("p.product_type=%s")
params.append(product_type_filter)
where_detail_clause = " AND ".join(where_detail) if where_detail else "1=1"
# 始终 JOIN warehouses,供名称筛选和显示用
warehouse_join = "LEFT JOIN warehouses w ON LPAD(d.warehouse_code, 2, '0') = w.warehouse_code"
total = query(f"SELECT COUNT(*) AS cnt FROM inventory_detail d {join_type} {warehouse_join} WHERE {where_detail_clause}", params)[0]['cnt']
summary = query(f"""
SELECT COALESCE(SUM(d.available_qty), 0) AS available_qty,
COALESCE(SUM(d.production_in_transit), 0) AS production_in_transit,
COALESCE(SUM(d.purchase_in_transit), 0) AS purchase_in_transit,
COALESCE(SUM(
CASE WHEN LPAD(d.warehouse_code, 2, '0') = '15'
THEN d.available_qty * COALESCE(d.cost_price, 0)
ELSE 0 END
), 0) AS warehouse_15_finished_cost
FROM inventory_detail d
{join_type}
{warehouse_join}
WHERE {where_detail_clause}
""", params)[0]
# 获取采购跟单、采购计划、供应商信息
prod_fields = ", p.procurement_follower AS 采购跟单, p.procurement_planner AS 采购计划"
supp_join = ""
rows = query(f"""
SELECT d.id, d.data_date, d.source, d.warehouse_code, d.goods_code, d.product_code,
d.product_name, d.brand, d.cost_price, d.available_qty, d.production_in_transit,
d.purchase_in_transit, d.category1, d.category2, d.category3, d.created_time,
COALESCE(w.warehouse_name, CONCAT('仓库', d.warehouse_code)) AS warehouse_name{prod_fields},
COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商
FROM inventory_detail d
{join_type}
{warehouse_join}
{supp_join}
LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code
WHERE {where_detail_clause}
ORDER BY d.available_qty DESC, d.product_name ASC
LIMIT {size} OFFSET {offset}
""", params)
for row in rows:
if 'data_date' in row and row['data_date']:
row['data_date'] = row['data_date'].strftime('%Y-%m-%d') if hasattr(row['data_date'], 'strftime') else str(row['data_date'])[:10]
if 'created_time' in row and row['created_time']:
row['created_time'] = row['created_time'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_time'], 'strftime') else str(row['created_time'])[:19]
if 'cost_price' in row and row['cost_price'] is not None:
row['cost_price'] = float(row['cost_price'])
row['stock_value'] = round(row['cost_price'] * row['available_qty'], 2) if row['cost_price'] else 0
page_id = 'inventory_finished' if product_type_filter == '成品' else 'inventory_packaging' if product_type_filter == '包材' else 'inventory_finished'
_filter_field_rows(page_id, rows, session.get('user_id'), session.get('role'),
page_view=bool(role_required(page_id)),
page_edit=operation_required(page_id, 'edit_product'))
return jsonify({
"code": 0,
"data": {
"total": total,
"page": page,
"size": size,
"rows": rows,
"summary": {
"available_qty": float(summary.get('available_qty') or 0),
"production_in_transit": float(summary.get('production_in_transit') or 0),
"purchase_in_transit": float(summary.get('purchase_in_transit') or 0),
"warehouse_15_finished_cost": round(float(summary.get('warehouse_15_finished_cost') or 0), 2)
}
}
})
@inventory_bp.route('/api/inventory/filters')
@login_required
def inventory_filters():
brands = query("SELECT DISTINCT brand FROM inventory_detail WHERE brand IS NOT NULL AND brand != '' ORDER BY brand")
categories = query("SELECT DISTINCT category1 FROM inventory_detail WHERE category1 IS NOT NULL AND category1 != '' ORDER BY category1")
dates = query("SELECT DISTINCT data_date FROM inventory_detail ORDER BY data_date DESC")
warehouses = query("""
SELECT DISTINCT d.warehouse_code, COALESCE(w.warehouse_name, CONCAT('仓库', d.warehouse_code)) AS warehouse_name
FROM inventory_detail d
LEFT JOIN warehouses w ON LPAD(d.warehouse_code, 2, '0') = w.warehouse_code
ORDER BY d.warehouse_code
""")
latest_date_row = query("SELECT MAX(data_date) AS latest_date FROM inventory_detail")[0]
latest_date = latest_date_row['latest_date']
if hasattr(latest_date, 'strftime'):
latest_date = latest_date.strftime('%Y-%m-%d')
else:
latest_date = str(latest_date)[:10]
stats = query("""
SELECT
COUNT(DISTINCT product_code, product_name) AS total_products,
SUM(available_qty) AS total_available,
ROUND(SUM(available_qty * cost_price), 2) AS total_stock_value,
COUNT(DISTINCT CASE WHEN available_qty > 0 THEN CONCAT(product_code, '||', product_name) END) AS products_with_stock
FROM inventory_detail
WHERE data_date = %s
""", (latest_date,))[0]
return jsonify({
"code": 0,
"data": {
"brands": [b['brand'] for b in brands],
"categories": [c['category1'] for c in categories],
"dates": [d['data_date'].strftime('%Y-%m-%d') if hasattr(d['data_date'], 'strftime') else str(d['data_date'])[:10] for d in dates],
"warehouses": [{'code': w['warehouse_code'], 'name': w['warehouse_name']} for w in warehouses],
"stats": stats,
"latest_date": latest_date
}
})
@inventory_bp.route('/api/stagnant-inventory/dashboard')
@login_required
def stagnant_inventory_dashboard():
"""清库存看板指标:仅统计产品类型为“清库存”的商品。
国内/海外口径来自品牌信息管理的分类;已清件数取日出库表中的历史累计出库量。
"""
_ensure_stagnant_schema()
batch = (request.args.get('batch') or '').strip()
where_sql = ' WHERE batch=%s' if batch else ''
where_params = (batch,) if batch else ()
totals = query("""SELECT product_property,
COUNT(*) AS products, COALESCE(SUM(stock_qty),0) AS stock_qty,
COALESCE(SUM(total_cost),0) AS amount, COALESCE(SUM(cleared_qty),0) AS cleared_qty
FROM stagnant_inventory_detail""" + where_sql + " GROUP BY product_property", where_params)
platform_rows = query("""SELECT COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(data_json, '$."平台"')), 'null'), '') AS platform,
COUNT(*) AS products, COALESCE(SUM(stock_qty),0) AS stock_qty,
COALESCE(SUM(total_cost),0) AS amount, COALESCE(SUM(cleared_qty),0) AS cleared_qty
FROM stagnant_inventory_detail""" + where_sql + " GROUP BY platform ORDER BY platform", where_params)
batch_rows = query("SELECT DISTINCT batch FROM stagnant_inventory_detail WHERE batch IS NOT NULL AND batch<>'' ORDER BY batch")
if not totals:
return jsonify({'code': 0, 'data': {'latest_date': '', 'batch': batch, 'batch_values': [r['batch'] for r in batch_rows], 'platform_summary': [], 'metrics': {
'amount': {'total': 0, 'domestic': 0, 'overseas': 0},
'products': {'total': 0, 'domestic': 0, 'overseas': 0},
'stock_qty': {'total': 0, 'domestic': 0, 'overseas': 0},
'cleared_qty': {'total': 0, 'domestic': 0, 'overseas': 0},
}}})
metrics = {name: {'total': 0.0, 'domestic': 0.0, 'overseas': 0.0}
for name in ('amount', 'products', 'stock_qty', 'cleared_qty')}
for row in totals:
market = 'overseas' if row.get('product_property') == '海外品' else 'domestic'
metrics['amount'][market] += float(row.get('amount') or 0)
metrics['stock_qty'][market] += float(row.get('stock_qty') or 0)
metrics['products'][market] += int(row.get('products') or 0)
metrics['cleared_qty'][market] += float(row.get('cleared_qty') or 0)
for name in metrics:
metrics[name]['total'] = metrics[name]['domestic'] + metrics[name]['overseas']
for key in metrics[name]:
if name == 'products':
metrics[name][key] = int(round(metrics[name][key]))
else:
metrics[name][key] = round(metrics[name][key], 0)
platform_summary = []
for row in platform_rows:
stock = float(row.get('stock_qty') or 0)
cleared = float(row.get('cleared_qty') or 0)
platform_summary.append({
'responsible_unit': row.get('platform') or '未填写',
'products': int(row.get('products') or 0),
'stock_qty': round(stock, 0),
'amount': round(float(row.get('amount') or 0), 0),
'cleared_qty': round(cleared, 0),
'ratio': round(cleared / stock * 100, 2) if stock else 0
})
latest_row = query("SELECT MAX(created_at) AS imported_at FROM stagnant_inventory_detail" + where_sql, where_params)[0]
latest_text = latest_row.get('imported_at').strftime('%Y-%m-%d %H:%M:%S') if latest_row.get('imported_at') and hasattr(latest_row.get('imported_at'), 'strftime') else ''
return jsonify({'code': 0, 'data': {'latest_date': latest_text, 'batch': batch, 'batch_values': [r['batch'] for r in batch_rows], 'platform_summary': platform_summary, 'metrics': metrics}})
def _stagnant_plan_row(row):
for key in ('original_qty', 'original_amount', 'cleared_snapshot', 'planned_qty', 'target_price', 'actual_qty'):
if row.get(key) is not None:
row[key] = float(row[key])
for key in ('created_at', 'updated_at', 'plan_date', 'target_date'):
value = row.get(key)
if value and hasattr(value, 'strftime'):
row[key] = value.strftime('%Y-%m-%d' if key.endswith('_date') else '%Y-%m-%d %H:%M:%S')
return row
@inventory_bp.route('/api/stagnant-inventory/plans')
@login_required
def stagnant_inventory_plans_list():
denied = _stagnant_plan_permission('view')
if denied: return denied
_ensure_stagnant_plan_schema()
where, params = [], []
for arg, column in (('batch', 'p.batch'), ('status', 'p.status')):
value = (request.args.get(arg) or '').strip()
if value:
where.append(column + '=%s'); params.append(value)
search = (request.args.get('search') or '').strip()
if search:
where.append('(p.plan_no LIKE %s OR p.plan_name LIKE %s OR p.owner_name LIKE %s)')
params.extend(['%' + search + '%'] * 3)
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
rows = query("""SELECT p.id,p.plan_no,p.plan_name,p.batch,p.status,p.owner_name,p.plan_date,p.note,
p.created_by_name,p.created_at,p.updated_at,COUNT(i.id) AS item_count,
COALESCE(SUM(i.planned_qty),0) AS planned_qty,COALESCE(SUM(i.actual_qty),0) AS actual_qty
FROM stagnant_inventory_plans p LEFT JOIN stagnant_inventory_plan_items i ON i.plan_id=p.id"""
+ where_sql + ' GROUP BY p.id ORDER BY p.updated_at DESC,p.id DESC', tuple(params))
return jsonify({'code': 0, 'data': [_stagnant_plan_row(row) for row in rows]})
@inventory_bp.route('/api/stagnant-inventory/plans/candidates')
@login_required
def stagnant_inventory_plan_candidates():
denied = _stagnant_plan_permission('view')
if denied: return denied
_ensure_stagnant_plan_schema()
batch = (request.args.get('batch') or '').strip()
if not batch: return jsonify({'code': 400, 'msg': '请选择批次'}), 400
page = max(1, int(request.args.get('page', 1) or 1))
size = min(100, max(1, int(request.args.get('size', 50) or 50)))
search = (request.args.get('search') or '').strip()
params = [batch]
clause = 'batch=%s AND product_code<>%s AND product_name<>%s'
params.extend(['', ''])
if search:
clause += " AND (product_code LIKE %s OR product_name LIKE %s OR JSON_UNQUOTE(JSON_EXTRACT(data_json,%s)) LIKE %s OR JSON_UNQUOTE(JSON_EXTRACT(data_json,%s)) LIKE %s)"
params.extend(['%' + search + '%', '%' + search + '%', '$.平台', '%' + search + '%', '$.产品类型', '%' + search + '%'])
total = query('SELECT COUNT(*) AS n FROM stagnant_inventory_detail WHERE ' + clause, tuple(params))[0]['n']
rows = query("""SELECT id,product_property,product_code,product_name,stock_qty,total_cost,cleared_qty,
JSON_UNQUOTE(JSON_EXTRACT(data_json,%s)) AS responsible_unit,
JSON_UNQUOTE(JSON_EXTRACT(data_json,%s)) AS product_type
FROM stagnant_inventory_detail WHERE """ + clause + ' ORDER BY id LIMIT %s OFFSET %s',
tuple(['$.平台', '$.产品类型'] + params + [size, (page - 1) * size]))
for row in rows:
for key in ('stock_qty', 'total_cost', 'cleared_qty'):
row[key] = float(row.get(key) or 0)
row['responsible_unit'] = '' if row.get('responsible_unit') in (None, 'null') else row['responsible_unit']
row['product_type'] = '' if row.get('product_type') in (None, 'null') else row['product_type']
return jsonify({'code': 0, 'data': {'rows': rows, 'total': int(total), 'page': page, 'size': size}})
@inventory_bp.route('/api/stagnant-inventory/plans', methods=['POST'])
@login_required
def stagnant_inventory_plan_create():
denied = _stagnant_plan_permission('create')
if denied: return denied
_ensure_stagnant_plan_schema()
data = request.get_json(silent=True) or {}
name = str(data.get('plan_name') or '').strip()
batch = str(data.get('batch') or '').strip()
if not name or not batch:
return jsonify({'code': 400, 'msg': '方案名称和批次必填'}), 400
if not query('SELECT id FROM stagnant_inventory_detail WHERE batch=%s LIMIT 1', (batch,)):
return jsonify({'code': 400, 'msg': '所选批次不存在'}), 400
plan_date = data.get('plan_date') or None
if plan_date:
try: datetime.strptime(plan_date, '%Y-%m-%d')
except (TypeError, ValueError): return jsonify({'code': 400, 'msg': '方案日期格式应为 YYYY-MM-DD'}), 400
plan_no = 'QK' + datetime.now().strftime('%Y%m%d') + '-' + secrets.token_hex(3).upper()
plan_id = query("""INSERT INTO stagnant_inventory_plans
(plan_no,plan_name,batch,status,owner_name,plan_date,note,created_by,created_by_name)
VALUES (%s,%s,%s,'draft',%s,%s,%s,%s,%s)""",
(plan_no, name[:200], batch, str(data.get('owner_name') or '').strip()[:100], plan_date,
str(data.get('note') or ''), session.get('user_id'), str(session.get('username') or '')[:100]))
_stagnant_plan_log(plan_id, 'create', new_values={'plan_no': plan_no, 'plan_name': name[:200], 'batch': batch})
return jsonify({'code': 0, 'msg': '方案已创建', 'data': {'id': plan_id, 'plan_no': plan_no}})
@inventory_bp.route('/api/stagnant-inventory/plans/<int:plan_id>')
@login_required
def stagnant_inventory_plan_detail(plan_id):
denied = _stagnant_plan_permission('view')
if denied: return denied
_ensure_stagnant_plan_schema()
rows = query('SELECT * FROM stagnant_inventory_plans WHERE id=%s', (plan_id,))
if not rows: return jsonify({'code': 404, 'msg': '方案不存在'}), 404
plan = _stagnant_plan_row(rows[0])
items = query('SELECT * FROM stagnant_inventory_plan_items WHERE plan_id=%s ORDER BY id', (plan_id,))
return jsonify({'code': 0, 'data': {'plan': plan, 'items': [_stagnant_plan_row(row) for row in items]}})
@inventory_bp.route('/api/stagnant-inventory/plans/<int:plan_id>', methods=['PUT'])
@login_required
def stagnant_inventory_plan_update(plan_id):
denied = _stagnant_plan_permission('edit')
if denied: return denied
_ensure_stagnant_plan_schema()
data = request.get_json(silent=True) or {}
rows = query('SELECT status FROM stagnant_inventory_plans WHERE id=%s', (plan_id,))
if not rows: return jsonify({'code': 404, 'msg': '方案不存在'}), 404
if rows[0]['status'] in ('completed', 'archived'):
return jsonify({'code': 409, 'msg': '已完成或归档的方案不可编辑'}), 409
name = str(data.get('plan_name') or '').strip()
if not name: return jsonify({'code': 400, 'msg': '方案名称不能为空'}), 400
plan_date = data.get('plan_date') or None
if plan_date:
try: datetime.strptime(plan_date, '%Y-%m-%d')
except (TypeError, ValueError): return jsonify({'code': 400, 'msg': '方案日期格式应为 YYYY-MM-DD'}), 400
old_values = query('SELECT plan_name,owner_name,plan_date,note FROM stagnant_inventory_plans WHERE id=%s', (plan_id,))[0]
new_values = {'plan_name': name[:200], 'owner_name': str(data.get('owner_name') or '').strip()[:100], 'plan_date': plan_date, 'note': str(data.get('note') or '')}
query("UPDATE stagnant_inventory_plans SET plan_name=%s,owner_name=%s,plan_date=%s,note=%s WHERE id=%s",
(new_values['plan_name'], new_values['owner_name'], plan_date, new_values['note'], plan_id))
_stagnant_plan_log(plan_id, 'update_plan', old_values=old_values, new_values=new_values)
return jsonify({'code': 0, 'msg': '方案已保存'})
@inventory_bp.route('/api/stagnant-inventory/plans/<int:plan_id>/items', methods=['POST'])
@login_required
def stagnant_inventory_plan_add_items(plan_id):
denied = _stagnant_plan_permission('edit')
if denied: return denied
_ensure_stagnant_plan_schema()
data = request.get_json(silent=True) or {}
source_ids = data.get('source_ids')
if not isinstance(source_ids, list) or not source_ids or len(source_ids) > 500:
return jsonify({'code': 400, 'msg': '请选择 1-500 条商品'}), 400
plan_rows = query('SELECT batch,status FROM stagnant_inventory_plans WHERE id=%s', (plan_id,))
if not plan_rows: return jsonify({'code': 404, 'msg': '方案不存在'}), 404
if plan_rows[0]['status'] != 'draft': return jsonify({'code': 409, 'msg': '仅草稿方案可添加商品'}), 409
source_ids = list(dict.fromkeys(int(x) for x in source_ids if str(x).isdigit()))
if not source_ids: return jsonify({'code': 400, 'msg': '商品编号无效'}), 400
placeholders = ','.join(['%s'] * len(source_ids))
source_rows = query("""SELECT id,product_property,product_code,product_name,stock_qty,total_cost,cleared_qty,data_json,batch
FROM stagnant_inventory_detail WHERE id IN (""" + placeholders + ')', tuple(source_ids))
if len(source_rows) != len(source_ids) or any(r['batch'] != plan_rows[0]['batch'] for r in source_rows):
return jsonify({'code': 400, 'msg': '所选商品不属于方案批次'}), 400
conn = get_db()
added = 0
try:
with conn.cursor() as cur:
for row in source_rows:
if not row.get('product_code') or not row.get('product_name'): continue
raw = row.get('data_json') or '{}'
source = raw if isinstance(raw, dict) else json.loads(raw)
cur.execute("""INSERT IGNORE INTO stagnant_inventory_plan_items
(plan_id,source_detail_id,product_property,product_code,product_name,responsible_unit,
original_qty,original_amount,cleared_snapshot,planned_qty,target_price,target_date,actual_qty,status,note)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,NULL,0,'pending','')""",
(plan_id,row['id'],row['product_property'],row['product_code'],row['product_name'],
str(source.get('平台') or '').strip(),row['stock_qty'] or 0,row['total_cost'] or 0,row['cleared_qty'] or 0))
added += cur.rowcount
conn.commit()
except Exception:
conn.rollback(); raise
finally:
conn.close()
_stagnant_plan_log(plan_id, 'add_items', new_values={'source_ids': source_ids, 'added': added})
return jsonify({'code': 0, 'msg': '已加入方案', 'data': {'added': added}})
@inventory_bp.route('/api/stagnant-inventory/plans/items/<int:item_id>', methods=['PUT'])
@login_required
def stagnant_inventory_plan_item_update(item_id):
denied = _stagnant_plan_permission('edit')
if denied: return denied
_ensure_stagnant_plan_schema()
data = request.get_json(silent=True) or {}
rows = query("""SELECT i.plan_id,p.status AS plan_status FROM stagnant_inventory_plan_items i
JOIN stagnant_inventory_plans p ON p.id=i.plan_id WHERE i.id=%s""", (item_id,))
if not rows: return jsonify({'code': 404, 'msg': '方案商品不存在'}), 404
if rows[0]['plan_status'] in ('completed', 'archived'):
return jsonify({'code': 409, 'msg': '已完成或归档的方案不可编辑'}), 409
try:
planned_qty = max(0, float(data.get('planned_qty') or 0))
target_price = max(0, float(data.get('target_price') or 0))
actual_qty = max(0, float(data.get('actual_qty') or 0))
except (TypeError, ValueError): return jsonify({'code': 400, 'msg': '数量或价格格式错误'}), 400
target_date = data.get('target_date') or None
if target_date:
try: datetime.strptime(target_date, '%Y-%m-%d')
except (TypeError, ValueError): return jsonify({'code': 400, 'msg': '目标日期格式应为 YYYY-MM-DD'}), 400
status = str(data.get('status') or 'pending')
if status not in ('pending', 'planned', 'in_progress', 'done', 'paused'):
return jsonify({'code': 400, 'msg': '执行状态无效'}), 400
strategy = str(data.get('strategy_type') or '').strip()[:100]
responsible = str(data.get('responsible_unit') or '').strip()[:200]
note = str(data.get('note') or '')
old_values = query('SELECT strategy_type,planned_qty,target_price,target_date,actual_qty,status,responsible_unit,note FROM stagnant_inventory_plan_items WHERE id=%s', (item_id,))[0]
new_values = {'strategy_type':strategy,'planned_qty':planned_qty,'target_price':target_price,'target_date':target_date,
'actual_qty':actual_qty,'status':status,'responsible_unit':responsible,'note':note}
query("""UPDATE stagnant_inventory_plan_items SET strategy_type=%s,planned_qty=%s,target_price=%s,
target_date=%s,actual_qty=%s,status=%s,responsible_unit=%s,note=%s,updated_by=%s,updated_by_name=%s WHERE id=%s""",
(strategy,planned_qty,target_price,target_date,actual_qty,status,responsible,note,
session.get('user_id'),str(session.get('username') or '')[:100],item_id))
_stagnant_plan_log(rows[0]['plan_id'], 'update_item', item_id, old_values, new_values)
return jsonify({'code': 0, 'msg': '方案商品已保存'})
@inventory_bp.route('/api/stagnant-inventory/plans/<int:plan_id>/status', methods=['PUT'])
@login_required
def stagnant_inventory_plan_status(plan_id):
data = request.get_json(silent=True) or {}
status = str(data.get('status') or '')
if status == 'archived':
denied = _stagnant_plan_permission('delete')
else:
denied = _stagnant_plan_permission('edit')
if denied: return denied
_ensure_stagnant_plan_schema()
rows = query('SELECT status FROM stagnant_inventory_plans WHERE id=%s', (plan_id,))
if not rows: return jsonify({'code': 404, 'msg': '方案不存在'}), 404
transitions = {'draft': {'draft','executing','archived'}, 'executing': {'executing','completed','archived'},
'completed': {'completed','archived'}, 'archived': {'archived'}}
if status not in transitions.get(rows[0]['status'], set()):
return jsonify({'code': 400, 'msg': '不允许此方案状态变更'}), 400
query('UPDATE stagnant_inventory_plans SET status=%s WHERE id=%s', (status, plan_id))
_stagnant_plan_log(plan_id, 'status', old_values={'status':rows[0]['status']}, new_values={'status':status})
return jsonify({'code': 0, 'msg': '方案状态已更新'})
@inventory_bp.route('/api/stagnant-inventory/plans/items/<int:item_id>', methods=['DELETE'])
@login_required
def stagnant_inventory_plan_item_delete(item_id):
denied = _stagnant_plan_permission('edit')
if denied: return denied
_ensure_stagnant_plan_schema()
rows = query("""SELECT i.*,p.status AS plan_status FROM stagnant_inventory_plan_items i
JOIN stagnant_inventory_plans p ON p.id=i.plan_id WHERE i.id=%s""", (item_id,))
if not rows: return jsonify({'code': 404, 'msg': '方案商品不存在'}), 404
row = rows[0]
if row['plan_status'] != 'draft': return jsonify({'code': 409, 'msg': '仅草稿方案可移除商品'}), 409
old_values = {key: row.get(key) for key in ('product_code','product_name','responsible_unit','planned_qty','strategy_type')}
query('DELETE FROM stagnant_inventory_plan_items WHERE id=%s', (item_id,))
_stagnant_plan_log(row['plan_id'], 'remove_item', item_id, old_values, None)
return jsonify({'code': 0, 'msg': '已从方案移除商品'})
@inventory_bp.route('/api/stagnant-inventory/plans/<int:plan_id>/logs')
@login_required
def stagnant_inventory_plan_logs(plan_id):
denied = _stagnant_plan_permission('view')
if denied: return denied
_ensure_stagnant_plan_schema()
logs = query('SELECT action,actor_name,old_values,new_values,created_at FROM stagnant_inventory_plan_logs WHERE plan_id=%s ORDER BY id DESC LIMIT 100', (plan_id,))
for row in logs:
if row.get('created_at') and hasattr(row['created_at'],'strftime'): row['created_at']=row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
for key in ('old_values','new_values'):
if isinstance(row.get(key),str):
try: row[key]=json.loads(row[key])
except ValueError: pass
return jsonify({'code':0,'data':logs})
@inventory_bp.route('/api/stagnant-inventory/plans/<int:plan_id>/export')
@login_required
def stagnant_inventory_plan_export(plan_id):
denied = _stagnant_plan_permission('export')
if denied: return denied
_ensure_stagnant_plan_schema()
plans = query('SELECT plan_no,plan_name,batch,status,owner_name,plan_date,note FROM stagnant_inventory_plans WHERE id=%s', (plan_id,))
if not plans: return jsonify({'code': 404, 'msg': '方案不存在'}), 404
plan = plans[0]
rows = query('SELECT product_property,product_code,product_name,responsible_unit,original_qty,original_amount,cleared_snapshot,strategy_type,planned_qty,target_price,target_date,actual_qty,status,note FROM stagnant_inventory_plan_items WHERE plan_id=%s ORDER BY id', (plan_id,))
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.title = '方案明细'
sheet.append(['方案编号',plan['plan_no'],'方案名称',plan['plan_name'],'批次',plan['batch'],'状态',plan['status']])
headers = ['产品属性','货品编码','货品名称','责任单位','原库存数量','原库存金额','导入已清数量','处理方式','计划处理数量','目标售价','目标完成日期','实际清理数量','执行状态','备注']
sheet.append(headers)
for row in rows:
sheet.append([row.get('product_property'),row.get('product_code'),row.get('product_name'),row.get('responsible_unit'),
float(row.get('original_qty') or 0),float(row.get('original_amount') or 0),float(row.get('cleared_snapshot') or 0),
row.get('strategy_type'),float(row.get('planned_qty') or 0),float(row.get('target_price') or 0),
row.get('target_date'),float(row.get('actual_qty') or 0),row.get('status'),row.get('note')])
for cell in sheet[2]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='4B5563')
cell.alignment = Alignment(horizontal='center', vertical='center')
sheet.freeze_panes = 'A3'
sheet.auto_filter.ref = f'A2:N{max(2, sheet.max_row)}'
for column in sheet.columns:
letter = column[0].column_letter
width = min(36, max(12, max((len(str(cell.value or '')) for cell in column), default=0) + 2))
sheet.column_dimensions[letter].width = width
output = io.BytesIO()
workbook.save(output)
output.seek(0)
filename = (plan['plan_no'] or '清库存方案') + '.xlsx'
return send_file(output, as_attachment=True, download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@inventory_bp.route('/api/stagnant-inventory/lists')
@login_required
def stagnant_inventory_lists():
if not role_required('stagnant_inventory_lists'):
return jsonify({'code': 403, 'msg': '没有呆滞品清单管理权限'}), 403
from services.stagnant_lists import BASE_FIELDS, MONTHLY_FIELDS, warehouse_fields, build_list_rows
page = max(1, request.args.get('page', 1, type=int) or 1)
size = min(100, max(1, request.args.get('size', 20, type=int) or 20))
fields = [r['field_name'] for r in query(
'SELECT field_name FROM stagnant_inventory_fields ORDER BY sort_order,id')]
if not fields:
field_rows = query('SELECT data_json FROM stagnant_inventory_detail ORDER BY id LIMIT 1')
if field_rows:
fields = list(json.loads(field_rows[0].get('data_json') or '{}').keys())
warehouses = warehouse_fields(_sort_stagnant_fields(fields))
fields = BASE_FIELDS + warehouses + MONTHLY_FIELDS
sources = query('SELECT product_code,product_name,image_path,data_json FROM stagnant_inventory_detail ORDER BY id')
statuses, clear_rate = _stagnant_month_metrics(sources)
snapshot_rows = query('SELECT snapshot_month,product_key,data_json FROM stagnant_inventory_snapshots ORDER BY snapshot_month DESC')
snapshot_months = []
snapshot_map = {}
for item in snapshot_rows:
if item['snapshot_month'] not in snapshot_months:
snapshot_months.append(item['snapshot_month'])
snapshot_map[(item['snapshot_month'], item['product_key'])] = item
rows = build_list_rows(sources, fields)
all_snapshots_by_key = {}
for item in snapshot_rows:
all_snapshots_by_key.setdefault(item['product_key'], set()).add(item['snapshot_month'])
for row in rows:
key = str(row.get('货品编码') or '') + '||' + str(row.get('货品名称') or '')
row['状态分类'] = statuses.get(key, '新增呆滞')
if row['状态分类'] == '已清完':
for field in ('总成品库存', '合计库存', '总成本'):
if field in row: row[field] = 0
row['连续呆滞月数'] = 0
else:
months = all_snapshots_by_key.get(key, set())
count = 0
for month in snapshot_months:
snapshot = snapshot_map.get((month, key))
if month not in months or _stagnant_json_stock(snapshot.get('data_json')) <= 0: break
count += 1
current_month = date.today().strftime('%Y-%m')
row['连续呆滞月数'] = max(1, count + (0 if current_month in months else 1))
row['月清SKU率'] = clear_rate
search = (request.args.get('search') or '').strip().casefold()
if search:
rows = [row for row in rows if any(search in str(row.get(field) or '').casefold()
for field in ('货品编码', '货品名称', '确权组员', '确权组长', '确权主管', '平台'))]
total = len(rows)
page = min(page, max(1, (total + size - 1) // size))
return jsonify({'code': 0, 'data': {
'fields': fields, 'rows': rows[(page - 1) * size:page * size],
'warehouse_fields': warehouses,
'total': total, 'page': page, 'size': size,
'monthly_history_available': bool(snapshot_months),
'source_label': '呆滞品数据明细',
}})
@inventory_bp.route('/api/stagnant-inventory/detail')
@login_required
def stagnant_inventory_detail():
_ensure_stagnant_schema()
page = max(1, int(request.args.get('page', 1) or 1))
size = min(100, max(1, int(request.args.get('size', 20) or 20)))
all_rows = query("SELECT id, sheet_name, product_property, product_code, product_name, image_path, stock_qty, unit_cost, total_cost, cleared_qty, batch, data_json, created_at FROM stagnant_inventory_detail ORDER BY id")
try: filters = json.loads(request.args.get('filters') or '{}')
except (TypeError, ValueError): filters = {}
sort_field = request.args.get('sort_field') or ''
sort_order = request.args.get('sort_order') or ''
search = (request.args.get('search') or '').strip().casefold()
def field_value(row, field):
if field == '序号': return row.get('id') or 0
if field == '产品属性': return row.get('product_property') or ''
if field == '批次': return row.get('batch') or ''
if field == '货品编码': return row.get('product_code') or row.get('data', {}).get(field) or ''
if field == '货品名称': return row.get('product_name') or row.get('data', {}).get(field) or ''
if field == '图片': return row.get('image_path') or row.get('data', {}).get(field) or ''
value = row.get('data', {}).get(field, '')
return '' if value is None else value
for row in all_rows:
row['data'] = json.loads(row.pop('data_json') or '{}')
filter_values = {}
display_fields = [r['field_name'] for r in query("SELECT field_name FROM stagnant_inventory_fields ORDER BY sort_order, id")]
if not display_fields:
for row in all_rows:
for field in row.get('data', {}):
if field not in display_fields:
display_fields.append(field)
display_fields = _sort_stagnant_fields(display_fields)
display_fields = ['序号', '产品属性', '批次'] + [
field for field in display_fields if field not in ('序号', '产品属性', '批次')
]
for field in display_fields:
values = {str(field_value(row, field)).strip() for row in all_rows
if str(field_value(row, field)).strip().casefold() not in ('', 'none', 'null')}
if any(str(field_value(row, field)).strip().casefold() in ('', 'none', 'null') for row in all_rows):
values.add('__STAGNANT_BLANK__')
filter_values[field] = sorted(values, key=lambda value: (value == '__STAGNANT_BLANK__', value.casefold()))
if search:
search_fields = ('货品名称', '货品编码', '主管', '平台', '产品类型')
all_rows = [row for row in all_rows if any(search in str(field_value(row, field)).casefold() for field in search_fields)]
if filters:
all_rows = [row for row in all_rows if all(
(any(str(field_value(row, key)).strip().casefold() in ('', 'none', 'null') for v in (vals if isinstance(vals, list) else [vals]) if str(v) == '__STAGNANT_BLANK__')
or any(str(field_value(row, key)) == str(v) for v in (vals if isinstance(vals, list) else [vals]) if str(v) != '__STAGNANT_BLANK__'))
for key, vals in filters.items() if vals)]
if sort_field:
def sort_key(row):
value = field_value(row, sort_field)
try: return (0, float(str(value).replace(',', '')))
except (TypeError, ValueError): return (1, str(value).casefold())
all_rows.sort(key=sort_key, reverse=(sort_order == 'desc'))
total = len(all_rows)
rows = all_rows[(page - 1) * size:page * size]
for row in rows:
if row.get('created_at') and hasattr(row['created_at'], 'strftime'):
row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
fields = [r['field_name'] for r in query("SELECT field_name FROM stagnant_inventory_fields ORDER BY sort_order, id")]
fields = _sort_stagnant_fields(fields)
return jsonify({'code': 0, 'data': {'rows': rows, 'fields': display_fields, 'filter_values': filter_values, 'total': total, 'page': page, 'size': size}})
@inventory_bp.route('/api/stagnant-inventory/template')
@login_required
def stagnant_inventory_template():
"""下载与当前明细字段一致的导入模板。"""
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
_ensure_stagnant_schema()
fields = [r['field_name'] for r in query("SELECT field_name FROM stagnant_inventory_fields ORDER BY sort_order, id")]
fields = _sort_stagnant_fields(fields)
if not fields:
rows = query("SELECT data_json FROM stagnant_inventory_detail ORDER BY id LIMIT 1")
if rows:
try: fields = list(json.loads(rows[0].get('data_json') or '{}').keys())
except (TypeError, ValueError): fields = []
if '产品属性' in fields: fields.remove('产品属性')
if '批次' in fields: fields.remove('批次')
if '序号' in fields: fields.remove('序号')
if '图片' not in fields: fields.insert(3 if len(fields) >= 3 else len(fields), '图片')
fields.insert(0, '批次')
fields.insert(0, '产品属性')
wb = openpyxl.Workbook()
for index, sheet_name in enumerate(('国内品', '海外品')):
ws = wb.active if index == 0 else wb.create_sheet()
ws.title = sheet_name
for col, label in enumerate(fields, 1):
cell = ws.cell(1, col, label)
cell.font = Font(name='微软雅黑', bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='4B5563')
cell.alignment = Alignment(horizontal='center', vertical='center')
ws.column_dimensions[openpyxl.utils.get_column_letter(col)].width = max(14, min(28, len(label) * 2 + 4))
ws.freeze_panes = 'A2'
output = io.BytesIO()
wb.save(output)
output.seek(0)
return send_file(output, as_attachment=True, download_name='呆滞品数据明细导入模板.xlsx', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@inventory_bp.route('/api/stagnant-inventory/import', methods=['POST'])
@login_required
def stagnant_inventory_import():
upload = request.files.get('file')
if not upload or not upload.filename.lower().endswith(('.xlsx', '.xlsm')):
return jsonify({'code': 400, 'msg': '请上传 xlsx 或 xlsm 文件'}), 400
try:
_ensure_stagnant_schema()
raw = upload.read()
rows = _parse_stagnant_workbook(raw)
fields = _stagnant_workbook_fields(raw)
query('TRUNCATE TABLE stagnant_inventory_detail')
query('TRUNCATE TABLE stagnant_inventory_fields')
for index, field in enumerate(fields):
query("INSERT INTO stagnant_inventory_fields (field_name, sort_order) VALUES (%s,%s)", (field, index))
for row in rows:
query("""INSERT INTO stagnant_inventory_detail
(sheet_name,product_property,product_code,product_name,image_path,stock_qty,unit_cost,total_cost,cleared_qty,batch,data_json)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(row['sheet_name'], row['product_property'], row['product_code'], row['product_name'], row['image_path'], row['stock_qty'], row['unit_cost'], row['total_cost'], row['cleared_qty'], str(row.get('batch') or '第六批'), row['data_json']))
return jsonify({'code': 0, 'msg': '导入成功', 'data': {'rows': len(rows)}})
except Exception as exc:
return jsonify({'code': 500, 'msg': '导入失败:' + str(exc)[:200]}), 500
@inventory_bp.route('/api/inventory/export', methods=['POST'])
@login_required
def inventory_export():
"""导出库存数据"""
data = request.json
product_codes = data.get('product_codes', [])
export_all = bool(data.get('export_all', False))
prod_code_filter = data.get('prod_code', '')
prod_name_filter = data.get('prod_name', '')
warehouse_filter = data.get('warehouse', '')
fmt = data.get('format', 'xlsx')
date = data.get('date', '')
date_from = data.get('date_from', '')
date_to = data.get('date_to', '')
product_type = data.get('product_type', '')
export_fields = data.get('fields', [])
if not product_codes and not export_all:
return jsonify({"code": 400, "msg": "请选择要导出的数据"})
if date_from and date_to and date_from > date_to:
return jsonify({"code": 400, "msg": "开始日期不能晚于结束日期"}), 400
# 库存页面默认展示最新库存日;全选导出未指定日期时也限定最新日,避免扫描历史快照。
if export_all and not date and not date_from and not date_to:
latest = query("SELECT MAX(data_date) AS latest_date FROM inventory_detail")
if latest and latest[0].get('latest_date'):
date = latest[0]['latest_date']
if hasattr(date, 'strftime'):
date = date.strftime('%Y-%m-%d')
# 字段映射:前端key → (SQL列, 表头标签)
ALL_FIELD_MAP = {
'product_code': ('d.product_code', '货品编号'),
'product_name': ('d.product_name', '货品名称'),
'warehouse_name': ("COALESCE(w.warehouse_name, CONCAT('仓库', d.warehouse_code))", '仓库'),
'available_qty': ('d.available_qty', '库存'),
'production_in_transit': ('d.production_in_transit', '生产在途'),
'purchase_in_transit': ('d.purchase_in_transit', '采购在途'),
'cost_price': ('d.cost_price', '成本价'),
'brand': ('d.brand', '品牌'),
'data_date': ('d.data_date', '库存日期'),
'采购跟单': ('p.procurement_follower', '采购跟单'),
'采购计划': ('p.procurement_planner', '采购计划'),
'供应商': ("COALESCE(s.supplier_name, p.supplier_name_match)", '供应商'),
}
if not export_fields:
export_fields = ['product_code', 'product_name', 'warehouse_name', 'available_qty',
'production_in_transit', 'purchase_in_transit', 'data_date']
# 构建查询
if export_all:
placeholders = ''
params = []
else:
placeholders = ", ".join(["%s"] * len(product_codes))
params = product_codes[:]
select_cols = []
headers = []
for f in export_fields:
if f in ALL_FIELD_MAP:
select_cols.append(ALL_FIELD_MAP[f][0] + ' AS ' + f)
headers.append(ALL_FIELD_MAP[f][1])
if not select_cols:
return jsonify({"code": 400, "msg": "没有选择有效字段"})
select_sql = ", ".join(select_cols)
sql = f"""
SELECT {select_sql}
FROM inventory_detail d
LEFT JOIN products p ON d.product_code = p.product_code
LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code
LEFT JOIN warehouses w ON LPAD(d.warehouse_code, 2, '0') = w.warehouse_code
WHERE d.product_code NOT LIKE %s AND d.product_name NOT LIKE %s
"""
params = ['%-NL%', '%内料%'] + params
if not export_all:
sql += f" AND d.product_code IN ({placeholders})"
if product_type == '成品':
sql += " AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)"
elif product_type == '包材':
sql += " AND (p.product_type = '包材' OR p.product_code IS NULL)"
if prod_code_filter:
sql += " AND (d.product_code LIKE %s OR d.goods_code LIKE %s)"
params.extend([f"%{prod_code_filter}%", f"%{prod_code_filter}%"])
if prod_name_filter:
sql += " AND d.product_name LIKE %s"
params.append(f"%{prod_name_filter}%")
if warehouse_filter:
sql += " AND (d.warehouse_code=%s OR w.warehouse_name LIKE %s)"
params.extend([warehouse_filter, f"%{warehouse_filter}%"])
if date:
sql += " AND d.data_date=%s"
params.append(date)
else:
if date_from:
sql += " AND d.data_date >= %s"
params.append(date_from)
if date_to:
sql += " AND d.data_date <= %s"
params.append(date_to)
sql += " ORDER BY d.product_code ASC"
import io
if fmt == 'csv':
import csv
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(headers)
for r in query_iter(sql, params):
writer.writerow([_export_cell(r.get(f)) for f in export_fields])
csv_content = output.getvalue()
return Response(csv_content, mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=inventory_export.csv'})
else:
import openpyxl
# write_only 模式单元格不驻留内存,配合流式取数,避免全量导出把 worker 内存撑爆
wb = openpyxl.Workbook(write_only=True)
ws = wb.create_sheet("库存导出")
ws.append(headers)
for r in query_iter(sql, params):
ws.append([_export_cell(r.get(f)) for f in export_fields])
output = io.BytesIO()
wb.save(output)
output.seek(0)
return Response(output.getvalue(),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f'attachment; filename=inventory_export.{fmt}'})
# ─── API: 库存导入(成品/包材共用,按页面区分权限与product_type) ───
INVENTORY_IMPORT_HEADERS = [
'库存日期', '仓库编码', '货品编号', '货品名称', '品牌',
'库存数量', '生产在途', '采购在途', '成本价',
'一级类目', '二级类目', '三级类目',
]
INVENTORY_IMPORT_SAMPLE = [
'2026-09-16', '35', 'POZ-MYL-6235-PDD', '示例:泊紫汀兰男士控油香氛沐浴露', '泊紫汀兰',
'2790', '0', '0', '6.0000', '个人护理', '', '',
]
def _inventory_import_page(product_type):
"""导入归属页面:包材走包材库存权限,其余走成品库存权限。"""
return 'inventory_packaging' if product_type == '包材' else 'inventory_finished'
def _decode_upload_text(raw):
"""按项目惯例依次尝试常见中文编码。"""
for enc in ('utf-8-sig', 'gbk', 'gb18030', 'utf-8'):
try:
return raw.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return None
def _parse_import_rows(raw):
"""解析上传的CSV为 (表头, 数据行);解析失败返回 (None, None)。"""
import csv
import io
text = _decode_upload_text(raw)
if text is None:
return None, None
text = text.replace('\r\n', '\n').replace('\r', '\n')
lines = [l for l in text.split('\n') if l.strip()]
if len(lines) < 2:
return None, None
rows = [r for r in csv.reader(io.StringIO('\n'.join(lines))) if any((c or '').strip() for c in r)]
if len(rows) < 2:
return None, None
headers = [(h or '').replace('\ufeff', '').strip() for h in rows[0]]
return headers, rows[1:]
def _normalize_import_date(val):
"""统一日期格式为 YYYY-MM-DD;无法识别返回 None。"""
import re
s = str(val if val is not None else '').strip()
if not s:
return None
s = re.split(r'[\sT]', s)[0].replace('/', '-').replace('.', '-')
if re.fullmatch(r'\d{8}', s):
return f"{s[:4]}-{s[4:6]}-{s[6:]}"
m = re.fullmatch(r'(\d{4})-(\d{1,2})-(\d{1,2})', s)
if m:
return f"{int(m.group(1)):04d}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
return None
def _import_int(val):
s = str(val if val is not None else '').strip().replace(',', '')
if not s or s.upper() in ('NA', 'N/A', '#N/A'):
return 0
try:
return int(float(s))
except (TypeError, ValueError):
return 0
def _import_float(val):
s = str(val if val is not None else '').strip().replace(',', '')
if not s or s.upper() in ('NA', 'N/A', '#N/A'):
return 0.0
try:
return float(s)
except (TypeError, ValueError):
return 0.0
def _import_text(val):
s = str(val if val is not None else '').strip()
return '' if s.upper() in ('NA', 'N/A', '#N/A') else s
def _is_invalid_product_code(code):
"""过滤报表汇总行与占位编码(与导入脚本口径一致)。"""
normalized = _import_text(code).upper().rstrip(':')
return (not normalized) or normalized in ('NA', 'N/A', '合计')
@inventory_bp.route('/api/inventory/import_template')
@login_required
def inventory_import_template():
"""下载库存导入模板 CSV(成品/包材共用表头)"""
product_type = request.args.get('product_type', '成品')
if not operation_required(_inventory_import_page(product_type), 'import'):
return jsonify({"code": 403, "msg": "无导入权限"})
import csv
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(INVENTORY_IMPORT_HEADERS)
writer.writerow(INVENTORY_IMPORT_SAMPLE)
csv_data = output.getvalue()
output.close()
filename = 'inventory_import_template_%s.csv' % ('packaging' if product_type == '包材' else 'finished')
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=%s' % filename}
)
@inventory_bp.route('/api/inventory/import', methods=['POST'])
@login_required
def inventory_import():
"""导入库存明细 CSV:同「库存日期+仓库+货品编号+数据来源」先删后插,避免重复。"""
product_type = (request.form.get('product_type') or request.args.get('product_type') or '').strip()
if not operation_required(_inventory_import_page(product_type), '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": "未选择文件"})
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 INVENTORY_IMPORT_HEADERS if h in headers)
if matched < 2:
return jsonify({
"code": 400,
"msg": "CSV列名不匹配\n预期列名:%s\n实际列名:%s" % (','.join(INVENTORY_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 ''
# 仓库编码可用编码或仓库名称填写,统一解析为标准编码
warehouse_map = {}
for w in query("SELECT warehouse_code, warehouse_name FROM warehouses"):
code = str(w.get('warehouse_code') or '').strip()
name = str(w.get('warehouse_name') or '').strip()
if code:
warehouse_map[code] = code
if code.isdigit():
warehouse_map[str(int(code))] = code
if name:
warehouse_map[name] = code
from services.inventory_calc import resolve_cost_price
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
wh_raw = _import_text(cell(row, '仓库编码'))
if not wh_raw:
errors.append('第%d行:仓库编码为空' % i)
continue
wh_code = warehouse_map.get(wh_raw, wh_raw)
cost_price = _import_float(cell(row, '成本价'))
records.append((
date_val, 'wdt', wh_code, code, code, _import_text(cell(row, '货品名称')),
_import_text(cell(row, '品牌')), resolve_cost_price(code, cost_price),
_import_int(cell(row, '库存数量')), _import_int(cell(row, '生产在途')),
_import_int(cell(row, '采购在途')), _import_text(cell(row, '一级类目')),
_import_text(cell(row, '二级类目')), _import_text(cell(row, '三级类目')),
))
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 inventory_detail WHERE data_date=%s AND warehouse_code=%s "
"AND product_code=%s AND source=%s",
(rec[0], rec[2], rec[4], rec[1])
)
cur.executemany(
"INSERT INTO inventory_detail "
"(data_date, source, warehouse_code, goods_code, product_code, product_name, brand, "
" cost_price, available_qty, production_in_transit, purchase_in_transit, "
" category1, category2, category3) "
"VALUES (%s,%s,%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('库存导入', '导入%s %d 条(跳过%d条)' % (product_type or '库存', len(records), len(errors)),
page=_inventory_import_page(product_type))
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)})