fix: normalize warehouse codes for property inventory

This commit is contained in:
Codex 2026-10-09 17:48:07 +08:00
parent e93c02d2ad
commit 5cfc056c6b
2 changed files with 405 additions and 0 deletions

358
services/inventory_calc.py Normal file
View File

@ -0,0 +1,358 @@
"""库存计算相关函数"""
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 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("""
SELECT product_code, SUM(available_qty) AS total_qty
FROM inventory_detail
WHERE data_date = %s AND warehouse_code != '15'
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' "
"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",
(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 "
"WHERE data_date = %s "
"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("""
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 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,
}

View File

@ -0,0 +1,47 @@
"""仓库属性库存按编号规范化匹配的回归测试。"""
import unittest
from datetime import date
from unittest import mock
from services import inventory_calc
class InventoryPropertyMappingTest(unittest.TestCase):
def test_numeric_warehouse_codes_ignore_leading_zeroes(self):
with mock.patch.object(
inventory_calc,
"query",
side_effect=[
[{"warehouse_code": "035"}],
[{"d": date(2026, 10, 9)}],
[{"product_code": "POZ-MSG-1322-PDD", "total_qty": 4935}],
],
) as query:
result = inventory_calc.compute_product_inventory_by_property("云仓")
self.assertEqual(4935, result["POZ-MSG-1322-PDD"])
inventory_sql, params = query.call_args.args
self.assertIn("CAST(warehouse_code AS UNSIGNED)", inventory_sql)
self.assertEqual(("2026-10-09", 35), params)
def test_non_numeric_warehouse_codes_remain_exact_matches(self):
with mock.patch.object(
inventory_calc,
"query",
side_effect=[
[{"warehouse_code": "OVERSEAS-A"}],
[{"d": date(2026, 10, 9)}],
[{"product_code": "SKU-1", "total_qty": 7}],
],
) as query:
result = inventory_calc.compute_product_inventory_by_property("外仓")
self.assertEqual(7, result["SKU-1"])
inventory_sql, params = query.call_args.args
self.assertNotIn("CAST(warehouse_code AS UNSIGNED)", inventory_sql)
self.assertEqual(("2026-10-09", "OVERSEAS-A"), params)
if __name__ == "__main__":
unittest.main()