344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""Candidates from the generator's state 1: packet keys, routing, go-live, tracked keys, revs and the BOM rules."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import date, timedelta
|
||
|
|
from decimal import Decimal
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from feedhelpers import gen
|
||
|
|
|
||
|
|
from packetstation.feed import candidates, orders
|
||
|
|
from packetstation.feed.materials import index
|
||
|
|
from packetstation.feed.types import Material, WoKey, parse_material, parse_orders, parse_row
|
||
|
|
|
||
|
|
TODAY = date.today()
|
||
|
|
WINDOW = (TODAY - timedelta(days=14), TODAY + timedelta(days=30))
|
||
|
|
|
||
|
|
|
||
|
|
def k(base: str, lot: str, split: str = "0", sub: str = "0") -> WoKey:
|
||
|
|
return WoKey(base, lot, split, sub)
|
||
|
|
|
||
|
|
|
||
|
|
def state(n: int = 1) -> dict[str, Any]:
|
||
|
|
return gen.feed_state(n, TODAY)
|
||
|
|
|
||
|
|
|
||
|
|
def material_legs(feed: dict[str, Any]) -> set[WoKey]:
|
||
|
|
return {k(m["wo"], m["lot"], m["split"], m["sub"]) for m in feed["materials"] if m["st"] in ("F", "R")}
|
||
|
|
|
||
|
|
|
||
|
|
def build(
|
||
|
|
n: int = 1,
|
||
|
|
*,
|
||
|
|
tracked: set[WoKey] | None = None,
|
||
|
|
golive: date | None = None,
|
||
|
|
departments: tuple[str, ...] = (),
|
||
|
|
mats: bool = True,
|
||
|
|
) -> candidates.Built:
|
||
|
|
feed = state(n)
|
||
|
|
rows = [parse_row(r) for r in feed["rows"]]
|
||
|
|
materials = [parse_material(m) for m in feed["materials"]] if mats else None
|
||
|
|
return candidates.build(
|
||
|
|
rows,
|
||
|
|
materials,
|
||
|
|
material_legs(feed) if mats else None,
|
||
|
|
resource_map=gen.RESOURCE_MAP,
|
||
|
|
window=WINDOW,
|
||
|
|
golive=golive,
|
||
|
|
departments=departments,
|
||
|
|
tracked_keys=tracked or set(),
|
||
|
|
today=TODAY,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_feed_file_shape() -> None:
|
||
|
|
feed = state(1)
|
||
|
|
assert set(feed) >= {"meta", "rows", "materials", "orders_by_base", "orders_by_project"}
|
||
|
|
row = feed["rows"][0]
|
||
|
|
assert set(row) >= {
|
||
|
|
"wo",
|
||
|
|
"lot",
|
||
|
|
"split",
|
||
|
|
"sub",
|
||
|
|
"seq",
|
||
|
|
"res",
|
||
|
|
"dwg",
|
||
|
|
"rev",
|
||
|
|
"ps",
|
||
|
|
"desc",
|
||
|
|
"qty",
|
||
|
|
"tgt",
|
||
|
|
"rel",
|
||
|
|
"want",
|
||
|
|
"st",
|
||
|
|
"barcode",
|
||
|
|
"part",
|
||
|
|
}
|
||
|
|
words = {r["st"] for r in feed["rows"]}
|
||
|
|
assert words <= {"Firmed", "Released", "Unreleased", "Closed", "Cancelled"}
|
||
|
|
assert {m["st"] for m in feed["materials"]} <= {"F", "R", "U", "C", "X"}
|
||
|
|
for r in feed["rows"]:
|
||
|
|
assert isinstance(r["seq"], int) and isinstance(r["lot"], str) and isinstance(r["split"], str)
|
||
|
|
assert isinstance(r["qty"], int | float) and not isinstance(r["qty"], bool)
|
||
|
|
assert r["rel"] is None or date.fromisoformat(r["rel"])
|
||
|
|
for m in feed["materials"]:
|
||
|
|
assert isinstance(m["piece"], int) and (m["part"] is None or isinstance(m["part"], str))
|
||
|
|
assert any(r["lot"] == "7A" for r in feed["rows"])
|
||
|
|
assert any(m["part"] is None for m in feed["materials"])
|
||
|
|
|
||
|
|
|
||
|
|
def test_generator_is_deterministic(tmp_path: Any) -> None:
|
||
|
|
a, b = tmp_path / "a", tmp_path / "b"
|
||
|
|
gen.generate(a, TODAY)
|
||
|
|
gen.generate(b, TODAY)
|
||
|
|
for name in ("feed.1.json", "feed.2.json", "drawings.1.json", "drawings.2.json", "state"):
|
||
|
|
assert (a / name).read_bytes() == (b / name).read_bytes(), name
|
||
|
|
assert (a / "state").read_text() == "1"
|
||
|
|
assert (a / "L900-0101 r1.pdf").read_bytes().startswith(b"%PDF-")
|
||
|
|
assert (a / "L900-0101 r1.pdf").read_bytes() != (a / "L900-0101 r2.pdf").read_bytes()
|
||
|
|
|
||
|
|
|
||
|
|
def test_packet_keys() -> None:
|
||
|
|
built = build()
|
||
|
|
assert set(built.candidates) == set(gen.STATE1_KEYS)
|
||
|
|
c = built.candidates[gen.KEY_WELD_PART]
|
||
|
|
assert (c.project, c.unit, c.lot, c.dept, c.op, c.drawing) == (
|
||
|
|
"L900",
|
||
|
|
"L900001",
|
||
|
|
"001",
|
||
|
|
"03",
|
||
|
|
"WELD-PART",
|
||
|
|
"L900-0101",
|
||
|
|
)
|
||
|
|
assert c.base == "L900-01" and c.in_window and c.title == "Inlet spool 6 in"
|
||
|
|
assert built.candidates[gen.KEY_LAYOUT_A].dept == "01"
|
||
|
|
nd = built.candidates[gen.KEY_NO_DWG]
|
||
|
|
assert nd.drawing == "" and nd.key.endswith("|(no drawing)")
|
||
|
|
|
||
|
|
|
||
|
|
def test_two_operations_at_one_resource_are_two_legs() -> None:
|
||
|
|
c = build().candidates[gen.KEY_TWO_OPS]
|
||
|
|
assert [(lg.key, lg.seq) for lg in c.legs] == [(k("L900-02", "1"), 10), (k("L900-02", "1"), 20)]
|
||
|
|
assert [lg.tgt for lg in c.legs] == [Decimal(4), Decimal(2)]
|
||
|
|
|
||
|
|
|
||
|
|
def test_fold_and_ignore() -> None:
|
||
|
|
c = build().candidates[gen.KEY_WELD_PART]
|
||
|
|
folded = {(f.seq, f.op) for f in c.folded}
|
||
|
|
assert folded == {(30, "MFG"), (50, "MAT_PIPE")}
|
||
|
|
assert all(lg.op not in ("MFG", "FORKLIFT", "MAT_PIPE") for cand in build().candidates.values() for lg in cand.legs)
|
||
|
|
# the LAYOUT packet of the same work order and lot folds the same operations
|
||
|
|
assert {f.seq for f in build().candidates[gen.KEY_LAYOUT_A].folded} == {30, 50}
|
||
|
|
assert candidates.route("MAT_STEEL", {}) == "fold" and candidates.route("FORKLIFT", gen.RESOURCE_MAP) == "ignore"
|
||
|
|
assert candidates.route("NEWRES", gen.RESOURCE_MAP) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_partspecs_distinct_in_first_seen_order() -> None:
|
||
|
|
c = build().candidates[gen.KEY_WELD_PART]
|
||
|
|
assert c.partspecs == ["Cut to length per sheet 1", "Weld per WPS-11\nBack purge"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_unmapped_counts() -> None:
|
||
|
|
assert build().unmapped == {"NEWRES": 2}
|
||
|
|
|
||
|
|
|
||
|
|
def test_lettered_lot_makes_no_candidate() -> None:
|
||
|
|
built = build()
|
||
|
|
assert built.skipped_lots == {"L900-14/7A": 1}
|
||
|
|
assert candidates.unit_number("L900", "7A") is None
|
||
|
|
assert candidates.unit_number("L900", "1") == "L900001"
|
||
|
|
assert candidates.unit_number("L900", "012") == "L900012"
|
||
|
|
|
||
|
|
|
||
|
|
def test_golive_ignores_old_releases() -> None:
|
||
|
|
golive = TODAY + timedelta(days=gen.GOLIVE_OFFSET)
|
||
|
|
built = build(golive=golive)
|
||
|
|
assert gen.KEY_GOLIVE not in built.candidates
|
||
|
|
assert [(r.wo, r.lot, r.seq) for r in built.ignored] == [("L900-13", "2", 10)]
|
||
|
|
# tracked keys come through whatever their release date
|
||
|
|
built = build(golive=golive, tracked={k("L900-13", "2")})
|
||
|
|
assert gen.KEY_GOLIVE in built.candidates and built.ignored == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_departments_filter() -> None:
|
||
|
|
built = build(departments=("06",))
|
||
|
|
assert built.candidates and {c.dept for c in built.candidates.values()} == {"06"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_tracked_keys_outside_the_window_and_status() -> None:
|
||
|
|
assert gen.KEY_RELEASED not in build().candidates # Released: not in the window set
|
||
|
|
tracked = {k("L900-06", "2"), k("L900-01", "1", "1"), k("L900-11", "1"), k("L900-12", "1")}
|
||
|
|
built = build(tracked=tracked)
|
||
|
|
rel = built.candidates[gen.KEY_RELEASED]
|
||
|
|
assert not rel.in_window and rel.statuses == {k("L900-06", "2"): "R"}
|
||
|
|
wp = built.candidates[gen.KEY_WELD_PART]
|
||
|
|
assert wp.statuses == {k("L900-01", "1"): "F", k("L900-01", "1", "1"): "U"}
|
||
|
|
assert {(lg.key.split, lg.seq) for lg in wp.legs} == {("0", 20), ("1", 20)}
|
||
|
|
assert built.candidates["L900001|WELD-CODE|L900-1101"].statuses == {k("L900-11", "1"): "C"}
|
||
|
|
assert built.candidates["L900001|WELD-CODE|L900-1201"].statuses == {k("L900-12", "1"): "X"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_rel_min_want_max() -> None:
|
||
|
|
c = build().candidates[gen.KEY_REV_A] # legs L900-04 (rel -4, want 22) and L900-08-1 (rel -2, want 24)
|
||
|
|
assert c.rel == TODAY - timedelta(days=4) and c.want == TODAY + timedelta(days=24)
|
||
|
|
|
||
|
|
|
||
|
|
def test_rev_highest_numeric() -> None:
|
||
|
|
c = build().candidates[gen.KEY_REV_A]
|
||
|
|
assert c.rev == "A" # the non-numeric rev holds against an empty rev
|
||
|
|
built = build(2, tracked={k("L900-01", "1", "1")})
|
||
|
|
assert built.candidates[gen.KEY_WELD_PART].rev == "2"
|
||
|
|
|
||
|
|
|
||
|
|
def _row(seq: int, rev: str) -> Any:
|
||
|
|
return parse_row(
|
||
|
|
{
|
||
|
|
"wo": "L900-20",
|
||
|
|
"lot": "1",
|
||
|
|
"split": "0",
|
||
|
|
"sub": "0",
|
||
|
|
"seq": seq,
|
||
|
|
"res": "WELD-PART",
|
||
|
|
"dwg": "D-1",
|
||
|
|
"rev": rev,
|
||
|
|
"qty": 1,
|
||
|
|
"tgt": 1,
|
||
|
|
"rel": TODAY.isoformat(),
|
||
|
|
"st": "Firmed",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"revs,expect",
|
||
|
|
[(["1", "3", "2"], "3"), (["", "2"], "2"), (["2", "A"], "2"), (["A", "2"], "A"), (["10", "9"], "10")],
|
||
|
|
)
|
||
|
|
def test_rev_rule(revs: list[str], expect: str) -> None:
|
||
|
|
rows = [_row(10 * (i + 1), r) for i, r in enumerate(revs)]
|
||
|
|
built = candidates.build(
|
||
|
|
rows,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
resource_map=gen.RESOURCE_MAP,
|
||
|
|
window=WINDOW,
|
||
|
|
golive=None,
|
||
|
|
departments=(),
|
||
|
|
tracked_keys=set(),
|
||
|
|
today=TODAY,
|
||
|
|
)
|
||
|
|
assert built.candidates["L900001|WELD-PART|D-1"].rev == expect
|
||
|
|
|
||
|
|
|
||
|
|
def _bom(key: str, n: int = 1, tracked: set[WoKey] | None = None) -> dict[str, Any]:
|
||
|
|
c = build(n, tracked=tracked).candidates[key]
|
||
|
|
assert c.bom is not None
|
||
|
|
return {b.item: b for b in c.bom}
|
||
|
|
|
||
|
|
|
||
|
|
def test_bom_split_items() -> None:
|
||
|
|
bom = _bom(gen.KEY_LAYOUT_A)
|
||
|
|
assert set(bom) == {"11", "12"} # the piece 10 header has no part: not a line
|
||
|
|
assert bom["11"].grp == "10" and bom["12"].grp == "10"
|
||
|
|
assert bom["12"].cons is True and bom["11"].cons is False
|
||
|
|
assert bom["11"].vkey == "L900-01|1|0|10|11"
|
||
|
|
|
||
|
|
|
||
|
|
def test_bom_main_loc_wip_sub_and_dwg_filter() -> None:
|
||
|
|
bom = _bom(gen.KEY_WELD_PART)
|
||
|
|
assert set(bom) == {"1", "2", "4"} # piece 3 is on another drawing: dropped; piece 4 has a blank drawing
|
||
|
|
assert bom["1"].loc == "A-01" and bom["1"].whs == "MAIN" # MAIN is left out of loc
|
||
|
|
assert bom["4"].loc == "SHOP R1"
|
||
|
|
assert bom["2"].sub is True and bom["2"].na is True
|
||
|
|
assert bom["1"].sub is False and bom["1"].issued == Decimal("12.5") and bom["1"].qty == Decimal("12.5")
|
||
|
|
assert all(b.grp is None for b in bom.values())
|
||
|
|
|
||
|
|
|
||
|
|
def test_bom_duplicate_items() -> None:
|
||
|
|
bom = _bom(gen.KEY_REV_A)
|
||
|
|
assert set(bom) == {"1", "1-1", "2"}
|
||
|
|
assert bom["1"].vkey.startswith("L900-04|") and bom["1-1"].vkey.startswith("L900-08-1|")
|
||
|
|
assert bom["2"].istat == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_bom_two_operations() -> None:
|
||
|
|
bom = _bom(gen.KEY_TWO_OPS)
|
||
|
|
assert set(bom) == {"1", "2"} and bom["2"].istat == 3
|
||
|
|
|
||
|
|
|
||
|
|
def test_released_leg_counts_as_known() -> None:
|
||
|
|
bom = _bom(gen.KEY_RELEASED, tracked={k("L900-06", "2")})
|
||
|
|
assert set(bom) == {"1"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_unreleased_leg_alone_is_unknown() -> None:
|
||
|
|
# A2 (Unreleased) is not a material leg; on the WELD-PART packet the firmed leg still makes it known
|
||
|
|
built = build(tracked={k("L900-01", "1", "1")})
|
||
|
|
assert built.candidates[gen.KEY_WELD_PART].bom is not None
|
||
|
|
|
||
|
|
|
||
|
|
def test_mat_known_none() -> None:
|
||
|
|
built = build()
|
||
|
|
assert built.candidates[gen.KEY_NO_DWG].bom is None # C has no material lines: not a material leg
|
||
|
|
assert all(c.bom is None for c in build(mats=False).candidates.values())
|
||
|
|
|
||
|
|
|
||
|
|
def test_best_status_per_piece() -> None:
|
||
|
|
key = k("L900-30", "1")
|
||
|
|
|
||
|
|
def m(st: str, qty: int) -> Material:
|
||
|
|
return parse_material(
|
||
|
|
{
|
||
|
|
"wo": "L900-30",
|
||
|
|
"lot": "1",
|
||
|
|
"split": "0",
|
||
|
|
"sub": "0",
|
||
|
|
"seq": 10,
|
||
|
|
"piece": 1,
|
||
|
|
"part": "P-1",
|
||
|
|
"qty": qty,
|
||
|
|
"issued": 0,
|
||
|
|
"st": st,
|
||
|
|
"dwg": "D-9",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
leg = candidates.Leg(
|
||
|
|
key=key, seq=10, op="WELD-PART", qty=Decimal(1), tgt=Decimal(1), dwg="D-9", rev="1", partspecs="", st="F"
|
||
|
|
)
|
||
|
|
from packetstation.feed.materials import bom_for
|
||
|
|
|
||
|
|
for lines, qty in (
|
||
|
|
([m("U", 3), m("F", 2), m("R", 1)], 1),
|
||
|
|
([m("U", 3), m("F", 2)], 2),
|
||
|
|
([m("C", 4), m("U", 3)], 3),
|
||
|
|
):
|
||
|
|
bom = bom_for([leg], index(lines))
|
||
|
|
assert [(b.item, b.qty) for b in bom] == [("1", Decimal(qty))]
|
||
|
|
|
||
|
|
|
||
|
|
def test_orders_unit_rule_on_fixture_orders() -> None:
|
||
|
|
feed = state(1)
|
||
|
|
by_base, by_project = parse_orders(feed["orders_by_base"]), parse_orders(feed["orders_by_project"])
|
||
|
|
assert by_base is not None and by_project is not None
|
||
|
|
|
||
|
|
def pick(unit: str, base: str, project: str = "L900", pin: str | None = None) -> tuple[str, str] | None:
|
||
|
|
ch = orders.choose(
|
||
|
|
unit_number=unit, base=base, by_base=by_base, by_project=by_project, project=project, pin=pin
|
||
|
|
)
|
||
|
|
return None if ch is None else (ch.order.cust_order_id, ch.via)
|
||
|
|
|
||
|
|
assert pick("L900001", "L900-01") == (gen.ORDER_BAYOU, "unit_top_job")
|
||
|
|
assert pick("L900002", "L900-04") == (gen.ORDER_ACME, "base")
|
||
|
|
assert pick("L900002", "L900-07") == (gen.ORDER_ACME, "unit_top_job")
|
||
|
|
assert pick("L900003", "L900-99") == (gen.ORDER_ACME, "project_open")
|
||
|
|
assert pick("L901001", "L901-01", "L901") == (gen.ORDER_L901, "unit_top_job")
|
||
|
|
assert pick("L901002", "L901-02", "L901") == (gen.ORDER_L901, "project_newest")
|
||
|
|
assert pick("L900001", "L900-01", pin=gen.ORDER_ACME) == (gen.ORDER_ACME, "pin")
|
||
|
|
assert pick("L900001", "L900-01", pin="C99-0000") == (gen.ORDER_BAYOU, "unit_top_job") # not the project's
|
||
|
|
assert pick("L902001", "L902-01", "L902") is None
|
||
|
|
d = orders.derive(by_project["L900"][1])
|
||
|
|
assert d["customer"] == "Bayou Chemical" and d["po"] == "PO-2002"
|
||
|
|
assert d["ship_date"] == TODAY + timedelta(days=70) # no promise date: the desired ship date
|