385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""库存计算相关函数"""
|
||
from services.db import query
|
||
from services.cache import RefreshableCache, register_cache
|
||
|
||
|
||
# ⚠️ 特殊成本价规则:以下商品不使用库存表中的成本价,使用单独指定的正确成本价。
|
||
# 键为商品编码,值为正确成本价。
|
||
SPECIAL_COST_PRICES = {
|
||
'POZ-FM-0184-TM-SM': 0.055, # 库存表55 → 正确0.055
|
||
'POZ-XFS-9014-TM-MD': 0.062, # 库存表31.32 → 正确0.062
|
||
'POZ-FM-9742-TM-MD': 0.181, # 库存表28.08 → 正确0.181
|
||
'POZ-MM-0580-TM-MD': 0.22, # 库存表26 → 正确0.22
|
||
'POZ-SKS-3374-LD': 0.1, # 库存表25 → 正确0.1
|
||
'POZ-SKS-3367-LD': 0.1, # 库存表25 → 正确0.1
|
||
'POZ-SKS-3350-LD': 0.1, # 库存表25 → 正确0.1
|
||
'POZ-SKS-3343-LD': 0.1, # 库存表25 → 正确0.1
|
||
}
|
||
# 以下商品的库存总数量不统计20号仓库。
|
||
EXCLUDE_WAREHOUSE_20_PRODUCTS = {
|
||
'POZ-03033',
|
||
'POZ-DXPW-1668-PDD',
|
||
'AH-YS-0584-TMGJ',
|
||
'POZ-JHY-7022',
|
||
'SEDATE-YMT-1682-PDD',
|
||
}
|
||
# 以下无产品资料商品固定归属成品,不按20号仓库自动归为包材。
|
||
FORCE_FINISHED_PRODUCTS = {
|
||
'POZ-WPH-0137',
|
||
'(AVA-YS-3252)',
|
||
'(UO-AJS-3362)',
|
||
'(WPK-CBZT-9707)',
|
||
'【UOUONE】LHJ0385',
|
||
}
|
||
FORCE_FINISHED_PRODUCTS_SQL = ', '.join(
|
||
"'%s'" % code.replace("'", "''") for code in sorted(FORCE_FINISHED_PRODUCTS)
|
||
)
|
||
|
||
|
||
def active_warehouse_condition(inventory_alias='inventory_detail'):
|
||
"""SQL condition that excludes warehouses marked disabled in warehouse management.
|
||
|
||
Inventory rows whose warehouse is not maintained in ``warehouses`` remain eligible;
|
||
only an explicit ``disabled='是'`` removes a warehouse from calculations. Numeric
|
||
warehouse codes are compared by value so 29/029 and 35/035 behave consistently.
|
||
"""
|
||
alias = inventory_alias
|
||
return f"""NOT EXISTS (
|
||
SELECT 1 FROM warehouses disabled_wh
|
||
WHERE COALESCE(disabled_wh.disabled, '否') = '是'
|
||
AND (
|
||
disabled_wh.warehouse_code = {alias}.warehouse_code
|
||
OR (
|
||
disabled_wh.warehouse_code REGEXP '^[0-9]+$'
|
||
AND {alias}.warehouse_code REGEXP '^[0-9]+$'
|
||
AND CAST(disabled_wh.warehouse_code AS UNSIGNED) = CAST({alias}.warehouse_code AS UNSIGNED)
|
||
)
|
||
)
|
||
)"""
|
||
|
||
|
||
def resolve_cost_price(product_code, inventory_price):
|
||
"""解析产品成本价:命中特殊规则的用单独指定的正确成本价,否则用库存表成本价。"""
|
||
if product_code in SPECIAL_COST_PRICES:
|
||
return SPECIAL_COST_PRICES[product_code]
|
||
return inventory_price
|
||
|
||
|
||
def _get_latest_inv_date():
|
||
"""获取最新库存数据日期"""
|
||
latest = query("SELECT MAX(data_date) AS d FROM inventory_detail")[0]['d']
|
||
if hasattr(latest, 'strftime'):
|
||
return latest.strftime('%Y-%m-%d')
|
||
return str(latest)[:10]
|
||
|
||
|
||
def _compute_product_inventory():
|
||
"""计算每个产品的最新库存(排除15号仓库)"""
|
||
latest_str = _get_latest_inv_date()
|
||
rows = query(f"""
|
||
SELECT product_code, SUM(available_qty) AS total_qty
|
||
FROM inventory_detail
|
||
WHERE data_date = %s AND warehouse_code != '15'
|
||
AND {active_warehouse_condition('inventory_detail')}
|
||
AND NOT (product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')
|
||
AND LPAD(warehouse_code, 2, '0') = '20')
|
||
GROUP BY product_code
|
||
""", (latest_str,))
|
||
|
||
inv_map = {}
|
||
for r in rows:
|
||
inv_map[r['product_code']] = int(r['total_qty']) if r['total_qty'] else 0
|
||
return inv_map
|
||
|
||
|
||
def _compute_raw_inventory_15():
|
||
"""计算每个产品的最新加工厂库存(仅15号仓库)"""
|
||
latest_str = _get_latest_inv_date()
|
||
rows = query(
|
||
"SELECT product_code, SUM(available_qty) AS total_qty "
|
||
"FROM inventory_detail "
|
||
"WHERE data_date = %s AND warehouse_code = '15' "
|
||
f"AND {active_warehouse_condition('inventory_detail')} "
|
||
"GROUP BY product_code",
|
||
(latest_str,))
|
||
inv15_map = {}
|
||
for r in rows:
|
||
inv15_map[r['product_code']] = int(r['total_qty']) if r['total_qty'] else 0
|
||
return inv15_map
|
||
|
||
# 缓存版获取最新加工厂库存
|
||
inv15_cache = RefreshableCache(maxsize=1)
|
||
register_cache(inv15_cache)
|
||
|
||
|
||
@inv15_cache.wrap
|
||
def compute_product_inventory_15():
|
||
"""缓存版加工厂库存计算(支持跨 worker 主动刷新)"""
|
||
return _compute_raw_inventory_15()
|
||
|
||
|
||
def compute_product_inventory_by_property(property_keyword):
|
||
"""根据仓库属性标签计算每个产品的库存(如云仓、外仓)"""
|
||
wh_codes = query(
|
||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET(%s, warehouse_property) > 0 "
|
||
"AND COALESCE(disabled, '否') != '是'",
|
||
(property_keyword,)
|
||
)
|
||
# 仓库管理和库存明细的编号格式可能不同(例如管理表为035、明细为35)。
|
||
# 对纯数字仓号按数值匹配,其他编号仍按原字符串精确匹配。
|
||
numeric_codes = set()
|
||
text_codes = set()
|
||
for row in wh_codes:
|
||
code = str(row.get('warehouse_code') or '').strip()
|
||
if not code:
|
||
continue
|
||
if code.isdigit():
|
||
numeric_codes.add(int(code))
|
||
else:
|
||
text_codes.add(code)
|
||
if not numeric_codes and not text_codes:
|
||
return {}
|
||
|
||
latest_str = _get_latest_inv_date()
|
||
match_clauses = []
|
||
params = [latest_str]
|
||
if numeric_codes:
|
||
placeholders = ','.join(['%s'] * len(numeric_codes))
|
||
match_clauses.append(
|
||
"warehouse_code REGEXP '^[0-9]+$' "
|
||
f"AND CAST(warehouse_code AS UNSIGNED) IN ({placeholders})"
|
||
)
|
||
params.extend(sorted(numeric_codes))
|
||
if text_codes:
|
||
placeholders = ','.join(['%s'] * len(text_codes))
|
||
match_clauses.append(f"warehouse_code IN ({placeholders})")
|
||
params.extend(sorted(text_codes))
|
||
|
||
rows = query(f"""
|
||
SELECT product_code, SUM(available_qty) AS total_qty
|
||
FROM inventory_detail
|
||
WHERE data_date = %s AND ({' OR '.join(match_clauses)})
|
||
GROUP BY product_code
|
||
""", tuple(params))
|
||
|
||
inv_map = {}
|
||
for r in rows:
|
||
inv_map[r['product_code']] = int(r['total_qty']) if r['total_qty'] else 0
|
||
return inv_map
|
||
|
||
# 缓存版云仓库存
|
||
_cloud_inv_cache = RefreshableCache(maxsize=1)
|
||
register_cache(_cloud_inv_cache)
|
||
|
||
|
||
@_cloud_inv_cache.wrap
|
||
def compute_cloud_inventory_cached():
|
||
"""缓存版云仓库存计算(支持跨 worker 主动刷新)"""
|
||
return compute_product_inventory_by_property('云仓')
|
||
|
||
|
||
# 缓存版外仓库存
|
||
_ext_inv_cache = RefreshableCache(maxsize=1)
|
||
register_cache(_ext_inv_cache)
|
||
|
||
|
||
@_ext_inv_cache.wrap
|
||
def compute_external_inventory_cached():
|
||
"""缓存版外仓库存计算(支持跨 worker 主动刷新)"""
|
||
return compute_product_inventory_by_property('外仓')
|
||
|
||
|
||
def compute_product_in_transit():
|
||
"""计算每个产品的最新生产在途、采购在途(所有仓库合计)"""
|
||
latest_str = _get_latest_inv_date()
|
||
rows = query(
|
||
"SELECT product_code, SUM(production_in_transit) AS prod_in_transit, "
|
||
"SUM(purchase_in_transit) AS purch_in_transit "
|
||
"FROM inventory_detail "
|
||
f"WHERE data_date = %s AND {active_warehouse_condition('inventory_detail')} "
|
||
"GROUP BY product_code",
|
||
(latest_str,))
|
||
transit_map = {}
|
||
for r in rows:
|
||
transit_map[r['product_code']] = {
|
||
'生产在途': int(r['prod_in_transit']) if r['prod_in_transit'] else 0,
|
||
'采购在途': int(r['purch_in_transit']) if r['purch_in_transit'] else 0,
|
||
}
|
||
return transit_map
|
||
|
||
# 缓存版在途库存
|
||
_transit_cache = RefreshableCache(maxsize=1)
|
||
register_cache(_transit_cache)
|
||
|
||
|
||
@_transit_cache.wrap
|
||
def compute_product_in_transit_cached():
|
||
"""缓存版在途库存计算(支持跨 worker 主动刷新)"""
|
||
return compute_product_in_transit()
|
||
|
||
|
||
def _determine_trend(avg_3, avg_7, avg_14):
|
||
"""根据3/7/14天销量均值判断销售趋势(共10种)"""
|
||
a3, a7, a14 = float(avg_3), float(avg_7), float(avg_14)
|
||
|
||
# 全部为0 → 近期无销售
|
||
if a3 == 0 and a7 == 0 and a14 == 0:
|
||
return '近期无销售'
|
||
|
||
# 防止除零
|
||
base_7 = a7 if a7 != 0 else 1
|
||
base_14 = a14 if a14 != 0 else 1
|
||
|
||
# 计算比值
|
||
ratio_3_7 = a3 / base_7
|
||
ratio_7_14 = a7 / base_14
|
||
diff_3_7 = abs(a3 - a7)
|
||
diff_7_14 = abs(a7 - a14)
|
||
|
||
# 应用趋势规则
|
||
if a3 > a7 and 0.95 <= ratio_7_14 <= 1.05:
|
||
return '开始上升'
|
||
if a3 < a7 and 0.95 <= ratio_7_14 <= 1.05:
|
||
return '开始下跌'
|
||
if diff_3_7 <= 20 and diff_7_14 <= 20:
|
||
return '平稳趋势'
|
||
if 0.95 <= ratio_3_7 <= 1.05 and a7 > a14:
|
||
return '上升趋稳'
|
||
if 0.95 <= ratio_3_7 <= 1.05 and a7 < a14:
|
||
return '下跌趋稳'
|
||
if a3 > a7 and a7 > a14:
|
||
return '持续上升'
|
||
if a3 < a7 and a7 > a14:
|
||
return '急跌'
|
||
if a3 < a7 and a7 < a14:
|
||
return '持续下跌'
|
||
if a3 > a7 and a7 < a14:
|
||
return '急增'
|
||
return '平稳趋势'
|
||
|
||
|
||
def _determine_trend_cached(avg_3, avg_7, avg_14):
|
||
"""趋势判断(带缓存)"""
|
||
return _determine_trend(avg_3, avg_7, avg_14)
|
||
|
||
|
||
# 主库存计算缓存
|
||
inv_cache = RefreshableCache(maxsize=1)
|
||
register_cache(inv_cache)
|
||
|
||
|
||
@inv_cache.wrap
|
||
def compute_product_inventory_cached():
|
||
"""缓存版库存计算(支持跨 worker 主动刷新)"""
|
||
return _compute_product_inventory()
|
||
|
||
|
||
def compute_inventory_cost_summary(latest_date=None):
|
||
"""按系统数据概览口径计算当前库存成本汇总(与 /api/dashboard/stats 一致)。
|
||
|
||
规则:
|
||
- 成本价:命中 SPECIAL_COST_PRICES 的商品用单独指定的正确成本价,其余用库存表成本价
|
||
- 成本价与库存数量使用同一个核对日期(latest_date,默认最新库存日期)
|
||
- 排除内料(编码含 -NL 或名称含"内料")
|
||
- 成品/包材分类:有产品资料的按 product_type;无资料按仓库 20 号=包材、其他=成品
|
||
- 20号仓库特定商品(EXCLUDE_WAREHOUSE_20_PRODUCTS)不统计
|
||
- 生产在途按成品BOM中"内料"项的库存成本价计算;采购在途按成品成本价计算
|
||
返回 dict:total_cost / finished_cost / packaging_cost / 各分项 / latest_date
|
||
"""
|
||
if not latest_date:
|
||
latest_date = _get_latest_inv_date()
|
||
|
||
# 成本价(同一核对日期)
|
||
_latest_cost = query("""
|
||
SELECT product_code, ANY_VALUE(cost_price) AS pr
|
||
FROM inventory_detail WHERE data_date = %s GROUP BY product_code
|
||
""", (latest_date,))
|
||
_cost_map = {r['product_code']: resolve_cost_price(r['product_code'], float(r['pr']) if r['pr'] else 0) for r in _latest_cost}
|
||
|
||
# 成品/包材分类汇总
|
||
_inventory_rows = query(f"""
|
||
SELECT id.product_code,
|
||
SUM(CASE WHEN
|
||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND (LPAD(id.warehouse_code, 2, '0') != '20'
|
||
OR id.product_code IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385')))
|
||
THEN id.available_qty ELSE 0 END) AS finished_available_qty,
|
||
SUM(CASE WHEN
|
||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND (LPAD(id.warehouse_code, 2, '0') != '20'
|
||
OR id.product_code IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385')))
|
||
THEN id.production_in_transit ELSE 0 END) AS finished_production_in_transit,
|
||
SUM(CASE WHEN
|
||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND (LPAD(id.warehouse_code, 2, '0') != '20'
|
||
OR id.product_code IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385')))
|
||
THEN id.purchase_in_transit ELSE 0 END) AS finished_purchase_in_transit,
|
||
SUM(CASE WHEN
|
||
(p.product_type = '包材' AND p.product_name NOT LIKE %s AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND LPAD(id.warehouse_code, 2, '0') = '20'
|
||
AND id.product_code NOT IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385'))
|
||
THEN id.available_qty ELSE 0 END) AS packaging_available_qty,
|
||
SUM(CASE WHEN
|
||
(p.product_type = '包材' AND p.product_name NOT LIKE %s AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND LPAD(id.warehouse_code, 2, '0') = '20'
|
||
AND id.product_code NOT IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385'))
|
||
THEN id.purchase_in_transit ELSE 0 END) AS packaging_purchase_in_transit
|
||
FROM inventory_detail id
|
||
LEFT JOIN products p ON id.product_code = p.product_code
|
||
WHERE id.data_date = %s
|
||
AND {active_warehouse_condition('id')}
|
||
AND id.product_code NOT LIKE %s
|
||
AND id.product_name NOT LIKE %s
|
||
AND NOT (id.product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')
|
||
AND LPAD(id.warehouse_code, 2, '0') = '20')
|
||
GROUP BY id.product_code
|
||
""", ('%内料%', '%内料%', latest_date, '%-NL%', '%内料%'))
|
||
|
||
# BOM 内料成本价(生产在途成本用)
|
||
_inner_cost_rows = query("""
|
||
SELECT b.product_code, b.material_code,
|
||
COALESCE(mi.cost_price, m.cost_price, 0) AS inner_cost
|
||
FROM bom b
|
||
LEFT JOIN (
|
||
SELECT product_code, ANY_VALUE(cost_price) AS cost_price
|
||
FROM inventory_detail WHERE data_date = %s GROUP BY product_code
|
||
) mi ON mi.product_code = b.material_code
|
||
LEFT JOIN products m ON m.product_code = b.material_code
|
||
WHERE (b.material_name LIKE %s OR b.material_code LIKE %s)
|
||
""", (latest_date, '%内料%', '%-NL'))
|
||
_inner_cost_map = {
|
||
r['product_code']: resolve_cost_price(r['material_code'], float(r['inner_cost'] or 0)) if r['material_code'] else 0
|
||
for r in _inner_cost_rows
|
||
}
|
||
|
||
finished_inventory_cost = finished_production_transit_cost = finished_purchase_transit_cost = 0.0
|
||
packaging_inventory_cost = packaging_purchase_transit_cost = 0.0
|
||
for r in _inventory_rows:
|
||
price = _cost_map.get(r['product_code'], 0)
|
||
finished_inventory_cost += float(r['finished_available_qty'] or 0) * price
|
||
finished_production_transit_cost += float(r['finished_production_in_transit'] or 0) * _inner_cost_map.get(r['product_code'], 0)
|
||
finished_purchase_transit_cost += float(r['finished_purchase_in_transit'] or 0) * price
|
||
packaging_inventory_cost += float(r['packaging_available_qty'] or 0) * price
|
||
packaging_purchase_transit_cost += float(r['packaging_purchase_in_transit'] or 0) * price
|
||
|
||
finished_inventory_cost = round(finished_inventory_cost, 2)
|
||
finished_production_transit_cost = round(finished_production_transit_cost, 2)
|
||
finished_purchase_transit_cost = round(finished_purchase_transit_cost, 2)
|
||
packaging_inventory_cost = round(packaging_inventory_cost, 2)
|
||
packaging_purchase_transit_cost = round(packaging_purchase_transit_cost, 2)
|
||
finished_cost = round(finished_inventory_cost + finished_production_transit_cost + finished_purchase_transit_cost, 2)
|
||
packaging_cost = round(packaging_inventory_cost + packaging_purchase_transit_cost, 2)
|
||
total_cost = round(finished_cost + packaging_cost, 2)
|
||
|
||
return {
|
||
'latest_date': latest_date,
|
||
'total_cost': total_cost,
|
||
'finished_cost': finished_cost,
|
||
'packaging_cost': packaging_cost,
|
||
'finished_inventory_cost': finished_inventory_cost,
|
||
'finished_production_transit_cost': finished_production_transit_cost,
|
||
'finished_purchase_transit_cost': finished_purchase_transit_cost,
|
||
'packaging_inventory_cost': packaging_inventory_cost,
|
||
'packaging_purchase_transit_cost': packaging_purchase_transit_cost,
|
||
}
|