| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533 |
- """合同产品运输安排数量汇总单元测试。"""
- from types import SimpleNamespace
- from collections import defaultdict
- from app.services.pl_contract_plan_qty import (
- _bucket_for_plan,
- accumulate_stop_products,
- attach_plan_qty_fields_to_products,
- match_contract_product_for_key,
- merge_product_qty_maps,
- normalize_package_spec,
- product_package_key,
- resolve_linked_plan_scopes,
- sum_qtys_for_scope,
- )
- def test_bucket_archived():
- assert (
- _bucket_for_plan(
- archive_status="archived",
- follow_status="arranged",
- transport_status="arranged",
- )
- == "archived"
- )
- def test_bucket_arranged_unarchived_includes_draft_archive():
- assert (
- _bucket_for_plan(
- archive_status="draft",
- follow_status="arranged",
- transport_status="arranged",
- )
- == "arranged_unarchived"
- )
- def test_bucket_skips_pending_and_cancelled():
- assert (
- _bucket_for_plan(
- archive_status="unarchived",
- follow_status="pending_arrange",
- transport_status="pending",
- )
- is None
- )
- assert (
- _bucket_for_plan(
- archive_status="unarchived",
- follow_status="cancelled",
- transport_status="arranged",
- )
- is None
- )
- def test_normalize_package_spec():
- from app.services.pl_contract_plan_qty import normalize_package_spec
- assert normalize_package_spec(None) == ""
- assert normalize_package_spec(" ") == ""
- assert normalize_package_spec(" 槽罐车 ") == "槽罐车"
- assert normalize_package_spec("25kg/桶") != normalize_package_spec("25Kg/袋")
- def test_accumulate_splits_same_product_different_package():
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [
- SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", plan_qty=15),
- SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", plan_qty=700),
- SimpleNamespace(product_id="SXWL007344", package_spec=" 槽罐车 ", plan_qty=10),
- ],
- )
- k_bucket = ("SXWL007344", "25kg/桶")
- k_tank = ("SXWL007344", "槽罐车")
- assert acc[k_bucket] == 15.0
- assert acc[k_tank] == 710.0
- def test_merge_product_qty_maps_composite_keys():
- out = merge_product_qty_maps(
- archived={("p1", "A"): 10.0},
- arranged_unarchived={("p1", "B"): 20.0, ("p1", "A"): 5.0},
- )
- assert out[("p1", "A")]["arrangedTotalQty"] == 15.0
- assert out[("p1", "B")]["arrangedTotalQty"] == 20.0
- def test_attach_plan_qty_fields_matches_package():
- qty_map = {
- ("pid1", "25kg/桶"): {
- "archivedQty": 1.0,
- "arrangedUnarchivedQty": 2.0,
- "arrangedTotalQty": 3.0,
- },
- ("pid1", "槽罐车"): {
- "archivedQty": 10.0,
- "arrangedUnarchivedQty": 20.0,
- "arrangedTotalQty": 30.0,
- },
- }
- products = [
- {"productId": "pid1", "packageSpec": "25kg/桶"},
- {"productId": "pid1", "packageSpec": "槽罐车"},
- ]
- attach_plan_qty_fields_to_products(products, qty_map)
- assert products[0]["arrangedTotalQty"] == 3.0
- assert products[1]["arrangedTotalQty"] == 30.0
- def test_merge_product_qty_maps_totals():
- out = merge_product_qty_maps(
- archived={("p1", ""): 120.0, ("p2", ""): 50.0},
- arranged_unarchived={("p1", ""): 80.0, ("p3", ""): 100.0},
- )
- assert out[("p1", "")]["archivedQty"] == 120.0
- assert out[("p1", "")]["arrangedUnarchivedQty"] == 80.0
- assert out[("p1", "")]["arrangedTotalQty"] == 200.0
- assert out[("p2", "")]["arrangedTotalQty"] == 50.0
- assert out[("p3", "")]["arrangedTotalQty"] == 100.0
- def test_sum_qtys_explicit_delivery_stop_only():
- load_products = {
- "L1": [SimpleNamespace(product_id="p1", plan_qty=10)],
- }
- delivery_products = {
- "D1": [SimpleNamespace(product_id="p1", plan_qty=40)],
- "D2": [SimpleNamespace(product_id="p1", plan_qty=50)],
- }
- qtys = sum_qtys_for_scope(
- load_products_by_load=load_products,
- delivery_products_by_delivery=delivery_products,
- all_load_ids={"L1"},
- all_delivery_ids={"D1", "D2"},
- scope={"load_ids": set(), "delivery_ids": {"D1"}, "use_all_stops": False},
- )
- assert qtys == {("p1", ""): 40.0}
- def test_legacy_use_all_stops_prefers_delivery():
- load_products = {
- "L1": [SimpleNamespace(product_id="p1", plan_qty=99)],
- }
- delivery_products = {
- "D1": [SimpleNamespace(product_id="p1", plan_qty=40)],
- }
- qtys = sum_qtys_for_scope(
- load_products_by_load=load_products,
- delivery_products_by_delivery=delivery_products,
- all_load_ids={"L1"},
- all_delivery_ids={"D1"},
- scope={"load_ids": set(), "delivery_ids": set(), "use_all_stops": True},
- )
- assert qtys == {("p1", ""): 40.0}
- def test_resolve_scopes_marks_legacy():
- links = [
- SimpleNamespace(plan_id="A", side="delivery", stop_id="D1"),
- ]
- scopes = resolve_linked_plan_scopes(links, ["B"])
- assert scopes["A"]["delivery_ids"] == {"D1"}
- assert scopes["A"]["has_explicit"] is True
- assert scopes["B"]["use_all_stops"] is True
- def test_accumulate_stop_products():
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [
- SimpleNamespace(product_id="p1", plan_qty=DecimalLike(12.5)),
- SimpleNamespace(product_id="p1", plan_qty=7.5),
- SimpleNamespace(product_id="", plan_qty=9),
- ],
- )
- assert acc[("p1", "")] == 20.0
- def test_accumulate_archive_actual_load_uses_inbound_qty():
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [
- SimpleNamespace(product_id="p1", plan_qty=100, inbound_qty=88),
- SimpleNamespace(product_id="p1", plan_qty=50, inbound_qty=None),
- ],
- stop_kind="load",
- qty_mode="archive_actual",
- )
- # 88 + 回退 plan_qty 50
- assert acc[("p1", "")] == 138.0
- def test_accumulate_archive_actual_delivery_uses_outbound_qty():
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [SimpleNamespace(product_id="p1", plan_qty=100, outbound_qty=66)],
- stop_kind="delivery",
- qty_mode="archive_actual",
- )
- assert acc[("p1", "")] == 66.0
- def test_accumulate_archive_actual_zero_does_not_fallback():
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [SimpleNamespace(product_id="p1", plan_qty=100, inbound_qty=0)],
- stop_kind="load",
- qty_mode="archive_actual",
- )
- assert acc[("p1", "")] == 0.0
- def test_sum_qtys_archive_actual_mode_on_scoped_stops():
- load_products = {
- "L1": [SimpleNamespace(product_id="p1", plan_qty=10, inbound_qty=7)],
- }
- delivery_products = {
- "D1": [SimpleNamespace(product_id="p1", plan_qty=40, outbound_qty=33)],
- }
- qtys = sum_qtys_for_scope(
- load_products_by_load=load_products,
- delivery_products_by_delivery=delivery_products,
- all_load_ids={"L1"},
- all_delivery_ids={"D1"},
- scope={"load_ids": {"L1"}, "delivery_ids": {"D1"}, "use_all_stops": False},
- qty_mode="archive_actual",
- )
- assert qtys == {("p1", ""): 40.0} # 7 + 33
- def test_sum_qtys_default_mode_ignores_actual_fields():
- load_products = {
- "L1": [SimpleNamespace(product_id="p1", plan_qty=10, inbound_qty=7)],
- }
- delivery_products = {
- "D1": [SimpleNamespace(product_id="p1", plan_qty=40, outbound_qty=33)],
- }
- qtys = sum_qtys_for_scope(
- load_products_by_load=load_products,
- delivery_products_by_delivery=delivery_products,
- all_load_ids={"L1"},
- all_delivery_ids={"D1"},
- scope={"load_ids": set(), "delivery_ids": {"D1"}, "use_all_stops": False},
- )
- assert qtys == {("p1", ""): 40.0}
- def test_match_contract_product_for_key_picks_package():
- products = [
- SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", quantity=15),
- SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", quantity=700),
- ]
- bucket = match_contract_product_for_key(
- products, product_package_key("SXWL007344", "25kg/桶")
- )
- tank = match_contract_product_for_key(
- products, product_package_key("SXWL007344", " 槽罐车 ")
- )
- assert bucket is not None and float(bucket.quantity) == 15.0
- assert tank is not None and float(tank.quantity) == 700.0
- def test_match_contract_product_for_key_sums_duplicate_keys():
- products = [
- SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=100),
- SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=50),
- ]
- row = match_contract_product_for_key(products, ("p1", "槽罐车"))
- assert row is not None and float(row.quantity) == 150.0
- def test_match_contract_product_for_key_missing():
- products = [SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=10)]
- assert match_contract_product_for_key(products, ("p1", "25kg/桶")) is None
- def test_match_shared_pool_line_json_package_spec():
- from app.utils.crm_package_spec import encode_package_specs
- products = [
- SimpleNamespace(
- id="L1",
- product_id="SXWL007344",
- package_spec=encode_package_specs(["25kg/桶", "槽罐车"]),
- quantity=715,
- )
- ]
- bucket = match_contract_product_for_key(
- products, product_package_key("SXWL007344", "25kg/桶")
- )
- tank = match_contract_product_for_key(
- products, product_package_key("SXWL007344", "槽罐车")
- )
- assert bucket is tank is not None
- assert float(bucket.quantity) == 715.0
- def test_attach_plan_qty_shared_pool_breakdown():
- from app.utils.crm_package_spec import encode_package_specs
- products = [
- {
- "id": "L1",
- "productId": "pid1",
- "quantity": 715,
- "packageSpec": encode_package_specs(["25kg/桶", "槽罐车"]),
- }
- ]
- qty_map = {
- ("pid1", "25kg/桶"): {
- "archivedQty": 0.0,
- "arrangedUnarchivedQty": 10.0,
- "arrangedTotalQty": 10.0,
- },
- ("pid1", "槽罐车"): {
- "archivedQty": 0.0,
- "arrangedUnarchivedQty": 10.0,
- "arrangedTotalQty": 10.0,
- },
- }
- attach_plan_qty_fields_to_products(products, qty_map)
- assert products[0]["arrangedTotalQty"] == 20.0
- assert products[0]["remainingQty"] == 695.0
- assert products[0]["packageSpecs"] == ["25kg/桶", "槽罐车"]
- assert len(products[0]["packageQtyBreakdown"]) == 2
- assert products[0]["packageQtyBreakdown"][0]["arrangedTotalQty"] == 10.0
- assert products[0]["packageQtyBreakdown"][1]["arrangedTotalQty"] == 10.0
- def test_sum_qty_stats_for_keys_shared_pool():
- from app.services.pl_contract_plan_qty import sum_qty_stats_for_keys
- qty_map = {
- ("pid1", "25kg/桶"): {
- "archivedQty": 0.0,
- "arrangedUnarchivedQty": 10.0,
- "arrangedTotalQty": 10.0,
- },
- ("pid1", "槽罐车"): {
- "archivedQty": 0.0,
- "arrangedUnarchivedQty": 10.0,
- "arrangedTotalQty": 10.0,
- },
- }
- stats = sum_qty_stats_for_keys(
- qty_map, [("pid1", "25kg/桶"), ("pid1", "槽罐车")]
- )
- assert stats["arrangedTotalQty"] == 20.0
- class DecimalLike:
- def __init__(self, v):
- self.v = v
- def __float__(self):
- return float(self.v)
- def test_accumulate_prefers_contract_product_id():
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [
- SimpleNamespace(
- product_id="p1",
- package_spec="槽罐车",
- plan_qty=10,
- contract_product_id="line-a",
- ),
- SimpleNamespace(
- product_id="p1",
- package_spec="槽罐车",
- plan_qty=20,
- contract_product_id="line-b",
- ),
- ],
- )
- from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX
- assert acc[(LINE_OCCUPANCY_PREFIX, "line-a")] == 10.0
- assert acc[(LINE_OCCUPANCY_PREFIX, "line-b")] == 20.0
- assert ("p1", "槽罐车") not in acc
- def test_attach_plan_qty_independent_duplicate_lines():
- from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX
- products = [
- {"id": "line-a", "productId": "pid1", "packageSpec": "槽罐车", "quantity": 50},
- {"id": "line-b", "productId": "pid1", "packageSpec": "槽罐车", "quantity": 40},
- ]
- qty_map = {
- (LINE_OCCUPANCY_PREFIX, "line-a"): {
- "archivedQty": 0.0,
- "arrangedUnarchivedQty": 10.0,
- "arrangedTotalQty": 10.0,
- }
- }
- attach_plan_qty_fields_to_products(products, qty_map)
- assert products[0]["arrangedTotalQty"] == 10.0
- assert products[0]["remainingQty"] == 40.0
- assert products[1]["arrangedTotalQty"] == 0.0
- assert products[1]["remainingQty"] == 40.0
- def test_attach_plan_qty_line_bound_multi_package_breakdown():
- """绑定合同行后数量记在 __line__;多包装拆开展示须带上对应包装的归档量。"""
- from app.utils.crm_package_spec import encode_package_specs
- from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX, package_display_key
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [
- SimpleNamespace(
- product_id="pid1",
- package_spec="20kg/袋",
- plan_qty=0.55,
- inbound_qty=0.55,
- contract_product_id="line-2",
- )
- ],
- stop_kind="load",
- qty_mode="archive_actual",
- )
- assert acc[(LINE_OCCUPANCY_PREFIX, "line-2")] == 0.55
- assert ("pid1", "20kg/袋") not in acc
- dkey = package_display_key("line-2", "20kg/袋")
- assert dkey in acc and acc[dkey] == 0.55
- products = [
- {
- "id": "line-2",
- "productId": "pid1",
- "quantity": 1,
- "packageSpec": encode_package_specs(["20kg/袋", "50Kg/桶", "1.2吨/桶"]),
- }
- ]
- qty_map = {
- (LINE_OCCUPANCY_PREFIX, "line-2"): {
- "archivedQty": 0.55,
- "arrangedUnarchivedQty": 0.0,
- "arrangedTotalQty": 0.55,
- },
- dkey: {
- "archivedQty": 0.55,
- "arrangedUnarchivedQty": 0.0,
- "arrangedTotalQty": 0.55,
- },
- }
- attach_plan_qty_fields_to_products(products, qty_map)
- assert products[0]["arrangedTotalQty"] == 0.55
- assert products[0]["remainingQty"] == 0.45
- by_spec = {x["packageSpec"]: x for x in products[0]["packageQtyBreakdown"]}
- assert by_spec["20kg/袋"]["archivedQty"] == 0.55
- assert by_spec["50Kg/桶"]["archivedQty"] == 0.0
- assert by_spec["1.2吨/桶"]["archivedQty"] == 0.0
- def test_package_display_qty_isolated_per_contract_line():
- from app.utils.crm_package_spec import encode_package_specs
- from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX
- acc = defaultdict(float)
- accumulate_stop_products(
- acc,
- [
- SimpleNamespace(
- product_id="pid1",
- package_spec="20kg/袋",
- plan_qty=5.1,
- inbound_qty=5.1,
- contract_product_id="line-1",
- ),
- SimpleNamespace(
- product_id="pid1",
- package_spec="20kg/袋",
- plan_qty=0.55,
- inbound_qty=0.55,
- contract_product_id="line-2",
- ),
- ],
- stop_kind="load",
- qty_mode="archive_actual",
- )
- qty_map = merge_product_qty_maps(archived=dict(acc), arranged_unarchived={})
- products = [
- {
- "id": "line-1",
- "productId": "pid1",
- "quantity": 11,
- "packageSpec": encode_package_specs(["20kg/袋"]),
- },
- {
- "id": "line-2",
- "productId": "pid1",
- "quantity": 1,
- "packageSpec": encode_package_specs(["20kg/袋", "50Kg/桶", "1.2吨/桶"]),
- },
- ]
- attach_plan_qty_fields_to_products(products, qty_map)
- assert products[0]["archivedQty"] == 5.1
- assert products[0]["remainingQty"] == 5.9
- assert products[0]["packageQtyBreakdown"][0]["archivedQty"] == 5.1
- assert products[1]["archivedQty"] == 0.55
- assert products[1]["remainingQty"] == 0.45
- by_spec = {x["packageSpec"]: x for x in products[1]["packageQtyBreakdown"]}
- assert by_spec["20kg/袋"]["archivedQty"] == 0.55
- assert (LINE_OCCUPANCY_PREFIX, "line-1") in acc
- def test_occupancy_keys_do_not_share_package_across_duplicate_lines():
- from app.services.pl_contract_plan_qty import occupancy_keys_for_contract_line
- lines = [
- SimpleNamespace(id="a", product_id="p1", package_spec="槽罐车"),
- SimpleNamespace(id="b", product_id="p1", package_spec="槽罐车"),
- ]
- keys_a = occupancy_keys_for_contract_line(lines[0], lines)
- keys_b = occupancy_keys_for_contract_line(lines[1], lines)
- assert ("__line__", "a") in keys_a
- assert ("p1", "槽罐车") in keys_a
- assert ("__line__", "b") in keys_b
- assert ("p1", "槽罐车") not in keys_b
|