2026-10-16 14:45:38 -06:00
|
|
|
"""The Clerical feed API (/api/feed), rev approval, hand uploads and feed change order approval, on the generator's
|
|
|
|
|
fixture files run through the real feed and drawing jobs."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
from collections.abc import Iterator
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fakedir import FAKE_PASSWORD, FakeDirectory
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
from feedhelpers import (
|
|
|
|
|
cos_of,
|
|
|
|
|
feed_settings_over,
|
|
|
|
|
gen,
|
|
|
|
|
golive_today_offset,
|
|
|
|
|
move_status,
|
|
|
|
|
packet_id,
|
|
|
|
|
run_drawings,
|
|
|
|
|
run_feed,
|
|
|
|
|
set_feed,
|
|
|
|
|
)
|
|
|
|
|
from pkthelpers import SIGN, seed_users, tiny_pdf, upload
|
|
|
|
|
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
|
|
|
|
|
|
|
|
|
|
from packetstation import db
|
|
|
|
|
from packetstation.settings import Settings
|
|
|
|
|
|
|
|
|
|
pytestmark = needs_pg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
|
|
|
|
|
s = feed_settings_over(pg_settings, tmp_path)
|
|
|
|
|
set_feed(s, auto_release=False)
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def client(fx: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
|
|
|
|
from packetstation.main import create_app
|
|
|
|
|
|
|
|
|
|
with TestClient(create_app(fx, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
|
|
|
|
|
seed_users(c, ("admin.ann", "clerk.clara", "clerk.leo", "wh.wade"))
|
|
|
|
|
yield c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def as_user(c: TestClient, sam: str) -> None:
|
|
|
|
|
assert sign_in(c, sam).status_code == 200, sam
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def drawing_pages(conn: Any, pid: int) -> list[tuple[Any, ...]]:
|
|
|
|
|
return [
|
|
|
|
|
tuple(r)
|
|
|
|
|
for r in conn.execute(
|
|
|
|
|
"SELECT n, rev, co_id IS NOT NULL, void_at IS NOT NULL, void_co_id IS NOT NULL FROM packet_pages"
|
|
|
|
|
" WHERE packet_id = %s AND type = 'drawing' ORDER BY n",
|
|
|
|
|
(pid,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def packet_row(conn: Any, pid: int) -> dict[str, Any]:
|
|
|
|
|
from psycopg.rows import dict_row
|
|
|
|
|
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
r = cur.execute("SELECT * FROM packets WHERE id = %s", (pid,)).fetchone()
|
|
|
|
|
assert r is not None
|
|
|
|
|
return dict(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- the From VISUAL panel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_overview_shape(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
set_feed(fx, golive_date=golive_today_offset())
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = client.get("/api/feed/overview")
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
o = r.json()
|
|
|
|
|
assert set(o) >= {
|
|
|
|
|
"status",
|
|
|
|
|
"last_run",
|
|
|
|
|
"runs",
|
|
|
|
|
"drawing_runs",
|
|
|
|
|
"ignored",
|
|
|
|
|
"unmapped",
|
|
|
|
|
"projects_without_names",
|
|
|
|
|
"golive_date",
|
|
|
|
|
"departments",
|
|
|
|
|
}
|
|
|
|
|
assert o["status"]["paused"] is False
|
|
|
|
|
assert o["status"]["worker"] is None or o["status"]["worker"]["stale"] is True
|
|
|
|
|
assert o["last_run"]["status"] == "ok" and o["last_run"]["counts"]["new_drafts"] == len(gen.STATE1_KEYS) - 1
|
|
|
|
|
assert len(o["runs"]) == 1 and len(o["drawing_runs"]) == 1
|
|
|
|
|
assert o["unmapped"] == [{"resource": "NEWRES", "rows": 2}]
|
|
|
|
|
assert [(i["wo_base"], i["lot"]) for i in o["ignored"]] == [("L900-13", "2")]
|
|
|
|
|
assert {p["number"] for p in o["projects_without_names"]} == {"L900", "L901"}
|
|
|
|
|
l900 = next(p for p in o["projects_without_names"] if p["number"] == "L900")
|
|
|
|
|
assert l900["units"] == 2 and l900["packets"] == len(gen.STATE1_KEYS) - 2
|
|
|
|
|
assert o["golive_date"] == golive_today_offset().isoformat() and o["departments"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_api_is_clerical_only(client: TestClient) -> None:
|
|
|
|
|
as_user(client, "wh.wade")
|
|
|
|
|
assert client.get("/api/feed/overview").status_code == 403
|
|
|
|
|
assert client.post("/api/feed/run", json={}).status_code == 403
|
|
|
|
|
assert client.get("/api/feed/batches").status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_now_inserts_a_request_and_notifies(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
listener = db.connect_as("worker", fx, autocommit=True)
|
|
|
|
|
listener.execute("LISTEN ps_job_request")
|
|
|
|
|
try:
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = client.post("/api/feed/run", json={})
|
|
|
|
|
assert r.status_code == 200 and r.json()["requested"] is True and r.json()["pending"] == 1, r.text
|
|
|
|
|
got = list(listener.notifies(timeout=3, stop_after=1))
|
|
|
|
|
assert got and json.loads(got[0].payload) == {"job": "visual-feed"}
|
|
|
|
|
r = client.post("/api/feed/run", json={"job": "drawing-lookup"})
|
|
|
|
|
assert r.status_code == 200 and r.json()["job"] == "drawing-lookup"
|
|
|
|
|
assert client.post("/api/feed/run", json={"job": "housekeeping"}).status_code == 422
|
|
|
|
|
finally:
|
|
|
|
|
listener.close()
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
rows = conn.execute("SELECT job FROM job_requests WHERE claimed_at IS NULL ORDER BY id").fetchall()
|
|
|
|
|
assert [r[0] for r in rows] == ["visual-feed", "drawing-lookup"]
|
|
|
|
|
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'feed.run_requested'").fetchone()[0] == 2
|
|
|
|
|
# the worker's next run claims the request and records the trigger
|
|
|
|
|
c = run_feed(fx, 1)
|
|
|
|
|
assert c["_trigger"] == "request"
|
|
|
|
|
assert client.get("/api/feed/overview").json()["last_run"]["trigger"] == "request"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pull_in(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
set_feed(fx, golive_date=golive_today_offset())
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
ig = client.get("/api/feed/overview").json()["ignored"][0]
|
|
|
|
|
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": ""})
|
|
|
|
|
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
|
|
|
|
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": "Customer still wants it"})
|
|
|
|
|
assert r.status_code == 200 and r.json()["pulled_in_at"], r.text
|
|
|
|
|
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": "again please"})
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
assert client.post("/api/feed/ignored/999999/pull-in", json={"reason": "nothing"}).status_code == 404
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
assert conn.execute("SELECT count(*) FROM job_requests WHERE claimed_at IS NULL").fetchone()[0] == 1
|
|
|
|
|
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'feed.pulled_in'").fetchone()[0] == 1
|
|
|
|
|
c = run_feed(fx, 1)
|
|
|
|
|
assert c["new_drafts"] == 1 and c["_trigger"] == "request"
|
|
|
|
|
assert client.get("/api/feed/overview").json()["ignored"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batches_and_changes(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_feed(fx, 2)
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
batches = client.get("/api/feed/batches").json()
|
|
|
|
|
assert len(batches) == 2 and batches[0]["id"] > batches[1]["id"]
|
|
|
|
|
ch = client.get("/api/feed/changes").json()
|
|
|
|
|
assert ch["batch"]["id"] == batches[0]["id"]
|
|
|
|
|
kinds = {(r["wo_base"], r["kind"]) for r in ch["rows"]}
|
|
|
|
|
assert ("L900-01", "changed") in kinds and ("L900-05", "gone") in kinds
|
|
|
|
|
first = client.get(f"/api/feed/changes?batch_id={batches[1]['id']}").json()
|
|
|
|
|
assert all(r["kind"] == "added" for r in first["rows"]) and first["rows"]
|
|
|
|
|
assert client.get("/api/feed/changes?batch_id=999999").status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- rev approval
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rev_approve(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
pid = packet_id(conn, gen.KEY_TWO_OPS) # VISUAL rev 2, M-Files rev 3: staged
|
|
|
|
|
nf = packet_id(conn, gen.KEY_NOT_FOUND)
|
|
|
|
|
body = {"reason": "Engineering confirmed rev 3 is right", "sign": SIGN}
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
|
|
|
|
|
assert r.status_code == 403, r.text
|
|
|
|
|
as_user(client, "clerk.leo")
|
|
|
|
|
r = client.post(f"/api/packets/{pid}/rev-approve", json={"reason": "no signature"})
|
|
|
|
|
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
|
|
|
|
r = client.post(f"/api/packets/{nf}/rev-approve", json=body)
|
|
|
|
|
assert r.status_code == 409 and r.json()["code"] == "nothing_staged", r.text
|
|
|
|
|
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
p = packet_row(conn, pid)
|
|
|
|
|
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
|
|
|
|
|
assert p["visual"]["rev_ok"]["rev"] == "3" and p["visual"]["rev_ok"]["vis_rev"] == "2"
|
|
|
|
|
assert p["visual"]["rev_confirmed"] is True
|
|
|
|
|
pages = drawing_pages(conn, pid)
|
|
|
|
|
assert pages and all(pg[1] == "3" for pg in pages)
|
|
|
|
|
assert (
|
|
|
|
|
conn.execute(
|
|
|
|
|
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Different rev approved by hand'",
|
|
|
|
|
(pid,),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
== 1
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
conn.execute(
|
|
|
|
|
"SELECT count(*) FROM audit_events WHERE action = 'packet.rev_approve' AND packet_id = %s", (pid,)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
== 1
|
|
|
|
|
)
|
|
|
|
|
# the drawing job leaves the approved rev alone
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
conn.execute("UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()")
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
p = packet_row(conn, pid)
|
|
|
|
|
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def test_rev_approve_keeps_the_rev_visual_calls(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
pid = packet_id(conn, gen.KEY_TWO_OPS) # VISUAL rev 2 (Retired in M-Files), M-Files rev 3
|
|
|
|
|
nf = packet_id(conn, gen.KEY_NOT_FOUND)
|
|
|
|
|
body = {"reason": "Engineering says build lot 1 to rev 2", "sign": SIGN, "use": "called"}
|
|
|
|
|
as_user(client, "clerk.leo")
|
|
|
|
|
r = client.post(f"/api/packets/{nf}/rev-approve", json=body)
|
|
|
|
|
assert r.status_code == 409 and r.json()["code"] == "nothing_staged", r.text
|
|
|
|
|
r = client.post(f"/api/packets/{pid}/rev-approve", json={**body, "use": "oldest"})
|
|
|
|
|
assert r.status_code == 422, r.text
|
|
|
|
|
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
p = packet_row(conn, pid)
|
|
|
|
|
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
|
|
|
|
|
ok = p["visual"]["rev_ok"]
|
|
|
|
|
assert (ok["rev"], ok["vis_rev"], ok["use"], ok["latest"], ok["controlled"]) == (
|
|
|
|
|
"2",
|
|
|
|
|
"2",
|
|
|
|
|
"called",
|
|
|
|
|
"3",
|
|
|
|
|
"Retired",
|
|
|
|
|
)
|
|
|
|
|
assert p["visual"]["rev_confirmed"] is True
|
|
|
|
|
pages = drawing_pages(conn, pid)
|
|
|
|
|
assert pages and all(pg[1] == "2" for pg in pages)
|
|
|
|
|
log = conn.execute(
|
|
|
|
|
"SELECT note FROM packet_log WHERE packet_id = %s AND action = 'Rev VISUAL calls kept by hand'", (pid,)
|
|
|
|
|
).fetchall()
|
|
|
|
|
assert len(log) == 1 and "Retired in M-Files" in log[0][0] and "M-Files has rev 3" in log[0][0]
|
|
|
|
|
# the drawing job leaves the kept rev alone while M-Files' Effective rev is still 3
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
conn.execute("UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()")
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
p = packet_row(conn, pid)
|
|
|
|
|
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
|
|
|
|
|
assert p["visual"]["rev_confirmed"] is True and p["visual"].get("called") is None
|
|
|
|
|
assert all(pg[1] == "2" for pg in drawing_pages(conn, pid))
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
# ---------------------------------------------------------------- hand uploads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_hand_drawing(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
nf = packet_id(conn, gen.KEY_NOT_FOUND)
|
|
|
|
|
wp = packet_id(conn, gen.KEY_WELD_PART)
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
a = upload(client, tiny_pdf(2), "L900-0701 hand.pdf")
|
|
|
|
|
assert a.status_code == 201, a.text
|
|
|
|
|
asset = a.json()["id"]
|
|
|
|
|
r = client.post(f"/api/packets/{wp}/hand/drawing", json={"asset_id": asset, "reason": "Have it", "sign": SIGN})
|
|
|
|
|
assert r.status_code == 409 and r.json()["code"] == "not_flagged", r.text
|
|
|
|
|
r = client.post(f"/api/packets/{nf}/hand/drawing", json={"asset_id": asset, "reason": "From drafting"})
|
|
|
|
|
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/api/packets/{nf}/hand/drawing", json={"asset_id": asset, "reason": "From drafting by email", "sign": SIGN}
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
p = packet_row(conn, nf)
|
|
|
|
|
assert p["drawing_state"] == "hand" and "hand_drawing" in p["flags"] and "not_found" not in p["flags"]
|
|
|
|
|
assert p["visual"]["manual_dwg"]["reason"] == "From drafting by email"
|
|
|
|
|
assert len(drawing_pages(conn, nf)) == 2
|
|
|
|
|
assert (
|
|
|
|
|
conn.execute(
|
|
|
|
|
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Drawing uploaded by hand'", (nf,)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
== 1
|
|
|
|
|
)
|
|
|
|
|
# a hand flag keeps it off auto-release, and the drawing job does not take it away
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
assert packet_row(conn, nf)["drawing_state"] == "hand"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xlsx(rows: list[list[Any]]) -> bytes:
|
|
|
|
|
from openpyxl import Workbook
|
|
|
|
|
|
|
|
|
|
wb = Workbook()
|
|
|
|
|
ws = wb.active
|
|
|
|
|
assert ws is not None
|
|
|
|
|
for r in rows:
|
|
|
|
|
ws.append(r)
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
wb.save(buf)
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hand_bom(client: TestClient, pid: int, data: bytes, name: str, **form: str) -> Any:
|
|
|
|
|
fields = {"reason": "Engineering sent the BOM", "sign_factor": "password", "sign_secret": FAKE_PASSWORD}
|
|
|
|
|
fields.update(form)
|
|
|
|
|
return client.post(
|
|
|
|
|
f"/api/packets/{pid}/hand/bom", files={"file": (name, data, "application/octet-stream")}, data=fields
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["xlsx", "csv"])
|
|
|
|
|
def test_hand_bom(client: TestClient, fx: Settings, kind: str) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
mm = packet_id(conn, gen.KEY_TWO_OPS) # rev mismatch: flagged
|
|
|
|
|
wp = packet_id(conn, gen.KEY_WELD_PART) # attached, no flags
|
|
|
|
|
visual_lines = conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s", (mm,)).fetchone()[0]
|
|
|
|
|
if kind == "xlsx":
|
|
|
|
|
data, name = (
|
|
|
|
|
_xlsx(
|
|
|
|
|
[
|
|
|
|
|
["Item", "Qty", "Part ID", "Description"],
|
|
|
|
|
[1, 4, "PLT-0.5-A36", "Plate"],
|
|
|
|
|
[2, 2, "CPL-2-3000", "Coupling"],
|
|
|
|
|
[3, 1, None, "Gusset"],
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
"bom.xlsx",
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
data, name = (
|
|
|
|
|
b"Item,Qty,Part ID,Description\r\n1,4,PLT-0.5-A36,Plate\r\n2,2,CPL-2-3000,Coupling\r\n3,1,,Gusset\r\n",
|
|
|
|
|
"bom.csv",
|
|
|
|
|
)
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = _hand_bom(client, wp, data, name)
|
|
|
|
|
assert r.status_code == 409 and r.json()["code"] == "not_flagged", r.text
|
|
|
|
|
r = _hand_bom(client, mm, data, name, sign_secret="wrong password")
|
|
|
|
|
assert r.status_code in (400, 401, 403), r.text
|
|
|
|
|
r = _hand_bom(client, mm, b"Name,Colour\nPipe,Red\n", "bad.csv")
|
|
|
|
|
assert r.status_code == 422 and r.json()["code"] == "bad_file", r.text
|
|
|
|
|
r = _hand_bom(client, mm, data, name)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
lines = conn.execute(
|
|
|
|
|
"SELECT item, qty, part_id, description, src FROM bom_lines WHERE packet_id = %s ORDER BY item", (mm,)
|
|
|
|
|
).fetchall()
|
|
|
|
|
assert [(ln[0], ln[2], ln[4]) for ln in lines] == [
|
|
|
|
|
(1, "PLT-0.5-A36", "hand"),
|
|
|
|
|
(2, "CPL-2-3000", "hand"),
|
|
|
|
|
(3, None, "hand"),
|
|
|
|
|
]
|
|
|
|
|
p = packet_row(conn, mm)
|
|
|
|
|
assert "hand_bom" in p["flags"] and p["visual"]["manual_bom"]["lines"] == 3
|
|
|
|
|
assert (
|
|
|
|
|
conn.execute(
|
|
|
|
|
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'BOM uploaded by hand'", (mm,)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
== 1
|
|
|
|
|
)
|
|
|
|
|
# the feed keeps a hand BOM on a draft
|
|
|
|
|
run_feed(fx, 2)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
assert (
|
|
|
|
|
conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s AND src = 'hand'", (mm,)).fetchone()[0]
|
|
|
|
|
== 3
|
|
|
|
|
)
|
|
|
|
|
# back to VISUAL's BOM
|
|
|
|
|
r = client.request("DELETE", f"/api/packets/{mm}/hand/bom", json={"sign": SIGN})
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
src = conn.execute("SELECT DISTINCT src FROM bom_lines WHERE packet_id = %s", (mm,)).fetchall()
|
|
|
|
|
assert src == [("visual",)]
|
|
|
|
|
assert conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s", (mm,)).fetchone()[0] == visual_lines
|
|
|
|
|
p = packet_row(conn, mm)
|
|
|
|
|
assert "hand_bom" not in p["flags"] and "manual_bom" not in p["visual"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- feed change orders
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_co_approve_applies_vis_and_voids_pages(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
run_drawings(fx)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
wp = packet_id(conn, gen.KEY_WELD_PART)
|
|
|
|
|
assert packet_row(conn, wp)["drawing_state"] == "attached"
|
|
|
|
|
move_status(conn, wp, "ready")
|
|
|
|
|
old_pages = drawing_pages(conn, wp)
|
|
|
|
|
assert old_pages and all(pg[1] == "1" for pg in old_pages)
|
|
|
|
|
run_feed(fx, 2)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
co = cos_of(conn, wp)[0]
|
|
|
|
|
assert co["waiting"] is not None and co["staged"] is None
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
|
|
|
|
|
assert r.status_code == 409 and r.json()["code"] == "co_not_staged", r.text
|
|
|
|
|
assert "not been fetched" in r.json()["detail"]
|
|
|
|
|
r = client.get(f"/api/packets/{wp}")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["status"] == "ready"
|
|
|
|
|
run_drawings(fx, 2)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
co = cos_of(conn, wp)[0]
|
|
|
|
|
assert co["staged"] is not None and co["staged"]["rev"] == "2" and co["waiting"] is None
|
|
|
|
|
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={})
|
|
|
|
|
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
|
|
|
|
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
p = packet_row(conn, wp)
|
|
|
|
|
co = cos_of(conn, wp)[0]
|
|
|
|
|
assert co["review"] == "approved" and co["approved_by"] is not None
|
|
|
|
|
assert p["rev"] == "2" and p["status"] == "ready"
|
|
|
|
|
assert p["visual_fp_applied"] == co["target_fp"]
|
|
|
|
|
assert p["visual"]["fp_parts"] == co["vis"]["fp_parts"]
|
|
|
|
|
pages = drawing_pages(conn, wp)
|
|
|
|
|
void = [pg for pg in pages if pg[3]]
|
|
|
|
|
live = [pg for pg in pages if not pg[3]]
|
|
|
|
|
assert len(void) == len(old_pages) and all(pg[4] for pg in void) # VOID by this CO
|
|
|
|
|
assert live and all(pg[1] == "2" and pg[2] for pg in live)
|
|
|
|
|
items = conn.execute("SELECT old_rev, new_rev FROM co_items WHERE co_id = %s", (co["id"],)).fetchall()
|
|
|
|
|
assert ("1", "2") in [tuple(i) for i in items]
|
|
|
|
|
required = {
|
|
|
|
|
r[0]
|
|
|
|
|
for r in conn.execute("SELECT dept_code FROM co_required_depts WHERE co_id = %s", (co["id"],)).fetchall()
|
|
|
|
|
}
|
|
|
|
|
assert "03" in required
|
|
|
|
|
# the next feed run sees the packet matches VISUAL: no new change order
|
|
|
|
|
c = run_feed(fx, 2)
|
|
|
|
|
assert c["cos_opened"] == 0, c
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
assert len(cos_of(conn, wp)) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_minor_co_approve_needs_no_departments(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
g = packet_id(conn, gen.KEY_NOT_FOUND)
|
|
|
|
|
move_status(conn, g, "warehouse")
|
|
|
|
|
run_feed(fx, 2)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
co = cos_of(conn, g)[0]
|
|
|
|
|
assert co["minor"] is True
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = client.post(f"/api/packets/{g}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
assert conn.execute("SELECT count(*) FROM co_required_depts WHERE co_id = %s", (co["id"],)).fetchone()[0] == 0
|
|
|
|
|
assert conn.execute("SELECT target_hrs FROM packet_work_orders WHERE packet_id = %s", (g,)).fetchone()[0] == 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_changes_to_approve_filter(client: TestClient, fx: Settings) -> None:
|
|
|
|
|
run_feed(fx, 1)
|
|
|
|
|
with app_conn(fx) as conn:
|
|
|
|
|
wp = packet_id(conn, gen.KEY_WELD_PART)
|
|
|
|
|
move_status(conn, wp, "ready")
|
|
|
|
|
run_feed(fx, 2)
|
|
|
|
|
as_user(client, "clerk.clara")
|
|
|
|
|
r = client.get("/api/packets?co=pending")
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
body = r.json()
|
|
|
|
|
items = body["packets"]
|
|
|
|
|
assert [i["id"] for i in items] == [wp]
|
|
|
|
|
assert items[0]["co_pending"] is True
|