2026-10-16 09:31:45 -06:00
|
|
|
"""Projects and units: numbers, lots, aliases, renumbering, field CAS, soft delete and packet counts.
|
|
|
|
|
|
|
|
|
|
Ports the old projects.js rules. A unit's number is its project's number plus its lot. Every old number
|
|
|
|
|
of a project or unit stays in project_aliases, so a lookup by any number still finds it. Packets point at
|
|
|
|
|
units by id, so a renumber never touches a packet. Numbers and lots are text: never cast them.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from collections.abc import Iterable, Mapping
|
|
|
|
|
from dataclasses import dataclass
|
2026-10-16 14:45:38 -06:00
|
|
|
from datetime import UTC, date, datetime
|
2026-10-16 09:31:45 -06:00
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from psycopg import errors as pgerr
|
|
|
|
|
from psycopg.rows import dict_row
|
|
|
|
|
|
|
|
|
|
from .. import audit, events
|
|
|
|
|
from ..api.errors import ApiError
|
|
|
|
|
from ..text import LIMITS, RX_LOT, RX_PROJECT, clean_opt, clean_text
|
|
|
|
|
|
|
|
|
|
STATUSES = ("open", "hold", "closed")
|
|
|
|
|
LIST_FILTERS = ("active", "open", "hold", "closed", "all")
|
|
|
|
|
SHOP_STATUSES = ("ready", "assigned", "in_progress", "review")
|
|
|
|
|
COUNT_KEYS = ("draft", "warehouse", "shop", "qc", "complete", "filed", "total")
|
|
|
|
|
MAX_UNITS_AT_ONCE = 200
|
|
|
|
|
MAX_WORDS = 8
|
|
|
|
|
LIST_LIMIT = 500
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
PROJECT_FIELDS = ("name", "customer", "po", "due", "status", "notes", "order_pin", "order_pin_reason")
|
|
|
|
|
UNIT_FIELDS = ("name", "customer", "po", "due", "status", "notes", "order_pin", "order_pin_reason")
|
|
|
|
|
# Order-derived fields: the value in use, where it came from (visual | hand) and what VISUAL last said.
|
|
|
|
|
ORDER_FIELDS = ("customer", "po", "order_date", "ship_date")
|
|
|
|
|
RX_ORDER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ./_-]{0,39}$")
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
_DIGITS = re.compile(r"^[0-9]+$")
|
|
|
|
|
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
|
|
|
|
COUNTS_SQL = (
|
|
|
|
|
"count(*) FILTER (WHERE k.status = 'draft') AS c_draft,"
|
|
|
|
|
" count(*) FILTER (WHERE k.status = 'warehouse') AS c_warehouse,"
|
|
|
|
|
" count(*) FILTER (WHERE k.status IN ('ready', 'assigned', 'in_progress', 'review')) AS c_shop,"
|
|
|
|
|
" count(*) FILTER (WHERE k.status = 'qc') AS c_qc,"
|
|
|
|
|
" count(*) FILTER (WHERE k.status = 'complete') AS c_complete,"
|
|
|
|
|
" count(*) FILTER (WHERE k.status = 'filed') AS c_filed,"
|
|
|
|
|
" count(*) AS c_total"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
PROJECT_SELECT = (
|
|
|
|
|
"SELECT p.*," # noqa: S608
|
|
|
|
|
" (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS unit_count,"
|
|
|
|
|
" c.*"
|
|
|
|
|
" FROM projects p"
|
|
|
|
|
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k JOIN units u ON u.id = k.unit_id"
|
|
|
|
|
" WHERE u.project_id = p.id AND k.removed_at IS NULL) c ON true"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
UNIT_SELECT = (
|
|
|
|
|
"SELECT v.*, c.* FROM v_units v" # noqa: S608
|
|
|
|
|
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k"
|
|
|
|
|
" WHERE k.unit_id = v.id AND k.removed_at IS NULL) c ON true"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Who:
|
|
|
|
|
"""Who is writing, for the audit row. it_reason is set only when an admin acts outside their roles."""
|
|
|
|
|
|
|
|
|
|
user_id: Any
|
|
|
|
|
session_id: Any = None
|
|
|
|
|
role_snapshot: str | None = None
|
|
|
|
|
ip: str | None = None
|
|
|
|
|
it_reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- normalisation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_number(raw: Any) -> str:
|
|
|
|
|
"""A project number: cleaned, upper-cased, RX_PROJECT. 422 invalid_number otherwise."""
|
|
|
|
|
n = clean_text(raw, 200).upper()
|
|
|
|
|
if not RX_PROJECT.match(n):
|
|
|
|
|
raise ApiError(
|
|
|
|
|
422,
|
|
|
|
|
"invalid_number",
|
|
|
|
|
"A project number is 1 to 20 letters, digits or dashes, starting with a letter or digit.",
|
|
|
|
|
)
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_lot(raw: Any) -> str:
|
|
|
|
|
"""A unit lot: upper-cased, digits zero-padded to 3. `"7"` -> `"007"`, `"7a"` -> `"7A"`. 422 invalid_lot."""
|
|
|
|
|
lot = clean_text(raw, 40).upper()
|
|
|
|
|
if _DIGITS.match(lot) and len(lot) < 3:
|
|
|
|
|
lot = lot.zfill(3)
|
|
|
|
|
if not RX_LOT.match(lot) or (_DIGITS.match(lot) and len(lot) != 3):
|
|
|
|
|
raise ApiError(422, "invalid_lot", f"Lot {lot or '(blank)'}: 1 to 3 letters or digits, numbers as 001 to 999.")
|
|
|
|
|
return lot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lot_input(project_number: str, raw: Any) -> str:
|
|
|
|
|
"""A lot as typed: a full unit number that starts with the project number is cut down to its lot."""
|
|
|
|
|
text = clean_text(raw, 40).upper()
|
|
|
|
|
pn = project_number.upper()
|
|
|
|
|
if len(text) > 3 and text.startswith(pn) and len(text) > len(pn):
|
|
|
|
|
text = text[len(pn) :]
|
|
|
|
|
return normalize_lot(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_due(raw: Any) -> date | None:
|
|
|
|
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(raw, date):
|
|
|
|
|
return raw
|
|
|
|
|
text = str(raw).strip()
|
|
|
|
|
try:
|
|
|
|
|
if not _ISO_DATE.match(text):
|
|
|
|
|
raise ValueError(text)
|
|
|
|
|
return date.fromisoformat(text)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise ApiError(422, "invalid_date", "Give the due date as YYYY-MM-DD.") from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_status(raw: Any) -> str:
|
|
|
|
|
s = clean_text(raw, 20).lower()
|
|
|
|
|
if s not in STATUSES:
|
|
|
|
|
raise ApiError(422, "invalid_status", "Status is open, hold or closed.")
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_reason(raw: Any) -> str:
|
|
|
|
|
text = clean_text(raw, LIMITS["reason"])
|
|
|
|
|
if not text:
|
|
|
|
|
raise ApiError(400, "reason_required", "Give a reason.")
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _like(word: str) -> str:
|
|
|
|
|
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _words(q: str | None) -> list[str]:
|
|
|
|
|
return [w for w in clean_text(q, 200).split() if w][:MAX_WORDS]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _iso(v: Any) -> Any:
|
|
|
|
|
return v.isoformat() if isinstance(v, date) else v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _counts(r: Mapping[str, Any]) -> dict[str, int]:
|
|
|
|
|
return {k: int(r.get("c_" + k) or 0) for k in COUNT_KEYS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _removed(r: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
|
|
|
if r.get("removed_at") is None:
|
|
|
|
|
return None
|
|
|
|
|
by = r.get("removed_by")
|
|
|
|
|
return {"at": r["removed_at"], "by": str(by) if by else None, "reason": r.get("removed_reason")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- shapes
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def _order_out(r: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""The order-derived columns with their provenance, and the order pin. Shared by projects and units."""
|
|
|
|
|
pin_by = r.get("order_pin_by")
|
|
|
|
|
return {
|
|
|
|
|
"customer_src": r.get("customer_src"),
|
|
|
|
|
"customer_visual": r.get("customer_visual"),
|
|
|
|
|
"po_src": r.get("po_src"),
|
|
|
|
|
"po_visual": r.get("po_visual"),
|
|
|
|
|
"order_date": r.get("order_date"),
|
|
|
|
|
"order_date_src": r.get("order_date_src"),
|
|
|
|
|
"order_date_visual": r.get("order_date_visual"),
|
|
|
|
|
"ship_date": r.get("ship_date"),
|
|
|
|
|
"ship_date_src": r.get("ship_date_src"),
|
|
|
|
|
"ship_date_visual": r.get("ship_date_visual"),
|
|
|
|
|
"order_ref": r.get("order_ref"),
|
|
|
|
|
"order_pin": r.get("order_pin"),
|
|
|
|
|
"order_pin_by": str(pin_by) if pin_by else None,
|
|
|
|
|
"order_pin_at": r.get("order_pin_at"),
|
|
|
|
|
"order_pin_reason": r.get("order_pin_reason"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
def project_out(r: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": r["id"],
|
|
|
|
|
"number": r["number"],
|
|
|
|
|
"name": r["name"],
|
|
|
|
|
"customer": r["customer"],
|
|
|
|
|
"po": r["po"],
|
|
|
|
|
"due": r["due"],
|
|
|
|
|
"status": r["status"],
|
|
|
|
|
"notes": r["notes"],
|
|
|
|
|
"source": r["source"],
|
|
|
|
|
"needs_details": r["needs_details"],
|
2026-10-16 14:45:38 -06:00
|
|
|
**_order_out(r),
|
2026-10-16 09:31:45 -06:00
|
|
|
"units": int(r.get("unit_count") or 0),
|
|
|
|
|
"packets": _counts(r),
|
|
|
|
|
"version": r["version"],
|
|
|
|
|
"created_at": r["created_at"],
|
|
|
|
|
"updated_at": r["updated_at"],
|
|
|
|
|
"removed": _removed(r),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unit_out(r: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
status = r["status"]
|
|
|
|
|
return {
|
|
|
|
|
"id": r["id"],
|
|
|
|
|
"project_id": r["project_id"],
|
|
|
|
|
"number": r["number"],
|
|
|
|
|
"lot": r["lot"],
|
|
|
|
|
"name": r["name"],
|
|
|
|
|
"due": r["due"],
|
|
|
|
|
"due_effective": r["due_effective"],
|
|
|
|
|
"status": status,
|
|
|
|
|
"status_effective": "closed" if r["project_status"] == "closed" else status,
|
|
|
|
|
"notes": r["notes"],
|
2026-10-16 14:45:38 -06:00
|
|
|
"customer": r.get("customer"),
|
|
|
|
|
"po": r.get("po"),
|
|
|
|
|
**_order_out(r),
|
2026-10-16 09:31:45 -06:00
|
|
|
"packets": _counts(r),
|
|
|
|
|
"version": r["version"],
|
|
|
|
|
"created_at": r["created_at"],
|
|
|
|
|
"updated_at": r["updated_at"],
|
|
|
|
|
"removed": _removed(r),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- lookups
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def owners(conn: Any, number: str) -> list[dict[str, Any]]:
|
|
|
|
|
"""Everything that answers to `number`: live project and unit numbers first, then aliases of live ones."""
|
|
|
|
|
n = clean_text(number, 40).upper()
|
|
|
|
|
if not n:
|
|
|
|
|
return []
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
rows: list[dict[str, Any]] = cur.execute(
|
|
|
|
|
"SELECT * FROM ("
|
|
|
|
|
" SELECT 0 AS rank, 'project' AS kind, p.id, p.id AS project_id, p.number, false AS alias"
|
|
|
|
|
" FROM projects p WHERE upper(p.number) = %(n)s AND p.removed_at IS NULL"
|
|
|
|
|
" UNION ALL"
|
|
|
|
|
" SELECT 1, 'unit', u.id, u.project_id, p.number || u.lot, false"
|
|
|
|
|
" FROM units u JOIN projects p ON p.id = u.project_id"
|
|
|
|
|
" WHERE upper(p.number || u.lot) = %(n)s AND u.removed_at IS NULL AND p.removed_at IS NULL"
|
|
|
|
|
" UNION ALL"
|
|
|
|
|
" SELECT 2, CASE WHEN a.unit_id IS NULL THEN 'project' ELSE 'unit' END, coalesce(a.unit_id, a.project_id),"
|
|
|
|
|
" a.project_id, CASE WHEN a.unit_id IS NULL THEN p.number ELSE p.number || u.lot END, true"
|
|
|
|
|
" FROM project_aliases a JOIN projects p ON p.id = a.project_id AND p.removed_at IS NULL"
|
|
|
|
|
" LEFT JOIN units u ON u.id = a.unit_id"
|
|
|
|
|
" WHERE a.alias = %(n)s AND (a.unit_id IS NULL OR u.removed_at IS NULL)"
|
|
|
|
|
") x ORDER BY rank, id",
|
|
|
|
|
{"n": n},
|
|
|
|
|
).fetchall()
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lookup(conn: Any, number: str) -> dict[str, Any] | None:
|
|
|
|
|
"""`{kind, id, project_id, number, alias}` for a current or old number, or None."""
|
|
|
|
|
found = owners(conn, number)
|
|
|
|
|
if not found:
|
|
|
|
|
return None
|
|
|
|
|
o = found[0]
|
|
|
|
|
return {"kind": o["kind"], "id": o["id"], "project_id": o["project_id"], "number": o["number"], "alias": o["alias"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _taken(o: Mapping[str, Any]) -> ApiError:
|
|
|
|
|
if o["alias"]:
|
|
|
|
|
msg = f"That number is an old number of {o['number']}. Pick another number."
|
|
|
|
|
elif o["kind"] == "unit":
|
|
|
|
|
msg = f"{o['number']} already exists as a unit."
|
|
|
|
|
else:
|
|
|
|
|
msg = f"{o['number']} already exists as a project."
|
|
|
|
|
return ApiError(409, "number_taken", msg, extra={"taken": {"kind": o["kind"], "id": o["id"], "alias": o["alias"]}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def assert_free(conn: Any, number: str, *, own_project: int | None = None, own_unit: int | None = None) -> None:
|
|
|
|
|
"""409 number_taken unless nothing answers to `number`, or only the entity's own old number does."""
|
|
|
|
|
for o in owners(conn, number):
|
|
|
|
|
if o["alias"] and own_unit is not None and o["kind"] == "unit" and o["id"] == own_unit:
|
|
|
|
|
continue
|
|
|
|
|
if o["alias"] and own_project is not None and o["kind"] == "project" and o["id"] == own_project:
|
|
|
|
|
continue
|
|
|
|
|
raise _taken(o)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def next_lot(conn: Any, project_id: int, number: str) -> str | None:
|
|
|
|
|
"""The old nextUnitNumber: highest numeric lot plus one, skipping numbers already used (old ones too)."""
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
"SELECT lot FROM units WHERE project_id = %s AND removed_at IS NULL AND lot ~ '^[0-9]{3}$'", (project_id,)
|
|
|
|
|
).fetchall()
|
|
|
|
|
n = max((int(r[0]) for r in rows), default=0) + 1
|
|
|
|
|
while n <= 999:
|
|
|
|
|
lot = f"{n:03d}"
|
|
|
|
|
if not owners(conn, number + lot):
|
|
|
|
|
return lot
|
|
|
|
|
n += 1
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unique_taken(e: Exception) -> ApiError:
|
|
|
|
|
return ApiError(409, "number_taken", "That number was just taken. Pick another number.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- reads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_project(conn: Any, project_id: int, *, removed: bool = False) -> dict[str, Any] | None:
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
r = cur.execute(PROJECT_SELECT + " WHERE p.id = %s", (project_id,)).fetchone()
|
|
|
|
|
if r is None or (r["removed_at"] is not None and not removed):
|
|
|
|
|
return None
|
|
|
|
|
return project_out(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _project_or_404(conn: Any, project_id: int) -> dict[str, Any]:
|
|
|
|
|
out = get_project(conn, project_id)
|
|
|
|
|
if out is None:
|
|
|
|
|
raise ApiError(404, "not_found", "No such project.")
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def aliases_of(conn: Any, *, project_id: int | None = None, unit_id: int | None = None) -> list[dict[str, Any]]:
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
if unit_id is not None:
|
|
|
|
|
rows = cur.execute(
|
|
|
|
|
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE unit_id = %s"
|
|
|
|
|
" ORDER BY created_at, alias",
|
|
|
|
|
(unit_id,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
else:
|
|
|
|
|
rows = cur.execute(
|
|
|
|
|
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE project_id = %s"
|
|
|
|
|
" ORDER BY created_at, alias",
|
|
|
|
|
(project_id,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def project_detail(conn: Any, project_id: int) -> dict[str, Any]:
|
|
|
|
|
out = _project_or_404(conn, project_id)
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
rows = cur.execute(
|
|
|
|
|
UNIT_SELECT + " WHERE v.project_id = %s AND v.removed_at IS NULL ORDER BY v.lot", (project_id,)
|
|
|
|
|
).fetchall()
|
|
|
|
|
out["units"] = [unit_out(r) for r in rows]
|
|
|
|
|
out["unit_count"] = len(rows)
|
|
|
|
|
out["aliases"] = aliases_of(conn, project_id=project_id)
|
|
|
|
|
out["next_lot"] = next_lot(conn, project_id, out["number"])
|
2026-10-16 14:45:38 -06:00
|
|
|
out["orders"] = cached_orders(conn, out["number"], _project_bases(conn, project_id))
|
2026-10-16 09:31:45 -06:00
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_projects(conn: Any, *, q: str | None = None, status: str = "active") -> list[dict[str, Any]]:
|
|
|
|
|
if status not in LIST_FILTERS:
|
|
|
|
|
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
|
|
|
|
|
where = ["p.removed_at IS NULL"]
|
|
|
|
|
params: list[Any] = []
|
|
|
|
|
if status == "active":
|
|
|
|
|
where.append("p.status <> 'closed'")
|
|
|
|
|
elif status != "all":
|
|
|
|
|
where.append("p.status = %s")
|
|
|
|
|
params.append(status)
|
|
|
|
|
for w in _words(q):
|
|
|
|
|
where.append(
|
|
|
|
|
"(p.number ILIKE %s OR p.name ILIKE %s OR p.customer ILIKE %s OR p.po ILIKE %s"
|
|
|
|
|
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.project_id = p.id AND a.alias ILIKE %s))"
|
|
|
|
|
)
|
|
|
|
|
pat = _like(w)
|
|
|
|
|
params.extend([pat, pat, pat, pat, pat])
|
|
|
|
|
sql = PROJECT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY p.number LIMIT {LIST_LIMIT}"
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
rows = cur.execute(sql, params).fetchall()
|
|
|
|
|
return [project_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_unit(conn: Any, unit_id: int, *, removed: bool = False) -> dict[str, Any] | None:
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
r = cur.execute(UNIT_SELECT + " WHERE v.id = %s", (unit_id,)).fetchone()
|
|
|
|
|
if r is None or (r["removed_at"] is not None and not removed):
|
|
|
|
|
return None
|
|
|
|
|
return unit_out(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unit_or_404(conn: Any, unit_id: int) -> dict[str, Any]:
|
|
|
|
|
out = get_unit(conn, unit_id)
|
|
|
|
|
if out is None:
|
|
|
|
|
raise ApiError(404, "not_found", "No such unit.")
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unit_detail(conn: Any, unit_id: int) -> dict[str, Any]:
|
|
|
|
|
out = _unit_or_404(conn, unit_id)
|
|
|
|
|
p = get_project(conn, out["project_id"])
|
|
|
|
|
if p is None:
|
|
|
|
|
raise ApiError(404, "not_found", "No such unit.")
|
|
|
|
|
out["project"] = {k: p[k] for k in ("id", "number", "name", "customer", "po", "due", "status", "version")}
|
|
|
|
|
out["aliases"] = aliases_of(conn, unit_id=unit_id)
|
2026-10-16 14:45:38 -06:00
|
|
|
out["orders"] = cached_orders(conn, p["number"], _unit_bases(conn, unit_id))
|
2026-10-16 09:31:45 -06:00
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_units(
|
|
|
|
|
conn: Any, *, project_id: int | None = None, q: str | None = None, status: str = "active"
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
"""Units for the picker's second step. Status applies to the effective status (a closed project closes all)."""
|
|
|
|
|
if status not in LIST_FILTERS:
|
|
|
|
|
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
|
|
|
|
|
eff = "(CASE WHEN v.project_status = 'closed' THEN 'closed' ELSE v.status::text END)"
|
|
|
|
|
where = [
|
|
|
|
|
"v.removed_at IS NULL",
|
|
|
|
|
"EXISTS (SELECT 1 FROM projects p WHERE p.id = v.project_id AND p.removed_at IS NULL)",
|
|
|
|
|
]
|
|
|
|
|
params: list[Any] = []
|
|
|
|
|
if project_id is not None:
|
|
|
|
|
where.append("v.project_id = %s")
|
|
|
|
|
params.append(project_id)
|
|
|
|
|
if status == "active":
|
|
|
|
|
where.append(f"{eff} <> 'closed'")
|
|
|
|
|
elif status != "all":
|
|
|
|
|
where.append(f"{eff} = %s")
|
|
|
|
|
params.append(status)
|
|
|
|
|
for w in _words(q):
|
|
|
|
|
where.append(
|
|
|
|
|
"(v.number ILIKE %s OR v.lot ILIKE %s OR v.name ILIKE %s"
|
|
|
|
|
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.unit_id = v.id AND a.alias ILIKE %s))"
|
|
|
|
|
)
|
|
|
|
|
pat = _like(w)
|
|
|
|
|
params.extend([pat, pat, pat, pat])
|
|
|
|
|
sql = UNIT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY v.project_number, v.lot LIMIT {LIST_LIMIT}"
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
rows = cur.execute(sql, params).fetchall()
|
|
|
|
|
return [unit_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- writes: helpers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _audit(conn: Any, who: Who, action: str, subject_type: str, subject_id: Any, detail: Any) -> None:
|
|
|
|
|
audit.append(
|
|
|
|
|
conn,
|
|
|
|
|
action=action,
|
|
|
|
|
user_id=who.user_id,
|
|
|
|
|
session_id=who.session_id,
|
|
|
|
|
role_snapshot=who.role_snapshot,
|
|
|
|
|
subject_type=subject_type,
|
|
|
|
|
subject_id=subject_id,
|
|
|
|
|
detail=detail,
|
|
|
|
|
it_reason=who.it_reason,
|
|
|
|
|
ip=who.ip,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _publish(conn: Any, project_id: int, unit_id: int | None = None, action: str = "") -> None:
|
|
|
|
|
payload: dict[str, Any] = {"event": "changed", "kind": "project", "project_id": project_id, "action": action}
|
|
|
|
|
if unit_id is not None:
|
|
|
|
|
payload["unit_id"] = unit_id
|
|
|
|
|
events.publish(conn, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lock_project(conn: Any, project_id: int) -> dict[str, Any]:
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
r = cur.execute(
|
|
|
|
|
"SELECT * FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE", (project_id,)
|
|
|
|
|
).fetchone()
|
|
|
|
|
if r is None:
|
|
|
|
|
raise ApiError(404, "not_found", "No such project.")
|
|
|
|
|
return dict(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lock_unit(conn: Any, unit_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
|
|
|
"""Lock the unit's project, then the unit (always in that order). Returns (unit, project)."""
|
|
|
|
|
row = conn.execute("SELECT project_id FROM units WHERE id = %s AND removed_at IS NULL", (unit_id,)).fetchone()
|
|
|
|
|
if row is None:
|
|
|
|
|
raise ApiError(404, "not_found", "No such unit.")
|
|
|
|
|
try:
|
|
|
|
|
project = _lock_project(conn, int(row[0]))
|
|
|
|
|
except ApiError:
|
|
|
|
|
raise ApiError(404, "not_found", "No such unit.") from None
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
u = cur.execute("SELECT * FROM units WHERE id = %s AND removed_at IS NULL FOR UPDATE", (unit_id,)).fetchone()
|
|
|
|
|
if u is None:
|
|
|
|
|
raise ApiError(404, "not_found", "No such unit.")
|
|
|
|
|
return dict(u), project
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmp(v: Any) -> Any:
|
|
|
|
|
if v is None:
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(v, date):
|
|
|
|
|
return v.isoformat()
|
|
|
|
|
text = str(v).strip()
|
|
|
|
|
return text or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _norm_field(key: str, value: Any, *, unit: bool) -> Any:
|
|
|
|
|
if key == "due":
|
|
|
|
|
return parse_due(value)
|
|
|
|
|
if key == "status":
|
|
|
|
|
return parse_status(value)
|
|
|
|
|
if key == "name":
|
|
|
|
|
return clean_opt(value, LIMITS["unit_name"] if unit else LIMITS["project_name"])
|
|
|
|
|
if key == "customer":
|
|
|
|
|
return clean_opt(value, LIMITS["customer"])
|
|
|
|
|
if key == "po":
|
|
|
|
|
return clean_opt(value, LIMITS["po"])
|
|
|
|
|
if key == "notes":
|
|
|
|
|
return clean_opt(value, LIMITS["notes"])
|
2026-10-16 14:45:38 -06:00
|
|
|
if key == "order_pin":
|
|
|
|
|
pin = clean_opt(value, LIMITS["order_pin"] + 20)
|
|
|
|
|
if pin is not None and not RX_ORDER_ID.match(pin):
|
|
|
|
|
raise ApiError(422, "invalid_order", "Give a VISUAL customer order ID, or nothing to clear the pin.")
|
|
|
|
|
return pin
|
|
|
|
|
if key == "order_pin_reason":
|
|
|
|
|
return clean_opt(value, LIMITS["reason"])
|
2026-10-16 09:31:45 -06:00
|
|
|
raise ApiError(422, "invalid_field", f"{key} cannot be changed here.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def field_cas(
|
|
|
|
|
row: Mapping[str, Any], values: Mapping[str, Any], base: Mapping[str, Any], fields: Iterable[str], *, unit: bool
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""The field-level CAS: returns {key: new value} for the keys that change; 409 field_conflict on a stale base.
|
|
|
|
|
|
|
|
|
|
A key whose base differs from the current value conflicts, unless the new value already equals the current one.
|
|
|
|
|
Keys without a base entry are written without a check.
|
|
|
|
|
"""
|
|
|
|
|
allowed = set(fields)
|
|
|
|
|
bad = [k for k in values if k not in allowed]
|
|
|
|
|
if bad:
|
|
|
|
|
raise ApiError(422, "invalid_field", f"{', '.join(sorted(bad))} cannot be changed here.")
|
|
|
|
|
new = {k: _norm_field(k, v, unit=unit) for k, v in values.items()}
|
|
|
|
|
conflicts = {
|
|
|
|
|
k: _iso(row[k]) for k in new if k in base and _cmp(base[k]) != _cmp(row[k]) and _cmp(new[k]) != _cmp(row[k])
|
|
|
|
|
}
|
|
|
|
|
if conflicts:
|
|
|
|
|
raise ApiError(
|
|
|
|
|
409,
|
|
|
|
|
"field_conflict",
|
|
|
|
|
"Someone else changed this since you opened it. Check the new values.",
|
|
|
|
|
extra={"current": conflicts},
|
|
|
|
|
)
|
|
|
|
|
return {k: v for k, v in new.items() if _cmp(v) != _cmp(row[k])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _change_detail(row: Mapping[str, Any], changes: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
out: dict[str, Any] = {}
|
|
|
|
|
for k, v in changes.items():
|
|
|
|
|
out[k] = {"changed": True} if k == "notes" else {"from": _iso(row[k]), "to": _iso(v)}
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def _columns(row: Mapping[str, Any], changes: Mapping[str, Any], who: Who) -> dict[str, Any]:
|
|
|
|
|
"""The columns a field edit writes: a hand customer or po marks its source, a pin records who and when."""
|
|
|
|
|
cols = dict(changes)
|
|
|
|
|
for k in ("customer", "po"):
|
|
|
|
|
if k in cols:
|
|
|
|
|
cols[f"{k}_src"] = "hand" if cols[k] else None
|
|
|
|
|
if "order_pin" in cols:
|
|
|
|
|
if cols["order_pin"] is None:
|
|
|
|
|
cols.update(order_pin_by=None, order_pin_at=None, order_pin_reason=None)
|
|
|
|
|
else:
|
|
|
|
|
cols["order_pin_by"] = who.user_id
|
|
|
|
|
cols["order_pin_at"] = datetime.now(UTC)
|
|
|
|
|
return cols
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_pin(
|
|
|
|
|
conn: Any, row: Mapping[str, Any], changes: Mapping[str, Any], *, project_number: str, bases: list[str]
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A new pin needs a reason and, when VISUAL's orders are cached for this project, must name one of them."""
|
|
|
|
|
if "order_pin" not in changes and "order_pin_reason" in changes and row.get("order_pin") is None:
|
|
|
|
|
raise ApiError(422, "invalid_field", "There is no order pin to give a reason for.")
|
|
|
|
|
pin = changes.get("order_pin")
|
|
|
|
|
if pin is None:
|
|
|
|
|
return
|
|
|
|
|
if not changes.get("order_pin_reason"):
|
|
|
|
|
raise ApiError(400, "reason_required", "Say why this order is pinned.")
|
|
|
|
|
known = [o["cust_order_id"] for o in cached_orders(conn, project_number, bases)]
|
|
|
|
|
if known and pin.upper() not in {k.upper() for k in known}:
|
|
|
|
|
raise ApiError(
|
|
|
|
|
422,
|
|
|
|
|
"unknown_order",
|
|
|
|
|
f"Order {pin} is not one of the VISUAL orders for {project_number}.",
|
|
|
|
|
extra={"orders": known},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _project_bases(conn: Any, project_id: int) -> list[str]:
|
|
|
|
|
return [
|
|
|
|
|
r[0]
|
|
|
|
|
for r in conn.execute(
|
|
|
|
|
"SELECT DISTINCT wo.wo_base FROM packet_work_orders wo JOIN packets k ON k.id = wo.packet_id"
|
|
|
|
|
" JOIN units u ON u.id = k.unit_id WHERE u.project_id = %s ORDER BY 1",
|
|
|
|
|
(project_id,),
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unit_bases(conn: Any, unit_id: int) -> list[str]:
|
|
|
|
|
return [
|
|
|
|
|
r[0]
|
|
|
|
|
for r in conn.execute(
|
|
|
|
|
"SELECT DISTINCT wo.wo_base FROM packet_work_orders wo JOIN packets k ON k.id = wo.packet_id"
|
|
|
|
|
" WHERE k.unit_id = %s ORDER BY 1",
|
|
|
|
|
(unit_id,),
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cached_orders(conn: Any, project_number: str, bases: Iterable[str]) -> list[dict[str, Any]]:
|
|
|
|
|
"""Every cached VISUAL order of a project: the ones linked to the project number, then the ones linked to its
|
|
|
|
|
work order bases. Each once, with `via` (project | base) and the link's lines and top jobs."""
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
rows = cur.execute(
|
|
|
|
|
"SELECT o.*, l.kind AS via, l.key, l.rank, l.lines, l.top_jobs FROM visual_order_links l"
|
|
|
|
|
" JOIN visual_orders o ON o.cust_order_id = l.cust_order_id"
|
|
|
|
|
" WHERE (l.kind = 'project' AND upper(l.key) = upper(%s)) OR (l.kind = 'base' AND l.key = ANY(%s))"
|
|
|
|
|
" ORDER BY CASE l.kind WHEN 'project' THEN 0 ELSE 1 END, l.rank, l.key, o.cust_order_id",
|
|
|
|
|
(project_number, list(bases)),
|
|
|
|
|
).fetchall()
|
|
|
|
|
out: list[dict[str, Any]] = []
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
for r in rows:
|
|
|
|
|
if r["cust_order_id"] in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(r["cust_order_id"])
|
|
|
|
|
out.append(
|
|
|
|
|
{
|
|
|
|
|
"cust_order_id": r["cust_order_id"],
|
|
|
|
|
"status": r["status"],
|
|
|
|
|
"customer_id": r["customer_id"],
|
|
|
|
|
"customer_name": r["customer_name"],
|
|
|
|
|
"customer_po_ref": r["customer_po_ref"],
|
|
|
|
|
"order_date": r["order_date"],
|
|
|
|
|
"desired_ship_date": r["desired_ship_date"],
|
|
|
|
|
"promise_date": r["promise_date"],
|
|
|
|
|
"via": r["via"],
|
|
|
|
|
"key": r["key"],
|
|
|
|
|
"lines": list(r["lines"] or []),
|
|
|
|
|
"top_jobs": list(r["top_jobs"] or []),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
# ---------------------------------------------------------------- writes: projects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_project(conn: Any, who: Who, data: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
number = normalize_number(data.get("number"))
|
|
|
|
|
name = clean_opt(data.get("name"), LIMITS["project_name"])
|
|
|
|
|
customer = clean_opt(data.get("customer"), LIMITS["customer"])
|
|
|
|
|
po = clean_opt(data.get("po"), LIMITS["po"])
|
|
|
|
|
notes = clean_opt(data.get("notes"), LIMITS["notes"])
|
|
|
|
|
due = parse_due(data.get("due"))
|
|
|
|
|
status = parse_status(data.get("status") or "open")
|
|
|
|
|
try:
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
conn.execute("SELECT pg_advisory_xact_lock(7312)") # one number allocation at a time
|
|
|
|
|
assert_free(conn, number)
|
|
|
|
|
pid = conn.execute(
|
|
|
|
|
"INSERT INTO projects (number, name, customer, customer_src, po, po_src, due, status, notes, source,"
|
|
|
|
|
" created_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s) RETURNING id",
|
|
|
|
|
(
|
|
|
|
|
number,
|
|
|
|
|
name,
|
|
|
|
|
customer,
|
|
|
|
|
"hand" if customer else None,
|
|
|
|
|
po,
|
|
|
|
|
"hand" if po else None,
|
|
|
|
|
due,
|
|
|
|
|
status,
|
|
|
|
|
notes,
|
|
|
|
|
who.user_id,
|
|
|
|
|
),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
_audit(
|
|
|
|
|
conn,
|
|
|
|
|
who,
|
|
|
|
|
"project.created",
|
|
|
|
|
"project",
|
|
|
|
|
pid,
|
|
|
|
|
{"number": number, "name": name, "customer": customer, "po": po, "due": _iso(due), "status": status},
|
|
|
|
|
)
|
|
|
|
|
_publish(conn, pid, action="project.created")
|
|
|
|
|
except pgerr.UniqueViolation as e:
|
|
|
|
|
raise _unique_taken(e) from None
|
|
|
|
|
return _project_or_404(conn, pid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_project(
|
|
|
|
|
conn: Any, who: Who, project_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
row = _lock_project(conn, project_id)
|
|
|
|
|
changes = field_cas(row, values, base, PROJECT_FIELDS, unit=False)
|
|
|
|
|
if changes:
|
2026-10-16 14:45:38 -06:00
|
|
|
_check_pin(conn, row, changes, project_number=row["number"], bases=_project_bases(conn, project_id))
|
|
|
|
|
cols = _columns(row, changes, who)
|
2026-10-16 09:31:45 -06:00
|
|
|
sets = ", ".join(f"{k} = %s" for k in cols)
|
|
|
|
|
conn.execute(
|
|
|
|
|
f"UPDATE projects SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
|
|
|
|
|
[*cols.values(), project_id],
|
|
|
|
|
)
|
|
|
|
|
_audit(
|
|
|
|
|
conn,
|
|
|
|
|
who,
|
|
|
|
|
"project.updated",
|
|
|
|
|
"project",
|
|
|
|
|
project_id,
|
|
|
|
|
{"number": row["number"], "fields": _change_detail(row, changes)},
|
|
|
|
|
)
|
|
|
|
|
_publish(conn, project_id, action="project.updated")
|
|
|
|
|
return _project_or_404(conn, project_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def renumber_project(conn: Any, who: Who, project_id: int, number: Any, reason: Any) -> dict[str, Any]:
|
|
|
|
|
new = normalize_number(number)
|
|
|
|
|
why = clean_reason(reason)
|
|
|
|
|
try:
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
conn.execute("SELECT pg_advisory_xact_lock(7312)")
|
|
|
|
|
row = _lock_project(conn, project_id)
|
|
|
|
|
old = row["number"]
|
|
|
|
|
if new == old.upper():
|
|
|
|
|
raise ApiError(400, "same_number", f"The project is already {old}.")
|
|
|
|
|
units = conn.execute(
|
|
|
|
|
"SELECT id, lot FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY lot FOR UPDATE",
|
|
|
|
|
(project_id,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
assert_free(conn, new, own_project=project_id)
|
|
|
|
|
for uid, lot in units:
|
|
|
|
|
for o in owners(conn, new + lot):
|
|
|
|
|
own_live = o["kind"] == "unit" and not o["alias"] and o["project_id"] == project_id
|
|
|
|
|
own_old = o["kind"] == "unit" and o["alias"] and o["id"] == uid
|
|
|
|
|
if not (own_live or own_old):
|
|
|
|
|
raise _taken(o)
|
|
|
|
|
old_numbers = {old.upper(): None, **{(old + lot).upper(): uid for uid, lot in units}}
|
|
|
|
|
new_numbers = [new, *[new + lot for _, lot in units]]
|
|
|
|
|
if set(old_numbers) & set(new_numbers):
|
|
|
|
|
raise ApiError(409, "number_taken", "The new numbers overlap the old ones. Pick another number.")
|
|
|
|
|
conn.execute(
|
|
|
|
|
"DELETE FROM project_aliases WHERE alias = ANY(%s) AND project_id = %s", (new_numbers, project_id)
|
|
|
|
|
)
|
|
|
|
|
for alias, uid in old_numbers.items():
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'renumber')"
|
|
|
|
|
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
|
|
|
|
|
" kind = EXCLUDED.kind, created_at = now()",
|
|
|
|
|
(alias, project_id, uid),
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE projects SET number = %s, version = version + 1, updated_at = now() WHERE id = %s",
|
|
|
|
|
(new, project_id),
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
|
|
|
|
|
" VALUES (%s, %s, NULL, %s, %s, %s)",
|
|
|
|
|
(who.user_id, project_id, old, new, why),
|
|
|
|
|
)
|
|
|
|
|
for uid, lot in units:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
|
|
|
|
(who.user_id, project_id, uid, old + lot, new + lot, why),
|
|
|
|
|
)
|
|
|
|
|
_audit(
|
|
|
|
|
conn,
|
|
|
|
|
who,
|
|
|
|
|
"project.renumbered",
|
|
|
|
|
"project",
|
|
|
|
|
project_id,
|
|
|
|
|
{"from": old, "to": new, "reason": why, "units": len(units)},
|
|
|
|
|
)
|
|
|
|
|
_publish(conn, project_id, action="project.renumbered")
|
|
|
|
|
except pgerr.UniqueViolation as e:
|
|
|
|
|
raise _unique_taken(e) from None
|
|
|
|
|
return _project_or_404(conn, project_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _live_packets(conn: Any, unit_ids: list[int]) -> int:
|
|
|
|
|
if not unit_ids:
|
|
|
|
|
return 0
|
|
|
|
|
return int(
|
|
|
|
|
conn.execute(
|
|
|
|
|
"SELECT count(*) FROM packets WHERE unit_id = ANY(%s) AND removed_at IS NULL", (unit_ids,)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_project(conn: Any, who: Who, project_id: int, reason: Any) -> dict[str, Any]:
|
|
|
|
|
"""Soft delete with its units. Refused (409 has_packets) while any of its units has a live packet."""
|
|
|
|
|
why = clean_reason(reason)
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
row = _lock_project(conn, project_id)
|
|
|
|
|
ids = [
|
|
|
|
|
int(r[0])
|
|
|
|
|
for r in conn.execute(
|
|
|
|
|
"SELECT id FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY id FOR UPDATE",
|
|
|
|
|
(project_id,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
n = _live_packets(conn, ids)
|
|
|
|
|
if n:
|
|
|
|
|
raise ApiError(
|
|
|
|
|
409,
|
|
|
|
|
"has_packets",
|
|
|
|
|
f"{row['number']} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
|
|
|
|
|
extra={"packets": n},
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
|
|
|
|
|
" updated_at = now() WHERE id = ANY(%s)",
|
|
|
|
|
(who.user_id, why, ids),
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE projects SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
|
|
|
|
|
" updated_at = now() WHERE id = %s",
|
|
|
|
|
(who.user_id, why, project_id),
|
|
|
|
|
)
|
|
|
|
|
_audit(
|
|
|
|
|
conn,
|
|
|
|
|
who,
|
|
|
|
|
"project.removed",
|
|
|
|
|
"project",
|
|
|
|
|
project_id,
|
|
|
|
|
{"number": row["number"], "reason": why, "units": ids},
|
|
|
|
|
)
|
|
|
|
|
_publish(conn, project_id, action="project.removed")
|
|
|
|
|
out = get_project(conn, project_id, removed=True)
|
|
|
|
|
assert out is not None
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- writes: units
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lots_from_request(
|
|
|
|
|
project_number: str,
|
|
|
|
|
*,
|
|
|
|
|
lot: Any = None,
|
|
|
|
|
lots: list[Any] | None = None,
|
|
|
|
|
count: int | None = None,
|
|
|
|
|
start: Any = None,
|
|
|
|
|
suggest: str | None = None,
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
"""The unit sheet's one-or-several mode: one lot, a list of lots, or `count` lots counting up from `start`."""
|
|
|
|
|
if lots:
|
|
|
|
|
out = [lot_input(project_number, x) for x in lots]
|
|
|
|
|
elif count is not None:
|
|
|
|
|
if not 1 <= count <= MAX_UNITS_AT_ONCE:
|
|
|
|
|
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
|
|
|
|
|
first = lot_input(project_number, start) if start not in (None, "") else suggest
|
|
|
|
|
if first is None or not _DIGITS.match(first):
|
|
|
|
|
raise ApiError(422, "invalid_lot", "The starting lot has to be a number, e.g. 001.")
|
|
|
|
|
b = int(first)
|
|
|
|
|
if b + count - 1 > 999:
|
|
|
|
|
raise ApiError(422, "invalid_lot", "Lots go up to 999. Start lower or add fewer.")
|
|
|
|
|
out = [f"{b + i:03d}" for i in range(count)]
|
|
|
|
|
elif lot not in (None, ""):
|
|
|
|
|
out = [lot_input(project_number, lot)]
|
|
|
|
|
else:
|
|
|
|
|
if suggest is None:
|
|
|
|
|
raise ApiError(422, "invalid_lot", "No free lot left in this project. Give one.")
|
|
|
|
|
out = [suggest]
|
|
|
|
|
if len(out) > MAX_UNITS_AT_ONCE:
|
|
|
|
|
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
|
|
|
|
|
dup = sorted({x for x in out if out.count(x) > 1})
|
|
|
|
|
if dup:
|
|
|
|
|
raise ApiError(409, "number_taken", f"Lot {dup[0]} is in the list twice.", extra={"lots": dup})
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_units(conn: Any, who: Who, project_id: int, data: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
|
|
|
name = clean_opt(data.get("name"), LIMITS["unit_name"])
|
|
|
|
|
notes = clean_opt(data.get("notes"), LIMITS["notes"])
|
|
|
|
|
due = parse_due(data.get("due"))
|
|
|
|
|
status = parse_status(data.get("status") or "open")
|
|
|
|
|
try:
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
conn.execute("SELECT pg_advisory_xact_lock(7312)")
|
|
|
|
|
project = _lock_project(conn, project_id)
|
|
|
|
|
if project["status"] == "closed":
|
|
|
|
|
raise ApiError(409, "project_closed", f"Project {project['number']} is closed. Reopen it to add units.")
|
|
|
|
|
pn = project["number"]
|
|
|
|
|
wants_suggest = not data.get("lots") and data.get("lot") in (None, "") and data.get("start") in (None, "")
|
|
|
|
|
lots = lots_from_request(
|
|
|
|
|
pn,
|
|
|
|
|
lot=data.get("lot"),
|
|
|
|
|
lots=data.get("lots"),
|
|
|
|
|
count=data.get("count"),
|
|
|
|
|
start=data.get("start"),
|
|
|
|
|
suggest=next_lot(conn, project_id, pn) if wants_suggest else None,
|
|
|
|
|
)
|
|
|
|
|
for lot in lots:
|
|
|
|
|
assert_free(conn, pn + lot)
|
|
|
|
|
ids: list[int] = []
|
|
|
|
|
for lot in lots:
|
|
|
|
|
uid = conn.execute(
|
|
|
|
|
"INSERT INTO units (project_id, lot, name, due, status, notes, created_by)"
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
|
|
|
|
(project_id, lot, name, due, status, notes, who.user_id),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
ids.append(int(uid))
|
|
|
|
|
_audit(
|
|
|
|
|
conn,
|
|
|
|
|
who,
|
|
|
|
|
"unit.created",
|
|
|
|
|
"unit",
|
|
|
|
|
uid,
|
|
|
|
|
{"number": pn + lot, "project_id": project_id, "lot": lot, "name": name, "due": _iso(due)},
|
|
|
|
|
)
|
|
|
|
|
_publish(conn, project_id, action="unit.created")
|
|
|
|
|
except pgerr.UniqueViolation as e:
|
|
|
|
|
raise _unique_taken(e) from None
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
rows = cur.execute(UNIT_SELECT + " WHERE v.id = ANY(%s) ORDER BY v.lot", (ids,)).fetchall()
|
|
|
|
|
return [unit_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_unit(
|
|
|
|
|
conn: Any, who: Who, unit_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
row, project = _lock_unit(conn, unit_id)
|
|
|
|
|
changes = field_cas(row, values, base, UNIT_FIELDS, unit=True)
|
|
|
|
|
if changes:
|
2026-10-16 14:45:38 -06:00
|
|
|
_check_pin(conn, row, changes, project_number=project["number"], bases=_unit_bases(conn, unit_id))
|
|
|
|
|
cols = _columns(row, changes, who)
|
|
|
|
|
sets = ", ".join(f"{k} = %s" for k in cols)
|
2026-10-16 09:31:45 -06:00
|
|
|
conn.execute(
|
|
|
|
|
f"UPDATE units SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
|
2026-10-16 14:45:38 -06:00
|
|
|
[*cols.values(), unit_id],
|
2026-10-16 09:31:45 -06:00
|
|
|
)
|
|
|
|
|
_audit(
|
|
|
|
|
conn,
|
|
|
|
|
who,
|
|
|
|
|
"unit.updated",
|
|
|
|
|
"unit",
|
|
|
|
|
unit_id,
|
|
|
|
|
{"number": project["number"] + row["lot"], "fields": _change_detail(row, changes)},
|
|
|
|
|
)
|
|
|
|
|
_publish(conn, project["id"], unit_id, action="unit.updated")
|
|
|
|
|
return _unit_or_404(conn, unit_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def renumber_unit(conn: Any, who: Who, unit_id: int, lot: Any, reason: Any) -> dict[str, Any]:
|
|
|
|
|
why = clean_reason(reason)
|
|
|
|
|
try:
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
conn.execute("SELECT pg_advisory_xact_lock(7312)")
|
|
|
|
|
row, project = _lock_unit(conn, unit_id)
|
|
|
|
|
pn = project["number"]
|
|
|
|
|
new_lot = lot_input(pn, lot)
|
|
|
|
|
if new_lot == row["lot"]:
|
|
|
|
|
raise ApiError(400, "same_number", f"The unit is already {pn + new_lot}.")
|
|
|
|
|
old, new = pn + row["lot"], pn + new_lot
|
|
|
|
|
assert_free(conn, new, own_unit=unit_id)
|
|
|
|
|
conn.execute("DELETE FROM project_aliases WHERE alias = %s AND unit_id = %s", (new.upper(), unit_id))
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'unit_renumber')"
|
|
|
|
|
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
|
|
|
|
|
" kind = EXCLUDED.kind, created_at = now()",
|
|
|
|
|
(old.upper(), project["id"], unit_id),
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE units SET lot = %s, version = version + 1, updated_at = now() WHERE id = %s", (new_lot, unit_id)
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
|
|
|
|
(who.user_id, project["id"], unit_id, old, new, why),
|
|
|
|
|
)
|
|
|
|
|
_audit(conn, who, "unit.renumbered", "unit", unit_id, {"from": old, "to": new, "reason": why})
|
|
|
|
|
_publish(conn, project["id"], unit_id, action="unit.renumbered")
|
|
|
|
|
except pgerr.UniqueViolation as e:
|
|
|
|
|
raise _unique_taken(e) from None
|
|
|
|
|
return _unit_or_404(conn, unit_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_unit(conn: Any, who: Who, unit_id: int, reason: Any) -> dict[str, Any]:
|
|
|
|
|
why = clean_reason(reason)
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
row, project = _lock_unit(conn, unit_id)
|
|
|
|
|
n = _live_packets(conn, [unit_id])
|
|
|
|
|
number = project["number"] + row["lot"]
|
|
|
|
|
if n:
|
|
|
|
|
raise ApiError(
|
|
|
|
|
409,
|
|
|
|
|
"has_packets",
|
|
|
|
|
f"{number} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
|
|
|
|
|
extra={"packets": n},
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
|
|
|
|
|
" updated_at = now() WHERE id = %s",
|
|
|
|
|
(who.user_id, why, unit_id),
|
|
|
|
|
)
|
|
|
|
|
_audit(conn, who, "unit.removed", "unit", unit_id, {"number": number, "reason": why})
|
|
|
|
|
_publish(conn, project["id"], unit_id, action="unit.removed")
|
|
|
|
|
out = get_unit(conn, unit_id, removed=True)
|
|
|
|
|
assert out is not None
|
|
|
|
|
return out
|