1423 lines
71 KiB
Python
1423 lines
71 KiB
Python
"""库存路由"""
|
||
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)})
|