From 5cfc056c6baa8db0b8af97f6b644a6585c962dd3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 9 Oct 2026 17:48:07 +0800 Subject: [PATCH] fix: normalize warehouse codes for property inventory --- services/inventory_calc.py | 358 +++++++++++++++++++++++ tests/test_inventory_property_mapping.py | 47 +++ 2 files changed, 405 insertions(+) create mode 100644 services/inventory_calc.py create mode 100644 tests/test_inventory_property_mapping.py diff --git a/services/inventory_calc.py b/services/inventory_calc.py new file mode 100644 index 0000000..81d053c --- /dev/null +++ b/services/inventory_calc.py @@ -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, + } diff --git a/tests/test_inventory_property_mapping.py b/tests/test_inventory_property_mapping.py new file mode 100644 index 0000000..61d71d4 --- /dev/null +++ b/tests/test_inventory_property_mapping.py @@ -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()