"""Where the feed reads VISUAL from: VisualKit (DEV unless the stack says otherwise) or a synthetic fixture file. `VisualKitSource` copies the read calls and output shapes of VisualKit's examples/packet_station_feed.py: the window read, the tracked keys by key, operations, operation text and parts for rows; for_bases, requirements, requirement text, parts and part sites for materials; DEMAND_SUPPLY_LINK orders by base and by project; labor tickets summed per operation. Read-only: VisualKit enforces it. No cost, price, amount or pay column is read. `FixtureSource` reads `/state` and `/feed..json` in the same contract shape (CI and tests). Every value goes through `types.parse_row` / `parse_material` / `parse_order`, so both sources give identical FeedData. """ from __future__ import annotations import json import re from collections.abc import Iterable, Sequence from datetime import date, datetime from decimal import Decimal from pathlib import Path from typing import Any, Protocol from .types import ( FeedData, LaborLeg, Material, Order, Row, WoKey, parse_labor, parse_material, parse_order, parse_orders, parse_row, status_code, text, ) STATUS_WORDS = {"F": "Firmed", "R": "Released", "U": "Unreleased", "C": "Closed", "X": "Cancelled"} MATERIAL_STATUSES = ("F", "R") OPEN_ORDER_STATUSES = ("F", "H", "R") QTY_EMPLOYEE = "9999" _SECRETISH = re.compile(r"(?i)(password|pwd|secret|token)\s*[=:]\s*\S+") def redact(message: object) -> str: """An exception as one short line with anything password-like removed.""" s = _SECRETISH.sub( r"\1=***", f"{type(message).__name__}: {message}" if isinstance(message, BaseException) else str(message) ) return s.replace("\r", " ").replace("\n", " ")[:500] class FeedSource(Protocol): def read( self, *, site: str, since: date, until: date, tracked: Sequence[WoKey], with_materials: bool, with_orders: bool, with_labor: bool, labor_since: date | None, ) -> FeedData: ... def describe(self) -> str: ... # ---------------------------------------------------------------- value helpers (as the VisualKit example) def _to_number(v: Any) -> Any: if v is None or isinstance(v, bool): return v if isinstance(v, Decimal): return v return Decimal(str(v)) def _fmt_date(v: date | datetime | None) -> str | None: if v is None: return None if isinstance(v, datetime): v = v.date() return v.isoformat() def _clean(s: str | None) -> str: if not s: return "" return s.replace("\r\n", "\n").replace("\r", "\n") def _istat(calc_qty: Any, issued_qty: Any, line_status: str | None) -> int: """Job Pull's issue state: 3 over-issued, 0 line closed, 2 nothing issued, 1 partly.""" calc = Decimal(str(calc_qty or 0)) issued = Decimal(str(issued_qty or 0)) if issued > calc: return 3 if (line_status or "").strip().upper() == "C": return 0 if issued == 0: return 2 return 1 def _yes_no(flag: bool | None) -> str: return "" if flag is None else ("Y" if flag else "N") def project_of(base: str) -> str: """VisualKit's project_of: the base before its first dash, or the whole base.""" head = base.partition("-")[0] return head or base # ---------------------------------------------------------------- VisualKit class VisualKitSource: """The five reads through one `visualkit.Visual` (made by the job per run, closed after).""" def __init__(self, visual: Any) -> None: self.v = visual def describe(self) -> str: s = self.v.settings return f"{s.server} / {s.database}" def _key(self, k: WoKey) -> Any: from visualkit import WorkOrderKey return WorkOrderKey(k.base, k.lot, k.split, k.sub, "W") def _load_parts(self, part_ids: Iterable[str | None], cache: dict[str, Any]) -> None: missing = sorted({p for p in part_ids if p and p not in cache}) if missing: cache.update(self.v.parts.get_many(missing)) def read( self, *, site: str, since: date, until: date, tracked: Sequence[WoKey], with_materials: bool, with_orders: bool, with_labor: bool, labor_since: date | None, ) -> FeedData: v = self.v s = v.settings meta: dict[str, Any] = { "source": "visualkit", "server": s.server, "database": s.database, "site": site, "since": since.isoformat(), "until": until.isoformat(), "generated_at": datetime.now().isoformat(timespec="seconds"), } data = FeedData(meta=meta, rows=[], tracked=list(tracked), tracked_count=len(tracked)) parts: dict[str, Any] = {} # 1. The window: firmed TYPE W work orders released in [since, until], paged. wos: list[Any] = [] offset: int | None = 0 while offset is not None: page = v.work_orders.released(site, since, until, status=("F",), offset=offset) wos.extend(page.items) offset = page.next_offset data.window_count = len(wos) # 2. The tracked keys, whatever their status or date. Keys VISUAL lacks are missing. asked = {self._key(k): k for k in dict.fromkeys(tracked)} found = v.work_orders.get_many(list(asked)) if asked else {} data.tracked_missing = [k for vk, k in asked.items() if vk not in found] have = {wo.key for wo in wos} for wo in found.values(): if wo.key not in have: wos.append(wo) have.add(wo.key) # 3. Rows for the union. keys = [wo.key for wo in wos] ops = v.work_orders.operations_for(keys) if keys else {} specs = v.work_orders.operation_text_for(keys) if keys else {} self._load_parts((wo.part_id for wo in wos), parts) for wo in wos: part = parts.get(wo.part_id) if wo.part_id else None desc = _clean(part.description if part else None) qty = wo.desired_qty or Decimal(0) for op in ops.get(wo.key, []): data.rows.append( parse_row( { "wo": wo.base_id, "lot": wo.lot_id, "split": wo.split_id, "sub": wo.sub_id, "seq": op.sequence_no, "res": op.resource_id or "", "dwg": wo.drawing_id or "", "rev": wo.drawing_rev_no or "", "ps": specs.get((wo.key, op.sequence_no), ""), "desc": desc, "qty": _to_number(qty), "tgt": _to_number((op.run or Decimal(0)) * qty), "rel": _fmt_date(wo.desired_rls_date), "want": _fmt_date(wo.desired_want_date), "st": STATUS_WORDS.get((wo.status or "").strip().upper(), wo.status or ""), "barcode": op.barcode(), "part": wo.part_id or "", } ) ) bases = sorted({wo.base_id for wo in wos}) if with_materials: try: data.materials, data.material_legs = self._materials(bases, site, parts) except Exception as e: # a failed materials read keeps the BOMs as they are data.materials, data.material_legs = None, None data.errors.append("materials: " + redact(e)) if with_orders: try: data.orders_by_base = self._orders_by_base(bases) data.orders_by_project = self._orders_by_project({project_of(b) for b in bases}) except Exception as e: # the apply keeps the last cached orders data.orders_by_base, data.orders_by_project = None, None data.errors.append("orders: " + redact(e)) if with_labor: try: data.labor = self._labor(list(asked) or keys) except Exception as e: data.labor = None data.errors.append("labor: " + redact(e)) return data def _materials(self, bases: Sequence[str], site: str, parts: dict[str, Any]) -> tuple[list[Material], set[WoKey]]: v = self.v if not bases: return [], set() legs = v.work_orders.for_bases(bases, type="W", status=MATERIAL_STATUSES, site_id=site) keys = [wo.key for wo in legs] reqs = v.work_orders.requirements_for(keys) if keys else {} text_keys = [k for k in keys if any(not r.part_id for r in reqs.get(k, []))] texts = v.work_orders.requirement_text_for(text_keys) if text_keys else {} part_ids = {r.part_id for rs in reqs.values() for r in rs if r.part_id} self._load_parts(part_ids, parts) sites = v.parts.sites_for(sorted(part_ids), site) if part_ids else {} out: list[Material] = [] covered: set[WoKey] = set() for wo in legs: covered.add(WoKey.of(wo.base_id, wo.lot_id, wo.split_id, wo.sub_id)) for r in reqs.get(wo.key, []): part = parts.get(r.part_id) if r.part_id else None ps = sites.get(r.part_id) if r.part_id else None desc = ( _clean(part.description if part else None) if r.part_id else _clean(texts.get((wo.key, r.operation_seq_no, r.piece_no))) ) out.append( parse_material( { "wo": wo.base_id, "lot": wo.lot_id, "split": wo.split_id, "sub": wo.sub_id, "seq": r.operation_seq_no, "piece": r.piece_no, "part": r.part_id or None, "qty": _to_number(r.calc_qty or Decimal(0)), "issued": _to_number(r.issued_qty or Decimal(0)), "whs": (ps.primary_whs_id or "").upper() if ps else "", "loc": (ps.primary_loc_id or "").upper() if ps else "", "st": wo.status, "istat": _istat(r.calc_qty, r.issued_qty, r.status), "dwg": wo.drawing_id or "", "desc": desc, "comm": (part.commodity_code or "") if part else "", "cons": _yes_no(part.consumable) if part else "", } ) ) return out, covered def _orders_by_base(self, bases: Sequence[str]) -> dict[str, list[Order]]: v = self.v if not bases: return {} pairs = v.links.orders_for_bases(list(bases)) order_ids = sorted({oid for ps in pairs.values() for oid, _ in ps}) orders = v.orders.get_many(order_ids) if order_ids else {} customers = v.orders.customers(sorted({o.customer_id for o in orders.values()})) if orders else {} out: dict[str, list[Order]] = {} for base in bases: lines_by_order: dict[str, list[int]] = {} for oid, line_no in pairs.get(base, []): lines_by_order.setdefault(oid, []) if line_no is not None: lines_by_order[oid].append(line_no) if not lines_by_order: continue out[base] = [] for oid, lines in sorted(lines_by_order.items()): o = orders.get(oid) cust = customers.get(o.customer_id) if o else None out[base].append( parse_order( { "cust_order_id": oid, "lines": sorted(set(lines)), "status": o.status if o else None, "customer_id": o.customer_id if o else None, "customer_name": cust.name if cust else None, "customer_po_ref": o.customer_po_ref if o else None, "order_date": _fmt_date(getattr(o, "order_date", None)) if o else None, "desired_ship_date": _fmt_date(o.desired_ship_date) if o else None, "promise_date": _fmt_date(o.promise_date) if o else None, } ) ) return out def _orders_by_project(self, projects: Iterable[str]) -> dict[str, list[Order]]: v = self.v wanted = sorted(set(projects)) if not wanted: return {} found = v.links.orders_for_projects(wanted) cust_ids = sorted({po.customer_id for pos in found.values() for po in pos if po.customer_id}) customers = v.orders.customers(cust_ids) if cust_ids else {} out: dict[str, list[Order]] = {} for project in wanted: entries: dict[str, dict[str, Any]] = {} for po in found.get(project, []): e = entries.get(po.cust_order_id) if e is None: cust = customers.get(po.customer_id) if po.customer_id else None e = entries[po.cust_order_id] = { "cust_order_id": po.cust_order_id, "lines": [], "top_jobs": [], "status": po.status, "customer_id": po.customer_id, "customer_name": cust.name if cust else None, "customer_po_ref": po.customer_po_ref, "order_date": _fmt_date(po.order_date), "desired_ship_date": _fmt_date(po.desired_ship_date), "promise_date": _fmt_date(po.promise_date), } if po.line_no is not None and po.line_no not in e["lines"]: e["lines"].append(po.line_no) if po.supply_base_id not in e["top_jobs"]: e["top_jobs"].append(po.supply_base_id) if entries: for e in entries.values(): e["lines"].sort() e["top_jobs"].sort() ordered = sorted(entries.values(), key=lambda e: e["status"] not in OPEN_ORDER_STATUSES) out[project] = [parse_order(e) for e in ordered] return out def _labor(self, keys: Sequence[Any]) -> list[LaborLeg]: """Hours and ticket counts per (leg, seq). Employee 9999's quantity-only tickets and indirect tickets are left out. Hours only: no cost or pay column is read. VisualKit has no date filter on this call, so every ticket of the legs is read and summed (the totals need them all).""" if not keys: return [] tickets = self.v.labor.tickets_for_work_orders(list(keys)) sums: dict[tuple[WoKey, int], LaborLeg] = {} for vk, ts in tickets.items(): k = WoKey.of(vk.base_id, vk.lot_id, vk.split_id, vk.sub_id) for t in ts: if t.operation_seq_no is None or t.is_quantity_only or t.is_indirect: continue seq = int(t.operation_seq_no) leg = sums.get((k, seq)) if leg is None: leg = sums[(k, seq)] = LaborLeg(k.base, k.lot, k.split, k.sub, seq, Decimal(0), 0, None) leg.hours += Decimal(str(t.hours_worked or 0)) leg.tickets += 1 d = t.transaction_date.date() if isinstance(t.transaction_date, datetime) else t.transaction_date if d is not None and (leg.last_ticket is None or d > leg.last_ticket): leg.last_ticket = d return list(sums.values()) # ---------------------------------------------------------------- fixtures class FixtureSource: """Synthetic VISUAL: `/state` (default "1") picks `/feed..json`.""" def __init__(self, directory: Path) -> None: self.dir = Path(directory) def state(self) -> str: try: s = (self.dir / "state").read_text(encoding="utf-8").strip() except OSError: s = "" return s or "1" def describe(self) -> str: return f"fixture state {self.state()}" def read( self, *, site: str, since: date, until: date, tracked: Sequence[WoKey], with_materials: bool, with_orders: bool, with_labor: bool, labor_since: date | None, ) -> FeedData: state = self.state() raw = json.loads((self.dir / f"feed.{state}.json").read_text(encoding="utf-8")) all_rows: list[Row] = [parse_row(d) for d in raw.get("rows") or []] tracked_set = set(tracked) window_keys = {r.key for r in all_rows if r.st == "F" and r.rel is not None and since <= r.rel <= until} keep = window_keys | (tracked_set & {r.key for r in all_rows}) rows = [r for r in all_rows if r.key in keep] present = {r.key for r in all_rows} meta = dict(raw.get("meta") or {}) meta.update( {"source": "fixture", "state": state, "site": site, "since": since.isoformat(), "until": until.isoformat()} ) data = FeedData( meta=meta, rows=rows, tracked=list(tracked), tracked_missing=[k for k in dict.fromkeys(tracked) if k not in present], window_count=len(window_keys), tracked_count=len(tracked), ) bases = {r.wo for r in rows} if with_materials: if raw.get("materials") is None: data.materials, data.material_legs = None, None else: lines = [parse_material(d) for d in raw.get("materials") or []] data.materials = [m for m in lines if m.wo in bases] data.material_legs = {m.key for m in data.materials if m.st in MATERIAL_STATUSES} # A leg the file lists with status F or R counts as covered even when it has no lines. for r in all_rows: if r.wo in bases and r.st in MATERIAL_STATUSES: data.material_legs.add(r.key) if with_orders: obb = parse_orders(raw.get("orders_by_base")) obp = parse_orders(raw.get("orders_by_project")) if obb is None or obp is None: data.orders_by_base, data.orders_by_project = None, None else: projects = {project_of(b) for b in bases} data.orders_by_base = {b: v for b, v in obb.items() if b in bases} data.orders_by_project = {p: v for p, v in obp.items() if p in projects} if with_labor and raw.get("labor") is not None: data.labor = [parse_labor(d) for d in raw.get("labor") or []] return data def make(settings: Any, visual: Any = None) -> FeedSource: """The source the settings name. `visualkit` needs the open `visual` the job made.""" if settings.feed_source == "fixture": return FixtureSource(Path(settings.fixture_dir)) if visual is None: raise ValueError("the visualkit feed source needs an open visualkit.Visual") return VisualKitSource(visual) __all__ = [ "FeedSource", "FixtureSource", "VisualKitSource", "make", "project_of", "redact", "status_code", "text", ]