diff --git a/src/opal/api/routes/parts.py b/src/opal/api/routes/parts.py index d44863a..3a51edb 100644 --- a/src/opal/api/routes/parts.py +++ b/src/opal/api/routes/parts.py @@ -16,7 +16,9 @@ from opal.core.audit import get_model_dict, log_create, log_delete, log_update from opal.core.numbering import ( PartNumberError, + format_has_variant, next_part_number, + next_variant_part_number, peek_next_part_number, pn_exists, register_part_number, @@ -30,7 +32,7 @@ ensure_identity_mutable, reference_counts, ) -from opal.db.models import InventoryRecord, Part, Supplier, SupplierPart +from opal.db.models import BOMLine, InventoryRecord, Part, Supplier, SupplierPart router = APIRouter() @@ -73,6 +75,26 @@ class PartUpdate(BaseModel): metadata: dict[str, Any] | None = None +class PartVariantCreate(BaseModel): + """Overrides for a new variant; omitted fields copy from the source part. + + Identity (tier, internal_pn) is derived from the source and never + overridable — a variant is a sibling, not a new sequence. + """ + + name: str | None = None + description: str | None = None + category: str | None = None + unit_of_measure: str | None = None + tracking_type: str | None = None # "bulk" or "serialized" + external_pn: str | None = None + reorder_point: Decimal | None = None + is_tooling: bool | None = None + calibration_interval_days: int | None = None + parent_id: int | None = None + metadata: dict[str, Any] | None = None + + class PartResponse(BaseModel): """Schema for part response.""" @@ -310,6 +332,99 @@ def create_part( return get_part_with_quantity(db, part) +@router.post( + "/{part_id}/variants", response_model=PartResponse, status_code=status.HTTP_201_CREATED +) +def create_part_variant( + db: DbSession, + part_id: int, + user_id: CurrentUserId, + variant_in: PartVariantCreate | None = None, +) -> PartResponse: + """Mint the next variant of a part: a draft sibling copying attributes and BOM. + + The variant shares the source's tier+sequence base with the next variant + code; the tier sequence counter does not advance. Body fields override + the copied attributes; omitted fields copy, explicit nulls clear. + """ + part = db.query(Part).filter(Part.id == part_id, Part.deleted_at.is_(None)).first() + if not part: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"Part {part_id} not found" + ) + if not format_has_variant(get_active_project()): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Part numbering format has no {variant} placeholder", + ) + if not part.internal_pn: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Part has no internal part number, so its variant family cannot be derived", + ) + + overrides = variant_in.model_dump(exclude_unset=True) if variant_in else {} + parent_id = overrides.get("parent_id", part.parent_id) + if parent_id is not None and parent_id != part.parent_id: + parent = db.query(Part).filter(Part.id == parent_id, Part.deleted_at.is_(None)).first() + if not parent: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Parent part {parent_id} not found", + ) + + try: + new_pn = next_variant_part_number(db, part.tier, part.internal_pn) + except PartNumberError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e + + if "metadata" in overrides: + metadata = overrides["metadata"] + else: + metadata = dict(part.metadata_) if part.metadata_ else None + + variant = Part( + # name is NOT NULL: a null/empty override falls back to the source name + name=overrides.get("name") or part.name, + internal_pn=new_pn, + external_pn=overrides.get("external_pn", part.external_pn), + description=overrides.get("description", part.description), + category=overrides.get("category", part.category), + unit_of_measure=overrides.get("unit_of_measure") or part.unit_of_measure, + tracking_type=overrides.get("tracking_type") or part.tracking_type, + tier=part.tier, + parent_id=parent_id, + reorder_point=overrides.get("reorder_point", part.reorder_point), + # NOT NULL: a null override means "no opinion", not "clear" + is_tooling=( + part.is_tooling if overrides.get("is_tooling") is None else overrides["is_tooling"] + ), + calibration_interval_days=overrides.get( + "calibration_interval_days", part.calibration_interval_days + ), + metadata_=metadata, + ) + db.add(variant) + db.flush() + log_create(db, variant, user_id) + + for line in part.bom_lines: + bom_copy = BOMLine( + assembly_id=variant.id, + component_id=line.component_id, + quantity=line.quantity, + reference_designator=line.reference_designator, + notes=line.notes, + ) + db.add(bom_copy) + db.flush() + log_create(db, bom_copy, user_id) + + db.commit() + db.refresh(variant) + return get_part_with_quantity(db, variant) + + class NextPnResponse(BaseModel): """Preview of the next part number for a tier.""" diff --git a/src/opal/api/routes/project.py b/src/opal/api/routes/project.py index 3572fc5..95dcb8c 100644 --- a/src/opal/api/routes/project.py +++ b/src/opal/api/routes/project.py @@ -35,6 +35,7 @@ class PartNumberingInput(BaseModel): prefix: str = "" separator: str = "-" sequence_digits: int = 4 + variant_digits: int = 3 format: str = "{prefix}{sep}{tier_code}{sep}{sequence}" @@ -98,6 +99,7 @@ def from_config(cls, config: ProjectConfig) -> "ProjectConfigResponse": prefix=config.part_numbering.prefix, separator=config.part_numbering.separator, sequence_digits=config.part_numbering.sequence_digits, + variant_digits=config.part_numbering.variant_digits, format=config.part_numbering.format, ), categories=config.categories, @@ -125,6 +127,7 @@ def _numbering_from_input(pn: PartNumberingInput) -> PartNumberingConfig: prefix=pn.prefix, separator=pn.separator, sequence_digits=pn.sequence_digits, + variant_digits=pn.variant_digits, format=pn.format, ) diff --git a/src/opal/core/numbering.py b/src/opal/core/numbering.py index 47984a2..792c4e0 100644 --- a/src/opal/core/numbering.py +++ b/src/opal/core/numbering.py @@ -42,7 +42,26 @@ def part_number_regex(project: ProjectConfig | None, tier_level: int) -> re.Patt """ if project is None: return re.compile(rf"PN-{tier_level}-(?P\d{{{_FALLBACK_SEQUENCE_DIGITS},}})$") + return _compile_format_regex(project, tier_level, project.part_numbering.format) + +def base_part_number_regex(project: ProjectConfig, tier_level: int) -> re.Pattern[str]: + """Regex for the format with its {variant} segment stripped. + + Matches part numbers minted before {variant} entered the format; those + legacy bases anchor a variant family without being renumbered. + """ + template = project.part_numbering.format + if "{sep}{variant}" in template: + template = template.replace("{sep}{variant}", "") + else: + template = template.replace("{variant}", "") + return _compile_format_regex(project, tier_level, template) + + +def _compile_format_regex( + project: ProjectConfig, tier_level: int, template: str +) -> re.Pattern[str]: tier = project.get_tier(tier_level) if tier is None: raise PartNumberError(f"Unknown tier level: {tier_level}") @@ -54,7 +73,6 @@ def part_number_regex(project: ProjectConfig | None, tier_level: int) -> re.Patt "tier_name": tier.name, "tier_level": str(tier.level), } - template = project.part_numbering.format pattern = "" pos = 0 for match in re.finditer(r"\{(\w+)\}", template): @@ -62,6 +80,8 @@ def part_number_regex(project: ProjectConfig | None, tier_level: int) -> re.Patt token = match.group(1) if token == "sequence": pattern += rf"(?P\d{{{project.part_numbering.sequence_digits},}})" + elif token == "variant": + pattern += rf"(?P\d{{{project.part_numbering.variant_digits},}})" elif token in values: pattern += re.escape(values[token]) else: @@ -127,6 +147,65 @@ def next_part_number(db: Session, tier_level: int) -> str: return pn +def format_has_variant(project: ProjectConfig | None) -> bool: + """True when the numbering format mints variant codes.""" + return project is not None and "{variant}" in project.part_numbering.format + + +def variant_family_key( + project: ProjectConfig | None, tier_level: int, pn: str +) -> tuple[int, int] | None: + """(sequence, variant) placing a PN in its variant family, or None. + + A PN matching the format with the {variant} segment stripped is a legacy + base — numbered before {variant} entered the format. It counts as + variant 1 of its family: 001-0001 IS configuration 1, so its first + explicit variant mints -002 and code 001 stays a permanent gap. + """ + if not format_has_variant(project): + return None + match = part_number_regex(project, tier_level).match(pn) + if match: + return int(match.group("sequence")), int(match.group("variant")) + match = base_part_number_regex(project, tier_level).match(pn) + if match: + return int(match.group("sequence")), 1 + return None + + +def next_variant_part_number(db: Session, tier_level: int, source_pn: str) -> str: + """Mint the next variant of an existing part number. + + Variants share the source's tier+sequence base; the next code is + max(existing)+1 across ALL rows including soft-deleted ones — variant + codes are never reused. The tier sequence counter is not touched: + a variant is a sibling, not a new sequence. + """ + from opal.config import get_active_project + + project = get_active_project() + if project is None or not format_has_variant(project): + raise PartNumberError("Part numbering format has no {variant} placeholder") + + key = variant_family_key(project, tier_level, source_pn) + if key is None: + example = _format_part_number(project, tier_level, 1) + raise PartNumberError( + f"'{source_pn}' does not match the tier-{tier_level} part number format " + f"(e.g. {example}), so its variant family cannot be derived" + ) + sequence = key[0] + + # Full-column scan + regex match: correct for arbitrary token order and + # fine at single-instance SQLite scale. + max_variant = 0 + for (pn,) in db.query(Part.internal_pn).filter(Part.internal_pn.isnot(None)).all(): + k = variant_family_key(project, tier_level, pn) + if k and k[0] == sequence: + max_variant = max(max_variant, k[1]) + return project.generate_part_number(tier_level, sequence, max_variant + 1) + + def peek_next_part_number(db: Session, tier_level: int) -> tuple[str, int]: """Preview the next part number for a tier without consuming it.""" from opal.config import get_active_project diff --git a/src/opal/project.py b/src/opal/project.py index abdd7ff..0b125db 100644 --- a/src/opal/project.py +++ b/src/opal/project.py @@ -22,6 +22,7 @@ class PartNumberingConfig(BaseModel): prefix: str = "" separator: str = "-" sequence_digits: int = 4 + variant_digits: int = 3 format: str = "{prefix}{sep}{tier_code}{sep}{sequence}" @@ -140,12 +141,13 @@ def get_requirement(self, req_id: str) -> RequirementConfig | None: return req return None - def generate_part_number(self, tier_level: int, sequence: int) -> str: + def generate_part_number(self, tier_level: int, sequence: int, variant: int = 1) -> str: """Generate a part number according to project config. Args: tier_level: The tier level (1, 2, 3, etc.) sequence: The sequence number for this part. + variant: The variant code, used only when the format includes {variant}. Returns: Formatted part number string. @@ -164,6 +166,7 @@ def generate_part_number(self, tier_level: int, sequence: int) -> str: tier_name=tier.name, tier_level=tier.level, sequence=str(sequence).zfill(self.part_numbering.sequence_digits), + variant=str(variant).zfill(self.part_numbering.variant_digits), ) @@ -280,6 +283,7 @@ def create_project_config( prefix: str = "", separator: str = "-", sequence_digits: int = 4, + variant_digits: int = 3, part_number_format: str = "{prefix}{sep}{tier_code}{sep}{sequence}", tiers: list[TierConfig] | None = None, requirements: list[RequirementConfig] | None = None, @@ -295,6 +299,7 @@ def create_project_config( prefix: Part number prefix. separator: Part number separator (default: "-"). sequence_digits: Number of digits in sequence (default: 4). + variant_digits: Number of digits in the variant code (default: 3). part_number_format: Format string for part numbers. tiers: List of inventory tiers (defaults to Flight/Ground/Loose). requirements: List of project requirements. @@ -315,6 +320,7 @@ def create_project_config( prefix=prefix, separator=separator, sequence_digits=sequence_digits, + variant_digits=variant_digits, format=part_number_format, ), requirements=requirements or [], @@ -358,6 +364,7 @@ def save_project_config(config: ProjectConfig) -> None: "prefix": config.part_numbering.prefix, "separator": config.part_numbering.separator, "sequence_digits": config.part_numbering.sequence_digits, + "variant_digits": config.part_numbering.variant_digits, "format": config.part_numbering.format, }, "requirements": [ diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index d129ef8..7717901 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -7,6 +7,7 @@ from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import Any +from urllib.parse import quote from fastapi import APIRouter, Form, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -853,9 +854,7 @@ def parts_import(request: Request, db: DbSession) -> HTMLResponse: @router.get("/parts/new", response_class=HTMLResponse) -def parts_new( - request: Request, db: DbSession, parent_id: int | None = Query(None) -) -> HTMLResponse: +def parts_new(request: Request, db: DbSession, parent_id: int | None = Query(None)) -> HTMLResponse: """Deep link: the parts list with the create overlay open. The form never asks what the invoking context already knows — @@ -874,18 +873,14 @@ def parts_new( def _parts_detail_response( - request: Request, db: DbSession, part_id: int, edit_open: bool = False + request: Request, + db: DbSession, + part: Part, + edit_open: bool = False, + variant_pn: str | None = None, ) -> HTMLResponse: from opal.config import get_active_project - part = db.query(Part).filter(Part.id == part_id, Part.deleted_at.is_(None)).first() - if not part: - return templates.TemplateResponse( - "errors/404.html", - {"request": request, "message": f"Part {part_id} not found"}, - status_code=404, - ) - # The PN is the page's name; the DB row id appears nowhere context = get_base_context(request, db, f"{part.internal_pn or part.name} - OPAL") context["part"] = part @@ -899,7 +894,7 @@ def _parts_detail_response( ) # Get inventory records - inventory_records = db.query(InventoryRecord).filter(InventoryRecord.part_id == part_id).all() + inventory_records = db.query(InventoryRecord).filter(InventoryRecord.part_id == part.id).all() context["inventory_records"] = inventory_records # Calculate total quantity for display @@ -956,6 +951,40 @@ def _parts_detail_response( where_used = db.query(BOMLine).filter(BOMLine.component_id == part.id).all() context["where_used"] = where_used + # Variants: live siblings sharing this PN's tier+sequence base, derived + # from the PN itself — no stored relation. Legacy bases (numbered before + # {variant} entered the format) anchor their family as implicit variant 1. + from opal.core.numbering import format_has_variant, variant_family_key + + can_variant = False + variant_rows: list[Part] = [] + if format_has_variant(project) and part.internal_pn and tier_config: + key = variant_family_key(project, part.tier, part.internal_pn) + if key: + can_variant = True + sequence = key[0] + siblings = ( + db.query(Part) + .filter( + Part.deleted_at.is_(None), + Part.id != part.id, + Part.tier == part.tier, + Part.internal_pn.isnot(None), + ) + .all() + ) + variant_rows = sorted( + ( + sib + for sib in siblings + if (k := variant_family_key(project, part.tier, sib.internal_pn)) + and k[0] == sequence + ), + key=lambda p: p.internal_pn, + ) + context["can_variant"] = can_variant + context["variant_rows"] = variant_rows + # Test templates from opal.db.models.inventory import TestTemplate @@ -1017,19 +1046,93 @@ def _parts_detail_response( context["categories"] = sorted(category_set) context["form_open"] = edit_open + if variant_pn is not None: + # The shared form renders in variant mode: next code as a locked fact + context["form_variant"] = True + context["variant_pn"] = variant_pn + context["variant_pn_segments"] = _pn_segments( + variant_pn, tier_config.code if tier_config else str(part.tier) + ) + context["form_open"] = True + return templates.TemplateResponse("parts/detail.html", context) -@router.get("/parts/{part_id}", response_class=HTMLResponse) -def parts_detail(request: Request, db: DbSession, part_id: int) -> HTMLResponse: - """Part detail page; hosts the edit overlay.""" - return _parts_detail_response(request, db, part_id) +def _resolve_part_ref(db: DbSession, ref: str) -> Part | None: + """Resolve /parts/{ref}: exact PN match first, then numeric id. + + PN-first means a purely numeric part number wins over an id collision. + """ + part = db.query(Part).filter(Part.internal_pn == ref, Part.deleted_at.is_(None)).first() + if part is None and ref.isdigit(): + part = db.query(Part).filter(Part.id == int(ref), Part.deleted_at.is_(None)).first() + return part + + +# PNs the literal sibling routes would shadow; those parts stay id-addressed +_PN_SHADOWED_BY_ROUTES = {"new", "table", "search", "import"} + +def _canonical_part_redirect(part: Part, ref: str, suffix: str = "") -> RedirectResponse | None: + """302 an id-addressed request to the canonical PN URL. -@router.get("/parts/{part_id}/edit", response_class=HTMLResponse) -def parts_edit(request: Request, db: DbSession, part_id: int) -> HTMLResponse: + No redirect when the part has no PN, when the PN can't live in a single + path segment ('/'), or when a literal sibling route shadows it. + """ + pn = part.internal_pn + if not pn or pn == ref or "/" in pn or pn in _PN_SHADOWED_BY_ROUTES: + return None + return RedirectResponse(url=f"/parts/{quote(pn, safe='')}{suffix}", status_code=302) + + +def _parts_404(request: Request, ref: str) -> HTMLResponse: + return templates.TemplateResponse( + "errors/404.html", + {"request": request, "message": f"Part {ref} not found"}, + status_code=404, + ) + + +@router.get("/parts/{part_ref}", response_class=HTMLResponse, response_model=None) +def parts_detail(request: Request, db: DbSession, part_ref: str) -> HTMLResponse | RedirectResponse: + """Part detail page; hosts the edit overlay. The PN is the canonical URL.""" + part = _resolve_part_ref(db, part_ref) + if not part: + return _parts_404(request, part_ref) + if redirect := _canonical_part_redirect(part, part_ref): + return redirect + return _parts_detail_response(request, db, part) + + +@router.get("/parts/{part_ref}/edit", response_class=HTMLResponse, response_model=None) +def parts_edit(request: Request, db: DbSession, part_ref: str) -> HTMLResponse | RedirectResponse: """Deep link: the part page with the edit overlay open.""" - return _parts_detail_response(request, db, part_id, edit_open=True) + part = _resolve_part_ref(db, part_ref) + if not part: + return _parts_404(request, part_ref) + if redirect := _canonical_part_redirect(part, part_ref, "/edit"): + return redirect + return _parts_detail_response(request, db, part, edit_open=True) + + +@router.get("/parts/{part_ref}/variant", response_class=HTMLResponse, response_model=None) +def parts_new_variant( + request: Request, db: DbSession, part_ref: str +) -> HTMLResponse | RedirectResponse: + """Deep link: the part page with the variant form open (next code minted on save).""" + from opal.core.numbering import PartNumberError, next_variant_part_number + + part = _resolve_part_ref(db, part_ref) + if not part: + return _parts_404(request, part_ref) + if redirect := _canonical_part_redirect(part, part_ref, "/variant"): + return redirect + try: + variant_pn = next_variant_part_number(db, part.tier, part.internal_pn or "") + except PartNumberError: + # Format mints no variants (or the PN predates it): bounce to the page + return RedirectResponse(url=f"/parts/{quote(part_ref, safe='')}", status_code=302) + return _parts_detail_response(request, db, part, variant_pn=variant_pn) # ============ INVENTORY ============ diff --git a/src/opal/web/templates/parts/_ledger_is.html b/src/opal/web/templates/parts/_ledger_is.html index d361b84..424985e 100644 --- a/src/opal/web/templates/parts/_ledger_is.html +++ b/src/opal/web/templates/parts/_ledger_is.html @@ -26,6 +26,27 @@ {% endif %} +{# --- Variants: live siblings derived from the PN base; rendered only when + the numbering format mints variant codes --- #} +{% if can_variant %} +
+ VAR + {{ variant_rows | length if variant_rows else '—' }} + [+] +
+{% if variant_rows %} +
+ {% for v in variant_rows %} +
+ {{ v.internal_pn }} + {{ v.name }} + {{ v.lifecycle_state | upper }} +
+ {% endfor %} +
+{% endif %} +{% endif %} + {# --- Requirements: counts carry state inline, tersely --- #} {% set req_rows = part_requirements | selectattr('req') | list %} {% set tbd_count = req_rows | selectattr('req.tbd') | list | length %} diff --git a/src/opal/web/templates/parts/_part_form.html b/src/opal/web/templates/parts/_part_form.html index d6b372a..15c0c16 100644 --- a/src/opal/web/templates/parts/_part_form.html +++ b/src/opal/web/templates/parts/_part_form.html @@ -1,23 +1,36 @@ -{# The one part form — create and edit share it (title + submit differ). - Rendered as an overlay on the page the user is already on: no - intermediate menus. Deep links (/parts/new, /parts/{id}/edit) render - the underlying page with this overlay open. +{# The one part form — create, edit, and variant share it (title + submit + differ). Rendered as an overlay on the page the user is already on: no + intermediate menus. Deep links (/parts/new, /parts/{ref}/edit, + /parts/{ref}/variant) render the underlying page with this overlay open. Context: form_part (Part | undefined -> create mode), tiers, categories, form_prefill (dict, create mode), form_open (bool), - tier_name/pn_segments (edit mode, for the tag). #} + tier_name/pn_segments (edit mode, for the tag). Variant mode: + form_variant (bool) + variant_pn/variant_pn_segments — form_part is the + SOURCE part; identity (PN, tier) is a locked fact, the rest prefills. #} {% import 'opalkit/_macros.html' as ok %} {% from 'parts/_tag.html' import part_tag %} -{% set fm_edit = form_part is defined and form_part %} +{% set fm_variant = form_variant is defined and form_variant %} +{% set fm_edit = form_part is defined and form_part and not fm_variant %} {% set fm_draft = fm_edit and form_part.lifecycle_state == 'draft' %} +{% set fm_filled = fm_edit or fm_variant %} {% set form_prefill = form_prefill if form_prefill is defined else {} %}
-
{{ 'EDIT ' ~ form_part.internal_pn if fm_edit else 'NEW PART' }}
+
{{ 'VARIANT ' ~ variant_pn if fm_variant else ('EDIT ' ~ form_part.internal_pn if fm_edit else 'NEW PART') }}
- {% if fm_edit %} + {% if fm_variant %} + {{ part_tag('forming', + pn=variant_pn, + name=form_part.name, + tier_level=form_part.tier, + tier_name=tier_name, + uom=form_part.unit_of_measure, + tracking=form_part.tracking_type.value if form_part.tracking_type else '', + pn_segments=variant_pn_segments) }} + {% elif fm_edit %} {{ part_tag('header', pn=form_part.internal_pn, name=form_part.name, @@ -34,7 +47,22 @@ {% endif %}
- {% if not fm_edit or fm_draft %} + {% if fm_variant %} + {# Identity is derived from the source — PN and tier live in the tag only #} +
+
+ + +
+
+ + +
+
+ {% elif not fm_edit or fm_draft %} {# Identity rows — absent (not disabled) once active #}
@@ -89,7 +117,7 @@
+ value="{{ form_part.category or '' if fm_filled else (form_prefill.get('category') or '') }}"> {% for cat in categories %} @@ -97,32 +125,32 @@
+ value="{{ form_part.unit_of_measure or 'EA' if fm_filled else 'EA' }}">
+ value="{{ '%g' % form_part.reorder_point if fm_filled and form_part.reorder_point is not none else '' }}">
+ value="{{ form_part.calibration_interval_days or '' if fm_filled else '' }}">
@@ -131,12 +159,12 @@
+ value="{{ form_part.parent_id or '' if fm_filled else (form_prefill.get('parent_id') or '') }}">
- +
- +
{{ ok.btn("CANCEL", attrs='onclick="closePartForm()"') }} - {{ ok.btn("SAVE" if fm_edit else "CREATE DRAFT", variant="primary", type="submit") }} + {{ ok.btn("SAVE" if fm_edit else ("CREATE VARIANT" if fm_variant else "CREATE DRAFT"), variant="primary", type="submit") }}
@@ -166,15 +194,16 @@