from decimal import Decimal from unittest.mock import MagicMock, patch from app.schemas.pl.plan import PlanDeliveryRead, PlanLoadRead, PlanStopProductRead from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES from app.services.pl_plan_archive_export import ( ARCHIVE_MERGED_SHEET_TITLE, _plan_common_export_fields, append_archive_list_merge_sheet, build_plan_archive_export_rows, prepare_archive_list_display_rows, resolve_archive_list_io_column, ) from app.utils.excel_export import ExportColumn def _stop_product(**kwargs) -> PlanStopProductRead: defaults = { "id": "sp1", "planId": "p1", "tenantNo": "t1", "productId": "prod1", "productNo": "P001", "productName": "甲醇", "model": "99%", "unit": "吨", "packageSpec": "散装", "planQty": Decimal("10"), "createTime": "2026-08-27T00:00:00", "updateTime": "2026-08-27T00:00:00", } defaults.update(kwargs) return PlanStopProductRead(**defaults) def test_resolve_archive_list_io_column(): internal = next(iter(INTERNAL_CUSTOMER_NAMES)) assert resolve_archive_list_io_column("load", internal) == "outbound" assert resolve_archive_list_io_column("load", "外部客户") == "inbound" assert resolve_archive_list_io_column("delivery", internal) == "inbound" assert resolve_archive_list_io_column("delivery", "外部客户") == "outbound" # 配对宽表 helper(_append_paired_product_rows / _append_stop_product_rows)单测已删除: # 归档导出改为段次×产品多行,不再断言同排 loadPlanQty+deliveryPlanQty / deliveryStopKind。 # 品牌别名与内部客户合同占位改由 build_plan_archive_export_rows 覆盖。 def _bare_plan(**overrides): plan = MagicMock() plan.id = "p1" plan.plan_no = "PL001" plan.contract_no = "C1" plan.plan_date = "2026-08-27" plan.archive_status = "archived" plan.follow_status = "arranged" plan.plan_type = "direct" plan.license_plate = "皖A12345" plan.trailer_no = None plan.erp_entered = 0 plan.freight_amount = None plan.remark = "" plan.transport_id = None plan.customer_name = "外部客户" plan.supplier_name = None plan.destination_name = None plan.delivery_time = None for key, value in overrides.items(): setattr(plan, key, value) return plan def _archive_rows( plan, load, deliveries, *, stop_kind=None, legacy_products=None, alias_maps=None, brand_by_stop=None, brand_by_plan_product=None, ): if legacy_products is None: legacy_products = {} if alias_maps is None: alias_maps = ({}, {}) if brand_by_stop is None: brand_by_stop = {} if brand_by_plan_product is None: brand_by_plan_product = {} with patch( "app.services.pl_plan_archive_export.load_plan_stops_grouped", return_value={plan.id: (load, deliveries)}, ), patch( "app.services.pl_plan_archive_export._load_legacy_products_by_plan", return_value=legacy_products, ), patch( "app.services.pl_plan_archive_export._load_contract_nos_by_plan", return_value={plan.id: []}, ), patch( "app.services.pl_plan_archive_export.load_crm_product_alias_maps", return_value=alias_maps, ), patch( "app.services.pl_plan_archive_export.load_brand_qtys_by_stop_product_ids", return_value=brand_by_stop, ), patch( "app.services.pl_plan_archive_export.load_brand_qtys_by_plan_product_ids", return_value=brand_by_plan_product, ): return build_plan_archive_export_rows( MagicMock(), "t1", [plan], {}, stop_kind=stop_kind ) def test_build_plan_archive_export_rows_segment_rows_and_single_side(): """默认按段次×产品展开:1 个装货产品 + 1 个送货产品 = 2 行;单侧筛选只留对应段。""" internal = next(iter(INTERNAL_CUSTOMER_NAMES)) load_product = _stop_product( planQty=Decimal("10"), inboundQty=Decimal("10"), inboundPrice=Decimal("100"), ) delivery_product = _stop_product( id="dp1", planQty=Decimal("12"), outboundQty=Decimal("9"), outboundPrice=Decimal("110"), outboundAmount=Decimal("990"), ) load = PlanLoadRead( id="load1", planId="p1", tenantNo="t1", customerName=internal, address="装货地址", loadTime="2026-08-27T08:00:00", products=[load_product], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) delivery = PlanDeliveryRead( id="del1", planId="p1", tenantNo="t1", seq=2, customerName="外部客户", address="送货地址", deliveryTime="2026-08-28T10:00:00", products=[delivery_product], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) plan = _bare_plan() rows = _archive_rows(plan, load, [delivery], stop_kind=None) load_only = _archive_rows(plan, load, [delivery], stop_kind="load") delivery_only = _archive_rows(plan, load, [delivery], stop_kind="delivery") assert len(rows) == 2 assert [row["stopSegment"] for row in rows] == ["装货", "送货 #2"] assert rows[0]["productName"] == "甲醇" assert rows[1]["productName"] == "甲醇" assert rows[0]["loadCustomer"] == internal assert rows[0]["deliveryCustomer"] == "" assert rows[0]["loadAddress"] == "装货地址" assert rows[0]["loadTime"] == "2026-08-27" assert rows[0]["deliveryAddress"] == "" assert rows[0]["deliveryTime"] == "" assert rows[0]["planQty"] == Decimal("10") assert rows[0]["actualQty"] == Decimal("10") assert rows[0]["unitPrice"] == Decimal("100") assert rows[0]["amount"] == Decimal("1000") assert rows[1]["loadCustomer"] == "" assert rows[1]["deliveryCustomer"] == "外部客户" assert rows[1]["loadAddress"] == "" assert rows[1]["loadTime"] == "" assert rows[1]["deliveryAddress"] == "送货地址" assert rows[1]["deliveryTime"] == "2026-08-28" assert rows[1]["planQty"] == Decimal("12") assert rows[1]["actualQty"] == Decimal("9") assert rows[1]["unitPrice"] == Decimal("110") assert rows[1]["amount"] == Decimal("990") assert rows[0].get("loadPlanQty") is None assert rows[0].get("deliveryPlanQty") is None assert rows[1].get("loadPlanQty") is None assert rows[1].get("deliveryPlanQty") is None assert not rows[0].get("deliveryStopKind") assert not rows[1].get("deliveryStopKind") assert len(load_only) == 1 assert load_only[0]["stopSegment"] == "装货" assert load_only[0]["loadCustomer"] == internal assert load_only[0]["deliveryCustomer"] == "" assert load_only[0]["planQty"] == Decimal("10") assert load_only[0]["actualQty"] == Decimal("10") assert load_only[0]["unitPrice"] == Decimal("100") assert load_only[0]["amount"] == Decimal("1000") assert load_only[0].get("loadPlanQty") is None assert len(delivery_only) == 1 assert delivery_only[0]["stopSegment"] == "送货 #2" assert delivery_only[0]["deliveryCustomer"] == "外部客户" assert delivery_only[0]["loadCustomer"] == "" assert delivery_only[0]["planQty"] == Decimal("12") assert delivery_only[0]["actualQty"] == Decimal("9") assert delivery_only[0]["unitPrice"] == Decimal("110") assert delivery_only[0]["amount"] == Decimal("990") assert delivery_only[0].get("deliveryPlanQty") is None class _FakePlan: id = "p1" plan_no = "PL001" contract_no = "SX/X-JT20260827001" plan_date = "2026-08-27" archive_status = "archived" follow_status = "arranged" plan_type = "direct" license_plate = "皖A12345" trailer_no = "挂001" erp_entered = 1 freight_amount = Decimal("500") remark = "测试" def test_plan_common_export_fields(): fields = _plan_common_export_fields(_FakePlan(), None) assert fields["planId"] == "p1" assert fields["planNo"] == "PL001" assert fields["contractNo"] == "SX/X-JT20260827001" assert fields["archiveStatus"] == "已归档" assert fields["followStatus"] == "已安排" assert fields["planType"] == "直销单" assert fields["licensePlate"] == "皖A12345挂001" assert fields["erpEntered"] == "是" def test_build_plan_archive_export_rows_includes_brand_and_alias(): plan_product = MagicMock() plan_product.id = "pp1" plan_product.product_id = "prod1" plan_product.product_name = "甲醇别名" plan_product.brand_mode = "any" plan_product.brand_name = None load_product = _stop_product(id="lp1", planProductId="pp1", productName="甲醇别名") delivery_product = _stop_product( id="dp1", planProductId="pp1", productName="甲醇别名", outboundQty=Decimal("9"), outboundPrice=Decimal("110"), ) load = PlanLoadRead( id="load1", planId="p1", tenantNo="t1", customerName="装货客户", products=[load_product], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) delivery = PlanDeliveryRead( id="del1", planId="p1", tenantNo="t1", seq=1, customerName="送货客户", products=[delivery_product], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) rows = _archive_rows( _bare_plan(), load, [delivery], legacy_products={"p1": [plan_product]}, alias_maps=({"prod1": "甲醇"}, {"prod1": ["甲醇别名"]}), brand_by_stop={ "lp1": [{"brandId": "b1", "brandName": "君正", "qty": Decimal("10")}], "dp1": [{"brandId": "b1", "brandName": "君正", "qty": Decimal("9")}], }, ) assert len(rows) == 2 assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"] assert rows[0]["loadCustomer"] == "装货客户" assert rows[0]["deliveryCustomer"] == "" assert rows[1]["loadCustomer"] == "" assert rows[1]["deliveryCustomer"] == "送货客户" for row in rows: assert row["productName"] == "甲醇" assert row["brandName"] == "君正" assert row["aliasName"] == "甲醇别名" assert rows[0]["actualQty"] == Decimal("10") assert rows[1]["actualQty"] == Decimal("9") def test_build_plan_archive_export_rows_splits_multi_brand(): plan_product = MagicMock() plan_product.id = "pp1" plan_product.product_id = "prod1" plan_product.product_name = "片碱" plan_product.brand_mode = "any" plan_product.brand_name = None load_product = _stop_product( id="lp1", planProductId="pp1", productName="片碱", planQty=Decimal("34"), inboundQty=Decimal("32"), inboundPrice=Decimal("2820"), ) load = PlanLoadRead( id="load1", planId="p1", tenantNo="t1", customerName="装货客户", products=[load_product], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) rows = _archive_rows( _bare_plan(), load, [], legacy_products={"p1": [plan_product]}, alias_maps=({"prod1": "片碱"}, {"prod1": []}), brand_by_stop={ "lp1": [ { "brandId": "b1", "brandName": "君正", "qty": Decimal("20"), "unitPrice": Decimal("2820"), }, { "brandId": "b2", "brandName": "红四方", "qty": Decimal("12"), "unitPrice": Decimal("2820"), }, ] }, ) load_rows = [r for r in rows if r.get("_stopKind") == "load"] assert len(load_rows) == 2 assert [r["brandName"] for r in load_rows] == ["君正", "红四方"] assert [r["actualQty"] for r in load_rows] == [Decimal("20"), Decimal("12")] assert all(r["planQty"] == Decimal("34") for r in load_rows) assert load_rows[0]["amount"] == Decimal("20") * Decimal("2820") def test_plan_common_export_fields_merges_link_contract_nos(): fields = _plan_common_export_fields( _FakePlan(), None, contract_nos=["SX/X-JT20260827001", "SX/X-JT20260827002"], ) assert fields["contractNo"] == "SX/X-JT20260827001、SX/X-JT20260827002" def test_build_plan_archive_export_rows_internal_customer_contract_per_segment(): """内部客户缺合同:装货行合同为出库,送货行为入库,不再拼在同一行。""" internal = next(iter(INTERNAL_CUSTOMER_NAMES)) load_product = _stop_product(id="lp1", inboundQty=Decimal("10")) delivery_product = _stop_product(id="dp1", outboundQty=Decimal("10")) load = PlanLoadRead( id="load1", planId="p1", tenantNo="t1", customerName=internal, products=[load_product], contracts=[], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) delivery = PlanDeliveryRead( id="del1", planId="p1", tenantNo="t1", seq=1, customerName=internal, products=[delivery_product], contracts=[], createTime="2026-08-27T00:00:00", updateTime="2026-08-27T00:00:00", ) rows = _archive_rows(_bare_plan(contract_no=""), load, [delivery]) assert len(rows) == 2 assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"] assert rows[0]["contractNo"] == "出库" assert rows[0]["loadCustomer"] == internal assert rows[0]["deliveryCustomer"] == "" assert rows[1]["contractNo"] == "入库" assert rows[1]["deliveryCustomer"] == internal assert rows[1]["loadCustomer"] == "" class _CellResolver: def row_to_cells(self, row, columns): return ["" if row.get(column.field) is None else row.get(column.field) for column in columns] _MERGE_COLUMNS = [ ExportColumn(title="序号", field="rowNo"), ExportColumn(title="计划编号", field="planNo"), ExportColumn(title="装货客户", field="loadCustomer"), ExportColumn(title="送货客户", field="deliveryCustomer"), ExportColumn(title="合同编号", field="contractNo"), ExportColumn(title="产品名称", field="productName"), ExportColumn(title="计划量", field="planQty"), ] def _merged_ranges(sheet): return {str(item) for item in sheet.merged_cells.ranges} def test_prepare_single_delivery_puts_delivery_customer_on_first_row(): internal = next(iter(INTERNAL_CUSTOMER_NAMES)) load = PlanLoadRead( id="load1", planId="p1", tenantNo="t1", customerName="合肥玉坤", address="装货地址", loadTime="2026-10-09T00:00:00", products=[ _stop_product(id="lp1", productName="片碱", planQty=Decimal("0.9")), _stop_product(id="lp2", productName="熟石灰", planQty=Decimal("1.75")), ], contracts=[], createTime="2026-10-09T00:00:00", updateTime="2026-10-09T00:00:00", ) delivery = PlanDeliveryRead( id="del1", planId="p1", tenantNo="t1", seq=1, customerName="凤阳京环", address="送货地址", deliveryTime="2026-10-09T00:00:00", products=[ _stop_product(id="dp1", productName="片碱", planQty=Decimal("0.9")), _stop_product(id="dp2", productName="熟石灰", planQty=Decimal("1.75")), ], contracts=[], createTime="2026-10-09T00:00:00", updateTime="2026-10-09T00:00:00", ) rows = _archive_rows(_bare_plan(plan_type="direct", customer_name=internal), load, [delivery]) display = prepare_archive_list_display_rows(rows) assert [row["rowNo"] for row in display] == [1, 1, 1, 1] assert display[0]["deliveryCustomer"] == "凤阳京环" assert display[0]["loadCustomer"] == "合肥玉坤" assert [row["productName"] for row in display] == ["片碱", "熟石灰", "片碱", "熟石灰"] assert display[0]["_contractMergeKey"] != display[2]["_contractMergeKey"] def test_merge_sheet_matches_list_rowspan(): from openpyxl import Workbook internal = next(iter(INTERNAL_CUSTOMER_NAMES)) load = PlanLoadRead( id="load1", planId="p1", tenantNo="t1", customerName=internal, products=[ _stop_product(id="lp1", productName="氯化钠", planQty=Decimal("0.45")), _stop_product(id="lp2", productName="次氯酸钠", planQty=Decimal("1.17")), ], contracts=[], createTime="2026-10-07T00:00:00", updateTime="2026-10-07T00:00:00", ) delivery_a = PlanDeliveryRead( id="del1", planId="p1", tenantNo="t1", seq=1, customerName="安庆奇创", deliveryTime="2026-10-07T00:00:00", products=[_stop_product(id="dp1", productName="次氯酸钠", planQty=Decimal("1.17"))], contracts=[], createTime="2026-10-07T00:00:00", updateTime="2026-10-07T00:00:00", ) delivery_b = PlanDeliveryRead( id="del2", planId="p1", tenantNo="t1", seq=2, customerName="安庆奇创", deliveryTime="2026-10-07T00:00:00", products=[_stop_product(id="dp2", productName="氯化钠", planQty=Decimal("0.45"))], contracts=[], createTime="2026-10-07T00:00:00", updateTime="2026-10-07T00:00:00", ) rows = _archive_rows(_bare_plan(plan_type="outbound"), load, [delivery_a, delivery_b]) workbook = Workbook() workbook.active.title = "计划归档" append_archive_list_merge_sheet(workbook, _MERGE_COLUMNS, rows, _CellResolver()) sheet = workbook[ARCHIVE_MERGED_SHEET_TITLE] assert [cell.value for cell in sheet[1]] == [column.title for column in _MERGE_COLUMNS] assert sheet["F2"].value == "氯化钠" assert sheet["F5"].value == "氯化钠" ranges = _merged_ranges(sheet) assert "A2:A5" in ranges assert "B2:B5" in ranges assert "C2:C5" in ranges assert "D2:D3" in ranges assert "D4:D5" not in ranges assert sheet["D2"].value == "—" assert sheet["D4"].value == "安庆奇创" assert sheet["D5"].value == "安庆奇创" assert "F2:F5" not in ranges