164 lines
6.3 KiB
Python
164 lines
6.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""导出所有成品库存数据(总库存 + 可发库存 + 30天销量 + 可支撑天数 + 单仓库明细)
|
||
|
||
口径说明:
|
||
- 成品定义:products.product_type IN ('成品','清库存','现货','赠品') 且 deleted_at IS NULL
|
||
- 建档时间:products.created_at
|
||
- 成本价:命中特殊规则用 SPEC 正确成本价,否则用最新库存日 inventory_detail.cost_price
|
||
- 总库存:最新库存日所有仓库 available_qty 合计(含15号加工厂储存仓)
|
||
- 可发库存:最新库存日排除15号仓库 available_qty 合计(含 EXCLUDE_WAREHOUSE_20_PRODUCTS 特例排除)
|
||
- 30天总销量:daily_outbound 近30天 SUM(货品数量),排除内料(名称含"内料"或编码含 -NL)
|
||
- 可支撑天数:可发库存 / (30天总销量/30),无销量=999
|
||
"""
|
||
import io
|
||
import os
|
||
import sys
|
||
from datetime import datetime
|
||
|
||
sys.path.insert(0, '/home/gxd/.openclaw/workspace/dy-dashboard')
|
||
from services.db import query
|
||
from services.inventory_calc import (
|
||
resolve_cost_price, SPECIAL_COST_PRICES, EXCLUDE_WAREHOUSE_20_PRODUCTS,
|
||
_get_latest_inv_date,
|
||
)
|
||
|
||
FINISHED_TYPES = ('成品', '清库存', '现货', '赠品')
|
||
|
||
|
||
def main():
|
||
latest_date = _get_latest_inv_date()
|
||
print(f'最新库存日期: {latest_date}')
|
||
|
||
# 1. 成品列表
|
||
products = query("""
|
||
SELECT product_code, product_name, created_at
|
||
FROM products
|
||
WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL
|
||
ORDER BY product_code ASC
|
||
""")
|
||
print(f'成品数: {len(products)}')
|
||
|
||
# 2. 最新库存日各仓库库存(pivot)
|
||
inv_rows = query("""
|
||
SELECT product_code, warehouse_code, available_qty
|
||
FROM inventory_detail
|
||
WHERE data_date = %s
|
||
""", (latest_date,))
|
||
from collections import defaultdict
|
||
per_wh = defaultdict(dict) # code -> {wh: qty}
|
||
total_qty = defaultdict(int) # code -> 所有仓库合计(含15号)
|
||
avail_qty = defaultdict(int) # code -> 排除15号 + 20号特例
|
||
for r in inv_rows:
|
||
code = r['product_code']
|
||
wh = r['warehouse_code']
|
||
qty = int(r['available_qty'] or 0)
|
||
per_wh[code][wh] = per_wh[code].get(wh, 0) + qty
|
||
total_qty[code] += qty
|
||
if wh != '15':
|
||
if code in EXCLUDE_WAREHOUSE_20_PRODUCTS and str(wh).zfill(2) == '20':
|
||
continue
|
||
avail_qty[code] += qty
|
||
|
||
# 3. 最新库存日成本价
|
||
cost_rows = query("""
|
||
SELECT product_code, MAX(cost_price) AS pr
|
||
FROM inventory_detail WHERE data_date = %s
|
||
GROUP BY product_code
|
||
""", (latest_date,))
|
||
cost_map = {r['product_code']: float(r['pr'] or 0) for r in cost_rows}
|
||
|
||
# 4. 30天总销量(排除内料)
|
||
sales_rows = query("""
|
||
SELECT 货品编号 AS code, SUM(货品数量) AS qty
|
||
FROM daily_outbound
|
||
WHERE 日期 >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||
AND 货品名称 NOT LIKE %s AND 货品编号 NOT LIKE %s
|
||
GROUP BY 货品编号
|
||
""", ('%内料%', '%-NL'))
|
||
sales_30 = {r['code']: float(r['qty'] or 0) for r in sales_rows}
|
||
|
||
# 5. 仓库列(最新库存日实际出现的仓库,按编码排序)
|
||
wh_codes = sorted({wh for d in per_wh.values() for wh in d.keys()})
|
||
wh_names = {w['warehouse_code']: w['warehouse_name'] for w in query(
|
||
"SELECT warehouse_code, warehouse_name FROM warehouses")}
|
||
wh_cols = []
|
||
for wh in wh_codes:
|
||
name = wh_names.get(wh, '')
|
||
if str(wh).zfill(2) in wh_names:
|
||
name = wh_names[str(wh).zfill(2)]
|
||
elif str(wh).lstrip('0') in wh_names:
|
||
name = wh_names[str(wh).lstrip('0')]
|
||
wh_cols.append((wh, name))
|
||
print(f'仓库列数: {len(wh_cols)}')
|
||
|
||
# 6. 生成 Excel
|
||
import openpyxl
|
||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = '成品库存明细'
|
||
|
||
headers = ['货品编码', '货品名称', '建档时间', '成本价', '总库存', '可发库存',
|
||
'可支撑天数', '30天总销量']
|
||
for wh, name in wh_cols:
|
||
headers.append(f'{wh} {name}')
|
||
|
||
ws.append(headers)
|
||
header_fill = PatternFill('solid', fgColor='4472C4')
|
||
header_font = Font(name='微软雅黑', bold=True, color='FFFFFF', size=11)
|
||
thin = Side(style='thin', color='D9D9D9')
|
||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||
for c in ws[1]:
|
||
c.fill = header_fill
|
||
c.font = header_font
|
||
c.alignment = Alignment(horizontal='center', vertical='center')
|
||
c.border = border
|
||
|
||
for p in products:
|
||
code = p['product_code']
|
||
name = p['product_name'] or ''
|
||
created = p['created_at']
|
||
created_str = created.strftime('%Y-%m-%d') if hasattr(created, 'strftime') else str(created)[:10] if created else ''
|
||
price = resolve_cost_price(code, cost_map.get(code, 0.0))
|
||
total = total_qty.get(code, 0)
|
||
avail = avail_qty.get(code, 0)
|
||
s30 = int(sales_30.get(code, 0))
|
||
daily = s30 / 30.0
|
||
support = round(avail / daily, 1) if daily > 0 else 999
|
||
|
||
row = [code, name, created_str, price, total, avail, support, s30]
|
||
for wh, _ in wh_cols:
|
||
row.append(per_wh[code].get(wh, 0))
|
||
ws.append(row)
|
||
|
||
# 列宽
|
||
widths = {'货品编码': 24, '货品名称': 40, '建档时间': 12, '成本价': 10,
|
||
'总库存': 10, '可发库存': 10, '可支撑天数': 12, '30天总销量': 12}
|
||
for i, h in enumerate(headers, start=1):
|
||
ws.column_dimensions[get_column_letter(i)].width = widths.get(h, 16)
|
||
|
||
# 冻结首行
|
||
ws.freeze_panes = 'A2'
|
||
|
||
# 数值格式
|
||
for row in ws.iter_rows(min_row=2):
|
||
row[3].number_format = '0.0000' # 成本价
|
||
for cell in row[4:]:
|
||
cell.number_format = '#,##0'
|
||
|
||
out_path = None
|
||
if len(sys.argv) > 1 and sys.argv[1].startswith('--out='):
|
||
out_path = sys.argv[1].split('=', 1)[1]
|
||
if not out_path:
|
||
out_path = f'/home/gxd/.openclaw/workspace/dingtalk-assistant/tmp_export/成品库存明细_{datetime.now().strftime("%Y%m%d")}.xlsx'
|
||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||
wb.save(out_path)
|
||
print(f'已生成: {out_path}')
|
||
print(f'行数: {len(products)}, 列数: {len(headers)}')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|