feat(library): pattern number optional, style number mandatory

The garment's identity is its style number (QR scan URL, hangtag, every TNA
join key). Pattern number is a pattern-master reference that routinely lands
after the sample, so it must not gate an approval.

- contract: MANDATORY_FIELDS = (style_number, fabric_quality, counters) in
  services/qr_fields.py, mirrored by frontend/src/lib/qrFields.ts; the
  QR_MANDATORY_FIELDS / missing_qr_fields names stay as aliases so the gates,
  the QR endpoint and the CI test list keep working.
- approval gates + POST /api/plm/{id}/qr now require the style number; it is
  listed exactly once (no double-listing in the refusal payload).
- closed the identity hole in PUT /api/garments/{id}: blank, whitespace-only
  and explicit JSON null are all 422 STYLE_NUMBER_REQUIRED and the row is left
  untouched. The edit form used to send null when the field was emptied, which
  silently erased the number.
- form: Style number carries the "· required" marker and refuses to save;
  Pattern no. loses its "· QR" marker and is marked optional.
- capture drafts are safe: garment_capture_save_service reserves a style number
  at save time, so no draft is blocked by the new rule.
- tests: inverted the null-clears-the-number case, added pattern-less-approval
  and per-field blocking coverage.

Existing style-less rows are deliberately left un-approvable until someone
fills the number in (no backfill, no data writes).
This commit is contained in:
2026-09-28 12:24:13 +05:30
parent 4b94122e2a
commit 7eae9482c6
15 changed files with 414 additions and 189 deletions
+32 -19
View File
@@ -123,9 +123,9 @@ async def upload_garment(
is_co_ord_set: bool = Form(False),
segment: str = Form("new_dev"), # new_dev | shopping — drives auto style-number series
category: str | None = Form(None), # mens | womens | kids
fabric_quality: str | None = Form(None), # QR-mandatory (buyer-visible quality)
pattern_number: str | None = Form(None), # QR-mandatory
counters: int | None = Form(None), # QR-mandatory (physical pieces made)
fabric_quality: str | None = Form(None), # mandatory (buyer-visible quality)
pattern_number: str | None = Form(None), # OPTIONAL — recorded when the pattern master exists
counters: int | None = Form(None), # mandatory (physical pieces made)
remove_bg: bool = Form(False), # when True, run background removal on each saved image
capture_mode: str = Form("gallery"), # "gallery" | "camera" — how the shot was taken
db: Session = Depends(get_db),
@@ -325,7 +325,9 @@ async def upload_garment(
)
# Freeze + store the QR right away if the style is QR-ready (style no. auto-assigned +
# the mandatory fields present) — so every uploaded style carries a scannable QR in the DB.
if style_number and fabric_quality and pattern_number and counters is not None:
# Keyed on the MANDATORY list (style number, fabric quality, counters): pattern
# number is optional since 2026-09-28, so it must not gate the frozen QR.
if style_number and fabric_quality and counters is not None:
garment.qr_payload = plm.build_qr_payload(style_number)
garment.qr_generated_at = plm.now_utc()
db.add(garment)
@@ -833,8 +835,8 @@ def confirm_draft_garment(
the confirmation itself is unchanged, and so is every status code.
Issue #893: confirming a draft is the same act as approving it in the review
queue, so it is gated on the same QR-mandatory trio (fabric quality, pattern
number, counters) and refuses with the same 422 payload. Without this, the
queue, so it is gated on the same mandatory fields (style number, fabric
quality, counters) and refuses with the same 422 payload. Without this, the
rule was optional in practice for anything calling the API directly.
"""
g = db.query(Garment).filter(Garment.id == garment_id,
@@ -999,25 +1001,36 @@ def update_garment(garment_id: str, body: dict, db: Session = Depends(get_db),
allowed = {
"style_number", "fabric_composition", "season", "design_team_city",
"collection_name", "description", "status", "is_co_ord_set", "linked_piece_ids",
# PLM / metadata fields (segment drives auto-numbering series; quality/
# pattern/counters are QR-mandatory) — editable post-upload so a wrong
# value doesn't force a re-upload.
# PLM / metadata fields (segment drives auto-numbering series; quality and
# counters are mandatory, pattern number is optional) — editable post-upload
# so a wrong value doesn't force a re-upload.
"segment", "fabric_quality", "pattern_number", "counters",
}
# Style-number guard (#641) — must run BEFORE the assignment loop: the session
# autoflushes a pending UPDATE ahead of the next query, so assigning a clashing
# value first would surface the DB's UniqueViolation as an unhandled 500.
# Same 409 shape as the upload guard above. The DB unique index covers every
# row (soft-deleted included), so the lookup must not filter is_deleted.
# An explicit JSON null is a legitimate "clear the number" call (62 rows in
# the dev DB carry NULL) — it skips the guard and keeps the old behaviour.
if body.get("style_number") is not None:
submitted = str(body["style_number"]).strip()
# Style-number guard (#641, tightened 2026-09-28) — must run BEFORE the
# assignment loop: the session autoflushes a pending UPDATE ahead of the next
# query, so assigning a clashing value first would surface the DB's
# UniqueViolation as an unhandled 500. Same 409 shape as the upload guard
# above. The DB unique index covers every row (soft-deleted included), so the
# lookup must not filter is_deleted.
#
# The style number is MANDATORY. This guard used to treat an explicit JSON
# null as a legitimate "clear the number", and the garment edit form sends
# exactly that when the field is emptied — which is how a style lost its
# identity (and with it its tag, its scan and every TNA join). A null, a
# blank or a whitespace-only value is now a 422 like any other empty string.
# Rows that ALREADY carry NULL are left alone: no backfill here, they simply
# read as "Style number still empty" in the readiness gate until someone
# fills the number in the garment form.
if "style_number" in body:
submitted = str(body["style_number"] or "").strip()
if not submitted:
raise HTTPException(
status_code=422,
detail={"error": "Style number cannot be empty", "code": "INVALID_STYLE_NUMBER"},
detail={
"error": "Style number is required — it is the garment's identity (tag, scan, TNA).",
"code": "STYLE_NUMBER_REQUIRED",
},
)
body["style_number"] = submitted # the assignment loop stores the trimmed form
clash = (
+8 -9
View File
@@ -74,17 +74,16 @@ def next_style_number(
def _require(g: Garment) -> list[str]:
"""Mandatory fields before a QR can be issued (style number is auto).
"""Mandatory fields before a QR can be issued.
The QR-mandatory trio itself comes from ``plm_service.QR_MANDATORY_FIELDS`` —
the SAME list the Review-Queue approval gate reads, so "QR-ready" means one
thing everywhere (a garment the gate lets through is one this endpoint will
happily tag, and vice versa).
The list itself comes from ``plm_service.MANDATORY_FIELDS`` — the SAME list
the Review-Queue approval gate reads, so "QR-ready" means one thing everywhere
(a garment the gate lets through is one this endpoint will happily tag, and
vice versa). Style number is IN that list (2026-09-28): it is the garment's
identity, not an auto detail, so it is no longer added here as a special case
— doing that would have listed it twice.
"""
missing = plm.missing_qr_fields(g)
if not g.style_number:
missing.insert(0, "style_number")
return missing
return plm.missing_qr_fields(g)
@router.post("/{garment_id}/qr", responses={404: {"description": "Not found"}, 422: {"description": "Unprocessable Entity"}})
+5 -2
View File
@@ -3,7 +3,7 @@
Numbering and QR-tag rules come from the design-team meeting (Notes/Features.md):
- Auto style number per category (mens/womens/kids), separate series for Shopping vs new dev.
- Colourways of one style = same base code + sub-letter A/B/C/D.
- QR is generated after mandatory fields (quality, pattern number, counters) and is a FROZEN
- QR is generated after the mandatory fields (style number, quality, counters) and is a FROZEN
unique identifier — re-issuing makes a NEW QR, never edits the old one.
- The physical hangtag shows only Style Number + Fabric Quality; the rest lives behind the scan.
@@ -25,8 +25,11 @@ from models.garment import Garment, StyleCounter
# Re-exported so QR callers keep reading the contract through this module; the
# definitions themselves are dependency-free (services/qr_fields.py).
from services.qr_fields import ( # noqa: F401
MANDATORY_FIELD_LABELS,
MANDATORY_FIELDS,
QR_FIELD_LABELS,
QR_MANDATORY_FIELDS,
missing_mandatory_fields,
missing_qr_fields,
qr_field_label,
)
@@ -37,7 +40,7 @@ SHOPPING_PREFIX = "S" # prepended for shopping samples → e.g. SM0001
PAD = 4 # zero-padding width → 0001
COLOURWAY_CODES = ["A", "B", "C", "D"]
# ── QR-mandatory garment fields ────────────────────────────────────────────────
# ── Mandatory garment fields ───────────────────────────────────────────────────
# The list + "what counts as filled" live in `services/qr_fields.py` — a module
# with no third-party imports, so the Review-Queue approval gate and the offline
# CI tests can read the SAME contract without pulling qrcode/PIL in. Imported
+43 -22
View File
@@ -1,47 +1,62 @@
"""The QR-mandatory garment fields — the ONE list both gates read.
"""The mandatory garment fields — the ONE list both gates read.
Deliberately dependency-free (pure Python, no qrcode/PIL/SQLAlchemy): this is the
contract shared by the QR-issuing endpoint, the Review-Queue approval gate and
the offline CI tests, so it must be importable in the leanest environment. The
rendering/generation side stays in `services/plm_service.py`.
Why the fields matter (design-team rule, Notes/Features.md): the QR is the
garment's identity — fabric quality, pattern number and counters are what the
printed hangtag shows and what a phone scan resolves to. A garment that reaches
the library without them can never be tagged, counted, or joined upstream.
Why these fields (design-team rule, Notes/Features.md): the QR is the garment's
identity. The **style number** IS that identity — the scan URL, the hangtag and
every TNA/order join key off it — and **fabric quality + counters** are what the
printed tag shows and what a phone scan resolves to. A garment that reaches the
library without them can never be tagged, counted, or joined upstream.
`routers/plm.py:_require()` and `services/review_service._approve_draft()` both
call `missing_qr_fields()`, so "QR-ready" cannot mean two different things: a
garment the approval gate lets through is one the tag endpoint will happily tag.
**Pattern number is NOT mandatory (2026-09-28, design team).** It is recorded
when it exists and stays editable everywhere, but the physical sample routinely
reaches the studio before the pattern master is keyed, and blocking the approval
on it stalled garments that were otherwise complete. It is no longer part of
``QR_MANDATORY_FIELDS``.
`routers/plm.py:_require()`, `services/review_service._approve_draft()` and
`routers/garments.confirm_draft_garment()` all call `missing_qr_fields()`, so
"QR-ready" cannot mean two different things: a garment the approval gate lets
through is one the tag endpoint will happily tag.
"""
QR_MANDATORY_FIELDS = ("fabric_quality", "pattern_number", "counters")
MANDATORY_FIELDS = ("style_number", "fabric_quality", "counters")
QR_FIELD_LABELS = {
# Deprecated alias — every caller that predates the rename keeps working.
QR_MANDATORY_FIELDS = MANDATORY_FIELDS
MANDATORY_FIELD_LABELS = {
"style_number": "Style number",
"fabric_quality": "Fabric quality",
"pattern_number": "Pattern no.",
"counters": "Counters (pieces)",
}
# Deprecated alias.
QR_FIELD_LABELS = MANDATORY_FIELD_LABELS
def qr_field_label(field: str) -> str:
"""Human label for a QR field ('pattern_number' → 'Pattern no.')."""
return QR_FIELD_LABELS.get(field, field.replace("_", " "))
"""Human label for a mandatory field ('style_number' → 'Style number')."""
return MANDATORY_FIELD_LABELS.get(field, field.replace("_", " "))
def missing_qr_fields(garment) -> list[str]:
"""QR-mandatory fields still empty on ``garment`` (empty list ⇒ QR-ready).
"""Mandatory fields still empty on ``garment`` (empty list ⇒ QR-ready).
``counters`` is an Integer column: 0 is a real answer ("none made yet"), so
only NULL counts as missing. On the two text columns a blank/whitespace-only
string is missing too — the same test the QR endpoint has always applied, so
a garment that was refused a QR cannot slip through the approval gate.
only NULL counts as missing. On the text columns (style number, fabric
quality) a blank/whitespace-only string is missing too — the same test the
QR endpoint has always applied, so a garment that was refused a QR cannot
slip through the approval gate.
An attribute that cannot be read counts as MISSING (never as ready): neither
gate may approve on a guess.
"""
missing: list[str] = []
for field in QR_MANDATORY_FIELDS:
for field in MANDATORY_FIELDS:
value = getattr(garment, field, None)
if value is None:
missing.append(field)
@@ -50,6 +65,11 @@ def missing_qr_fields(garment) -> list[str]:
return missing
# Same function under the name that matches the constant (new call sites should
# read this one; the historical name is kept because 4 modules import it).
missing_mandatory_fields = missing_qr_fields
# ---------------------------------------------------------------------------
# The refusal contract (#893)
#
@@ -59,15 +79,16 @@ def missing_qr_fields(garment) -> list[str]:
# minimal environment may not carry.
# ---------------------------------------------------------------------------
CODE_QR_FIELDS_MISSING = "QR_FIELDS_MISSING"
ERR_QR_FIELDS_MISSING = "This garment can't be approved yet — QR fields are missing"
ERR_QR_FIELDS_MISSING = "This garment can't be approved yet — mandatory fields are missing"
QR_REFUSAL_MESSAGE = (
"Fill the QR fields — fabric quality, pattern number and counters — then "
"{verb}. They are what the printed tag and the phone scan resolve to."
"Fill the mandatory fields — style number, fabric quality and counters — then "
"{verb}. They are the garment's identity: what the printed tag, the phone scan "
"and every TNA join resolve to."
)
def qr_refusal_detail(garment, verb: str = "approve") -> dict:
"""The 422 payload for a garment whose QR-mandatory trio is incomplete.
"""The 422 payload for a garment whose mandatory fields are incomplete.
Shape-compatible with the review approve refusal on purpose: the same UI
handling (naming and highlighting the fields) then works whichever door the
+3 -3
View File
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
ERR_REVIEW_NOT_FOUND = "Review item not found"
# A capture draft cannot be APPROVED (confirmed into the library) while any
# QR-mandatory garment field is still empty. The same trio the QR endpoint
# mandatory garment field is still empty. The same list the QR endpoint
# checks before it will issue a tag (services/plm_service.QR_MANDATORY_FIELDS):
# approving past it is how a garment reaches the library that can never be
# tagged/scanned — the identity the TNA + production counts hang off. The
@@ -303,8 +303,8 @@ class ReviewService:
Idempotent: an already-confirmed garment returns the same 200 shape as
``confirm-draft`` (``already_confirmed: True``) rather than 404.
Gated on the QR-mandatory garment fields: a draft whose fabric quality /
pattern number / counters are still empty is REFUSED with
Gated on the mandatory garment fields: a draft whose style number /
fabric quality / counters are still empty is REFUSED with
``code=QR_FIELDS_MISSING`` + ``missing[]`` (the router turns it into a
422) instead of being confirmed into the library, where it could never be
issued a QR tag. The idempotent branch above is deliberately NOT gated —
@@ -12,8 +12,10 @@ What is asserted here:
(the guard must run before any assignment — the session autoflushes a
pending UPDATE ahead of the next query, which is what produced the 500);
2. re-submitting the garment's OWN style number still saves;
3. an empty style number is a 422 INVALID_STYLE_NUMBER, while an explicit
JSON null clears the number (200, no clash check);
3. an empty style number — blank, whitespace-only or an explicit JSON null —
is a 422 STYLE_NUMBER_REQUIRED (2026-09-28: the number is mandatory, so the
old "null clears the number" path is gone; the row is left unchanged and no
clash check runs);
4. the lookup covers soft-deleted rows too (the DB unique index does);
5. an IntegrityError at commit (the two-reviewers race) is a 409, not a 500,
and the session stays usable;
@@ -203,10 +205,13 @@ def test_style_number_is_stored_trimmed(make_client, db):
# ===========================================================================
# 3. empty style number → 422, row untouched
# 3. empty style number → 422, row untouched (blank / whitespace / JSON null)
# ===========================================================================
@pytest.mark.parametrize("value", ["", " ", "\t"])
@pytest.mark.parametrize("value", ["", " ", "\t", None])
def test_empty_style_number_returns_422(make_client, db, value):
"""The style number is MANDATORY (2026-09-28). Blank, whitespace-only and an
explicit JSON null are the same refusal — the null case used to be a silent
'clear the number' 200, which is how a garment lost its identity."""
_garment(db, "g-a", "SA A0001")
r = make_client(_admin()).put("/api/garments/g-a",
@@ -214,8 +219,8 @@ def test_empty_style_number_returns_422(make_client, db, value):
assert r.status_code == 422, r.text
detail = r.json()["detail"]
assert detail["code"] == "INVALID_STYLE_NUMBER"
assert detail["error"] == "Style number cannot be empty"
assert detail["code"] == "STYLE_NUMBER_REQUIRED"
assert "Style number is required" in detail["error"]
row = _reload(db, "g-a")
assert row.style_number == "SA A0001"
assert row.description is None
@@ -329,23 +334,28 @@ def test_reservation_leaves_a_free_first_number_alone(db):
assert plm_service.peek_next_style_number(db, "shopping", "mens") == "SM0001"
def test_explicit_null_clears_the_style_number(make_client, db):
"""JSON null is a legitimate "clear the number" call (62 rows in the dev DB
carry NULL, 0 carry an empty string): 200, column NULL, no clash check."""
def test_explicit_null_no_longer_clears_the_style_number(make_client, db):
"""INVERTED 2026-09-28 (was test_explicit_null_clears_the_style_number).
The garment edit form cleared the number by sending JSON null — a real path a
user could hit by emptying the field. The number is mandatory now, so the same
call is a 422 and the row keeps its identity; the number is NOT freed."""
_garment(db, "g-a", "SA A0001")
_garment(db, "g-b", "SA B0002")
r = make_client(_admin()).put("/api/garments/g-a",
json={"style_number": None, "description": "number cleared"})
assert r.status_code == 200, r.text
assert r.status_code == 422, r.text
assert r.json()["detail"]["code"] == "STYLE_NUMBER_REQUIRED"
row = _reload(db, "g-a")
assert row.style_number is None
assert row.description == "number cleared"
# The other garment is untouched and the number is free to be reused.
assert row.style_number == "SA A0001" # identity kept
assert row.description is None # nothing else was written either
# The number was never freed, so it still clashes like any other row's.
assert _reload(db, "g-b").style_number == "SA B0002"
r2 = make_client(_admin()).put("/api/garments/g-b", json={"style_number": "SA A0001"})
assert r2.status_code == 200, r2.text
assert r2.status_code == 409, r2.text
assert r2.json()["detail"]["code"] == "STYLE_NUMBER_EXISTS"
def test_patch_without_style_number_is_not_a_conflict(make_client, db):
+91 -40
View File
@@ -1,14 +1,22 @@
"""The QR-mandatory field contract — dependency-light on purpose.
"""The mandatory-field contract — dependency-light on purpose.
This is the file CI can actually RUN (the curated offline job installs sqlalchemy
+ pillow and, in the router step, qrcode — and `services/__init__.py` is empty,
so importing `services.plm_service` pulls none of the heavy runtime).
What it locks down is the thing that must never drift: the SAME three fields
gate BOTH a QR being issued AND a capture draft being approved out of the Review
Queue. `routers/plm.py:_require` and `services/review_service._approve_draft`
both read `missing_qr_fields()`, so a change to the list, to what counts as
"filled", or to a caller switching to its own copy shows up here.
What it locks down is the thing that must never drift: the SAME fields gate BOTH
a QR being issued AND a capture draft being approved out of the Review Queue.
`routers/plm.py:_require`, `services/review_service._approve_draft` and
`routers/garments.confirm_draft_garment` all read `missing_qr_fields()`, so a
change to the list, to what counts as "filled", or to a caller switching to its
own copy shows up here.
2026-09-28 rule change (design team): **style number is mandatory, pattern number
is NOT.** The style number is the garment's identity (scan URL, hangtag, every TNA
join); the pattern master is routinely keyed after the physical sample arrives, so
blocking an approval on it stalled otherwise-complete garments. The assertions
below are the regression guard for exactly that: pattern number must never come
back as a reason to refuse.
The end-to-end gate behaviour (the 422, the queue payload, the per-field rows)
lives in tests/test_review_qr_approval_gate.py, which needs fastapi + chromadb.
@@ -26,66 +34,95 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import pytest
from services.qr_fields import (
QR_FIELD_LABELS,
MANDATORY_FIELDS,
MANDATORY_FIELD_LABELS,
QR_MANDATORY_FIELDS,
QR_FIELD_LABELS,
missing_qr_fields,
qr_field_label,
)
class _G:
"""A stand-in garment: `missing_qr_fields` only reads these three attrs."""
"""A stand-in garment: `missing_qr_fields` only reads these attrs."""
def __init__(self, fabric_quality=None, pattern_number=None, counters=None):
def __init__(self, style_number: str | None = "SA W0001", fabric_quality=None,
pattern_number=None, counters=None):
self.style_number = style_number
self.fabric_quality = fabric_quality
self.pattern_number = pattern_number
self.counters = counters
def test_the_trio_is_exactly_fabric_quality_pattern_number_counters():
assert QR_MANDATORY_FIELDS == ("fabric_quality", "pattern_number", "counters")
def test_the_list_is_exactly_style_number_fabric_quality_counters():
assert MANDATORY_FIELDS == ("style_number", "fabric_quality", "counters")
def test_pattern_number_is_not_mandatory():
"""The 2026-09-28 rule: an empty pattern number is NOT a reason to refuse a
garment a QR or an approval — the pattern master is keyed later than the
sample arrives."""
assert "pattern_number" not in MANDATORY_FIELDS
assert missing_qr_fields(_G(style_number="W1", fabric_quality="Poplin", counters=4)) == []
# …and it is never named in a refusal, even when every field around it is filled.
assert "pattern_number" not in MANDATORY_FIELD_LABELS
def test_style_number_replaced_pattern_number_in_the_alias():
"""`QR_MANDATORY_FIELDS` predates the rename and must stay identical."""
assert QR_MANDATORY_FIELDS == MANDATORY_FIELDS
assert QR_FIELD_LABELS is MANDATORY_FIELD_LABELS
def test_every_field_has_a_human_label():
for field in QR_MANDATORY_FIELDS:
assert QR_FIELD_LABELS[field].strip()
assert qr_field_label(field) == QR_FIELD_LABELS[field]
for field in MANDATORY_FIELDS:
assert MANDATORY_FIELD_LABELS[field].strip()
assert qr_field_label(field) == MANDATORY_FIELD_LABELS[field]
assert qr_field_label("style_number") == "Style number"
# An unknown field degrades to a readable label rather than a KeyError.
assert qr_field_label("some_new_field") == "some new field"
def test_all_empty_reports_all_three():
assert missing_qr_fields(_G()) == list(QR_MANDATORY_FIELDS)
assert missing_qr_fields(_G(style_number=None)) == list(MANDATORY_FIELDS)
@pytest.mark.parametrize("field", list(QR_MANDATORY_FIELDS))
@pytest.mark.parametrize("field", list(MANDATORY_FIELDS))
def test_any_single_empty_field_is_reported(field):
"""Two of three is not QR-ready — the gate is all-or-nothing."""
filled = {"fabric_quality": "Cotton Poplin 120gsm", "pattern_number": "PT-4471",
"""Two of three is not ready — the gate is all-or-nothing."""
filled = {"style_number": "SA W0301", "fabric_quality": "Cotton Poplin 120gsm",
"counters": 6}
filled[field] = None
assert missing_qr_fields(_G(**filled)) == [field]
def test_complete_garment_is_qr_ready():
assert missing_qr_fields(_G("Cotton Poplin 120gsm", "PT-4471", 6)) == []
assert missing_qr_fields(_G("SA W0301", "Cotton Poplin 120gsm", "PT-4471", 6)) == []
def test_counters_zero_is_a_real_answer():
"""0 pieces is filled ("none made yet"); only NULL is missing."""
assert missing_qr_fields(_G("Denim 12oz", "PT-1", 0)) == []
assert missing_qr_fields(_G("SA W0301", "Denim 12oz", "PT-1", 0)) == []
def test_blank_and_whitespace_text_is_missing():
"""The QR endpoint has always refused a blank quality; the approval gate must
agree, or a garment it lets through is one the tag endpoint still rejects."""
assert missing_qr_fields(_G(" ", "PT-1", 6)) == ["fabric_quality"]
assert missing_qr_fields(_G("Denim 12oz", "", 6)) == ["pattern_number"]
agree, or a garment it lets through is one the tag endpoint still rejects.
The style number is held to the same test."""
assert missing_qr_fields(_G(" ", "Cotton Poplin", "PT-1", 6)) == ["style_number"]
assert missing_qr_fields(_G("SA W0301", " ", "PT-1", 6)) == ["fabric_quality"]
def test_a_style_less_garment_is_never_qr_ready():
"""A NULL style number reads as missing — this is what stops the garment form
from claiming "QR-ready" for a style it could never print a tag for."""
assert missing_qr_fields(_G(None, "Cotton Poplin", "PT-1", 6)) == ["style_number"]
def test_partial_object_with_the_fields_reads_correctly():
"""A light object that carries the trio (a projection) is judged on its values."""
"""A light object that carries the list (a projection) is judged on its values."""
class _Partial:
style_number = "SA W0301"
fabric_quality = "Denim 12oz"
pattern_number = "PT-1"
counters = 3
@@ -94,13 +131,14 @@ def test_partial_object_with_the_fields_reads_correctly():
def test_object_without_the_attributes_is_treated_as_missing():
"""Deliberate conservative default: a garment whose QR fields cannot be read is
reported as MISSING, never as ready — the gate must not approve on a guess.
(Every caller passes a real Garment row, so this is a guard rail, not a path.)"""
"""Deliberate conservative default: a garment whose mandatory fields cannot be
read is reported as MISSING, never as ready — the gate must not approve on a
guess. (Every caller passes a real Garment row, so this is a guard rail, not a
path.)"""
class _Bare:
style_number = "SA W0001"
pass
assert missing_qr_fields(_Bare()) == list(QR_MANDATORY_FIELDS)
assert missing_qr_fields(_Bare()) == list(MANDATORY_FIELDS)
# ---------------------------------------------------------------------------
@@ -114,9 +152,9 @@ def test_qr_refusal_detail_names_every_missing_field():
qr_refusal_detail,
)
class _G: # one of the trio filled, one blank, one null
class _G: # style number blank, quality filled, counters null, pattern empty
id = "g-1"
style_number = "SA W0302"
style_number = " "
fabric_quality = "Cotton Poplin 120gsm"
pattern_number = ""
counters = None
@@ -124,30 +162,43 @@ def test_qr_refusal_detail_names_every_missing_field():
detail = qr_refusal_detail(_G(), verb="confirm")
assert detail["code"] == CODE_QR_FIELDS_MISSING
assert detail["error"] == ERR_QR_FIELDS_MISSING
assert detail["missing"] == ["pattern_number", "counters"]
assert detail["missing_labels"] == ["Pattern no.", "Counters (pieces)"]
assert detail["garment_id"] == "g-1" and detail["style_number"] == "SA W0302"
assert detail["missing"] == ["style_number", "counters"]
assert detail["missing_labels"] == ["Style number", "Counters (pieces)"]
assert detail["garment_id"] == "g-1" and detail["style_number"] == " "
assert "confirm" in detail["message"]
def test_a_blank_pattern_number_is_never_a_refusal_reason():
from services.qr_fields import qr_refusal_detail
class _G:
id, style_number = "g-3", "SA W0304"
fabric_quality = "Cotton Poplin 120gsm"
pattern_number = None
counters = 2
assert qr_refusal_detail(_G())["missing"] == []
def test_qr_refusal_detail_defaults_to_the_approve_wording():
"""The verb only changes for the door that is not the review queue."""
from services.qr_fields import qr_refusal_detail
class _G:
id, style_number = "g-2", "SA W0303"
id, style_number = "g-2", None
fabric_quality = pattern_number = ""
counters = None
assert "then approve." in qr_refusal_detail(_G())["message"]
def test_the_shipped_approve_wording_is_unchanged():
"""Users have already seen this sentence on the review queue; the shared
template must keep producing it byte for byte."""
def test_the_shipped_refusal_wording_names_the_new_fields():
"""Users have seen this sentence on the review queue; it must name the fields
the gate actually reads."""
from services.qr_fields import QR_REFUSAL_MESSAGE
assert QR_REFUSAL_MESSAGE.format(verb="approve") == (
"Fill the QR fields — fabric quality, pattern number and counters — then "
"approve. They are what the printed tag and the phone scan resolve to."
"Fill the mandatory fields — style number, fabric quality and counters — then "
"approve. They are the garment's identity: what the printed tag, the phone scan "
"and every TNA join resolve to."
)
+110 -42
View File
@@ -1,21 +1,26 @@
"""Review-Queue approval gate on the QR-mandatory garment fields.
"""Review-Queue approval gate on the mandatory garment fields.
Shridhar's rule (2026-09-23): a garment must NOT be approvable out of the Review
Queue while any QR-mandatory field is still empty — fabric quality, pattern
number, counters are what the printed hangtag and the phone scan resolve to, so
a garment approved without them can never be tagged upstream of TNA/production.
Shridhar's rule (2026-09-23, updated 2026-09-28): a garment must NOT be approvable
out of the Review Queue while a mandatory field is still empty. The mandatory set
is **style number, fabric quality, counters** — the garment's identity (scan URL,
hangtag, every TNA join) plus what the printed tag shows. **Pattern number is
optional** since 2026-09-28: it is recorded when the pattern master exists, and an
approval must never wait on it (the sample routinely arrives first). That is the
single most important assertion in this file — see
`test_a_pattern_less_draft_now_approves`.
Asserted at the layer that enforces it:
* a capture draft missing ANY of the three is refused with 422
* a capture draft missing ANY mandatory field is refused with 422
``code=QR_FIELDS_MISSING`` + ``missing[]``, and stays ``is_draft=True``;
* ``counters=0`` is a real answer (not missing); a blank/whitespace string is;
* a QR-complete draft still approves, and re-approving stays idempotent (a
garment confirmed before this rule existed must not start 422-ing);
* a draft with NO pattern number approves — and a QR-complete draft still
approves, with re-approving idempotent (a garment confirmed before this rule
existed must not start 422-ing);
* per-field AI-tag approvals are UNAFFECTED — they leave the garment where it
is, so they are not the "garment approval" the gate is about;
* the queue payload carries ``qr_missing`` so the row can warn before the click;
* the gate and the QR endpoint report the SAME missing fields (one shared list
in services/plm_service.py) — a garment the gate lets through is one
in services/qr_fields.py) — a garment the gate lets through is one
``POST /api/plm/{id}/qr`` will happily tag.
Harness mirrors tests/test_capture_jobs_autosave.py (scratch file-backed SQLite +
@@ -40,8 +45,8 @@ import pytest
# deliberately excludes (see requirements-test.txt). Skip the whole module where
# they are absent instead of ERRORing the run (the convention
# tests/test_soft_delete_restore.py uses for its router cases). The
# dependency-light half of the contract — the shared QR field list and what
# counts as filled — lives in tests/test_qr_mandatory_fields.py, which CI DOES run.
# dependency-light half of the contract — the shared field list and what counts
# as filled — lives in tests/test_qr_mandatory_fields.py, which CI DOES run.
pytest.importorskip("fastapi", reason="the gate tests drive the app over TestClient")
pytest.importorskip("chromadb", reason="review_service imports EmbeddingService (chromadb)")
@@ -64,11 +69,13 @@ import models.moodboard # noqa: F401
import models.feed_interaction # noqa: F401
import models.feed_post # noqa: F401
from services.garment_capture_preview_store import GarmentCapturePreview # noqa: F401
from models.garment import Garment, GarmentTag
from models.rbac import Role
from models.garment import Garment, GarmentTag # noqa: F401
from models.rbac import Role # noqa: F401
from models.review_queue import ReviewQueueItem
from models.user import User
from services.plm_service import QR_MANDATORY_FIELDS, missing_qr_fields
from services.plm_service import MANDATORY_FIELDS, QR_MANDATORY_FIELDS, missing_qr_fields
assert MANDATORY_FIELDS == QR_MANDATORY_FIELDS # the deprecated alias is the same list
# ---------------------------------------------------------------------------
@@ -126,14 +133,15 @@ def _user(role: str = "Admin", email: str | None = None, uid: str = "usr-1",
role=role, department_id=department_id, is_active=True, password_hash="x")
# Deliberately WITHOUT a pattern number: the fixtures prove the new rule.
QR_COMPLETE = {"fabric_quality": "Cotton Poplin 120gsm",
"pattern_number": "PT-4471",
"pattern_number": None,
"counters": 12}
def _draft(db, gid: str = "g-qr-draft", style: str = "SA W0900", is_draft: bool = True,
**overrides) -> Garment:
"""A capture draft whose QR fields default to EMPTY (the blocked case)."""
def _draft(db, gid: str = "g-qr-draft", style: str | None = "SA W0900",
is_draft: bool = True, **overrides) -> Garment:
"""A capture draft whose mandatory fields default to EMPTY (the blocked case)."""
cols = {"fabric_quality": None, "pattern_number": None, "counters": None}
cols.update(overrides)
g = Garment(id=gid, style_number=style, category="mens", season="AW26",
@@ -153,19 +161,19 @@ def _approve(client, gid: str):
# ---------------------------------------------------------------------------
# The gate
# ---------------------------------------------------------------------------
def test_qr_incomplete_draft_is_refused_and_stays_draft(make_client, db):
_draft(db)
def test_incomplete_draft_is_refused_and_stays_draft(make_client, db):
_draft(db, style=None) # no style number, no quality, no counters
r = _approve(make_client(_user()), "g-qr-draft")
assert r.status_code == 422, r.text
detail = r.json()["detail"]
assert detail["code"] == "QR_FIELDS_MISSING"
assert detail["missing"] == list(QR_MANDATORY_FIELDS)
assert detail["missing_labels"] == ["Fabric quality", "Pattern no.", "Counters (pieces)"]
assert detail["missing"] == list(MANDATORY_FIELDS)
assert detail["missing_labels"] == ["Style number", "Fabric quality", "Counters (pieces)"]
assert detail["garment_id"] == "g-qr-draft"
assert detail["style_number"] == "SA W0900"
assert detail["style_number"] is None
# The reviewer is given something to show, not a bare status code.
assert "QR fields" in detail["message"]
assert "mandatory fields" in detail["message"]
# Nothing was confirmed — the draft is still a draft and still in the queue.
g = db.query(Garment).filter(Garment.id == "g-qr-draft").first()
@@ -177,12 +185,34 @@ def test_qr_incomplete_draft_is_refused_and_stays_draft(make_client, db):
assert [i["id"] for i in queue["data"]] == ["draft:g-qr-draft"]
@pytest.mark.parametrize("field", list(QR_MANDATORY_FIELDS))
def test_every_qr_field_individually_blocks_approval(make_client, db, field):
def test_a_pattern_less_draft_now_approves(make_client, db):
"""THE 2026-09-28 promise: an empty pattern number does not hold an approval.
Before the change this draft was refused with `missing=["pattern_number"]`;
the pattern master is keyed after the sample arrives, so the garment sat in
the queue unable to move."""
_draft(db, gid="g-no-pattern", style="SA W1100", fabric_quality="Cotton Poplin 120gsm",
pattern_number=None, counters=4)
r = _approve(make_client(_user()), "g-no-pattern")
assert r.status_code == 200, r.text
assert r.json()["already_confirmed"] is False
g = db.query(Garment).filter(Garment.id == "g-no-pattern").first()
db.refresh(g)
assert g.is_draft is False
# …and the pattern number is still simply absent — never invented, never required.
assert g.pattern_number is None
@pytest.mark.parametrize("field", list(MANDATORY_FIELDS))
def test_every_mandatory_field_individually_blocks_approval(make_client, db, field):
"""Filling two of three is not enough — each field alone refuses the approval."""
cols = dict(QR_COMPLETE)
cols[field] = None
_draft(db, gid=f"g-{field}", style=f"SA W1{field[:3]}", **cols)
cols = dict(QR_COMPLETE) # fabric_quality + counters
cols["style_number"] = f"SA W1{field[:3]}"
cols[field] = None # …the one field under test is empty
style = cols.pop("style_number")
_draft(db, gid=f"g-{field}", style=style, **cols)
r = _approve(make_client(_user()), f"g-{field}")
@@ -195,14 +225,26 @@ def test_every_qr_field_individually_blocks_approval(make_client, db, field):
def test_blank_and_whitespace_text_fields_count_as_missing(make_client, db):
"""A whitespace-only value is not a fabric quality — the QR endpoint always
refused it, so the gate must too (same helper)."""
refused it, so the gate must too (same helper). A blank style number is held
to the same test."""
_draft(db, gid="g-blank", style="SA W1001", fabric_quality=" ",
pattern_number="", counters=4)
r = _approve(make_client(_user()), "g-blank")
assert r.status_code == 422, r.text
assert r.json()["detail"]["missing"] == ["fabric_quality", "pattern_number"]
assert r.json()["detail"]["missing"] == ["fabric_quality"]
_draft(db, gid="g-blank-style", style=" ", fabric_quality="Denim 12oz", counters=4)
r2 = _approve(make_client(_user()), "g-blank-style")
assert r2.status_code == 422, r2.text
assert r2.json()["detail"]["missing"] == ["style_number"]
def test_blank_pattern_number_is_not_missing_at_all(make_client, db):
_draft(db, gid="g-pattern-blank", style="SA W1101", fabric_quality="Denim 12oz",
pattern_number=" ", counters=4)
r = _approve(make_client(_user()), "g-pattern-blank"
)
assert r.status_code == 200, r.text
def test_counters_zero_is_a_real_answer(make_client, db):
@@ -230,7 +272,7 @@ def test_qr_complete_draft_approves_and_repeat_approve_is_idempotent(make_client
assert g.review_status == "human_verified"
# Re-approving an ALREADY confirmed garment stays a 200 even though this one
# has no QR fields: the gate is on the confirmation, not on the re-read.
# has no mandatory fields: the gate is on the confirmation, not on the re-read.
_draft(db, gid="g-old", style="SA W1004", is_draft=False)
ok = _approve(make_client(_user()), "g-old")
assert ok.status_code == 200, ok.text
@@ -247,12 +289,21 @@ def test_queue_payload_exposes_qr_readiness(make_client, db):
by_id = {i["id"]: i for i in data}
partial = by_id["draft:g-partial"]
assert partial["qr_missing"] == ["pattern_number", "counters"]
assert partial["qr_missing_labels"] == ["Pattern no.", "Counters (pieces)"]
assert partial["qr_required"] == list(QR_MANDATORY_FIELDS)
assert partial["qr_missing"] == ["counters"]
assert partial["qr_missing_labels"] == ["Counters (pieces)"]
assert partial["qr_required"] == list(MANDATORY_FIELDS)
assert by_id["draft:g-ready"]["qr_missing"] == []
def test_queue_payload_names_a_missing_style_number(make_client, db):
_draft(db, gid="g-no-style", style=None, fabric_quality="Cotton Poplin 120gsm",
counters=4)
data = make_client(_user()).get("/api/review/queue").json()["data"]
mine = [i for i in data if i["id"] == "draft:g-no-style"][0]
assert mine["qr_missing"] == ["style_number"]
assert mine["qr_missing_labels"] == ["Style number"]
def test_per_field_approval_is_not_gated(make_client, db):
"""Per-field AI-tag rows leave the garment where it is, so they keep
approving normally — only the garment confirmation is gated (Shridhar's call)."""
@@ -281,6 +332,9 @@ def test_draft_correct_shares_the_gate(make_client, db):
assert r.status_code == 422, r.text
assert r.json()["detail"]["code"] == "QR_FIELDS_MISSING"
# The style number is present on this fixture, so the refusal names the two
# fields that are actually empty.
assert r.json()["detail"]["missing"] == ["fabric_quality", "counters"]
g = db.query(Garment).filter(Garment.id == "g-corr").first()
db.refresh(g)
assert g.is_draft is True
@@ -288,8 +342,7 @@ def test_draft_correct_shares_the_gate(make_client, db):
def test_gate_and_qr_endpoint_agree_on_what_is_missing(make_client, db):
"""One shared list: the fields the queue gate names are the fields the QR
endpoint refuses to issue a tag without (style number is QR-only, so it may
not appear in the gate's list)."""
endpoint refuses to issue a tag without."""
_draft(db, gid="g-agree", style="SA W1009", fabric_quality="Cotton Poplin 120gsm")
client = make_client(_user())
@@ -298,15 +351,30 @@ def test_gate_and_qr_endpoint_agree_on_what_is_missing(make_client, db):
assert qr.status_code == 422, qr.text
qr_missing = qr.json()["detail"]["missing"]
assert approve_missing == ["pattern_number", "counters"]
assert qr_missing == approve_missing # style number IS present → nothing dropped
assert approve_missing == ["counters"] # pattern number is not a reason
assert qr_missing == approve_missing # style number IS present → nothing dropped
assert set(approve_missing) <= set(qr_missing)
def test_qr_endpoint_refuses_a_style_less_garment_once(make_client, db):
"""The style number is what the tag prints, so the endpoint names it exactly
once (regression guard: it used to be inserted as a special case AND read from
the shared list, which double-listed it)."""
_draft(db, gid="g-no-style-qr", style=None, fabric_quality="Cotton Poplin 120gsm",
counters=4)
r = make_client(_user()).post("/api/plm/g-no-style-qr/qr")
assert r.status_code == 422, r.text
assert r.json()["detail"]["missing"] == ["style_number"]
def test_missing_qr_fields_helper_is_the_single_source(make_client, db):
"""The helper is what both callers read, so lock its contract down here."""
g = _draft(db, gid="g-helper", style="SA W1010", counters=3)
assert missing_qr_fields(g) == ["fabric_quality", "pattern_number"]
assert missing_qr_fields(g) == ["fabric_quality"]
g.fabric_quality = "Cotton Poplin 120gsm"
g.pattern_number = "PT-9"
assert missing_qr_fields(g) == []
g.style_number = None
assert missing_qr_fields(g) == ["style_number"]
+1 -1
View File
@@ -20,7 +20,7 @@ An AI platform for a garment export house, built around **two pillars**:
taxonomy with a confidence score per field. ≥85% auto-saves, 65–85% goes to review, <65% is flagged.
- **Auto style-numbering** — on upload, a gap-safe style number is reserved per **segment** (new-dev / shopping)
× **category** (mens / womens / kids), e.g. `M0001`, `W0002`, `SM0001`; colourways use a shared base + `-A/-B`.
- **QR hangtag** — once a style has fabric quality + pattern no. + counters, a **frozen** QR is generated and
- **QR hangtag** — once a style has style number + fabric quality + counters, a **frozen** QR is generated and
stored; a printable hangtag shows **Style No. + Fabric Quality** with everything else behind the scan.
- **QR scan landing** (`/g/:style`, public) — scanning resolves to the full garment detail view.
- **Semantic + intent search**, **faceted filters**, **Boards/Collections** (personal + public "playlists"),
+1 -1
View File
@@ -33,7 +33,7 @@ sample-dev date, selected-by, location…). The **physical hangtag shows only St
Quality** (what a buyer checks); everything else lives behind the scan. QR/counters drive **sample
tracking** (where each piece is: Bangalore / returned / in-house).
- Mandatory before QR: **fabric quality, pattern number, counters** (style number is auto).
- Mandatory before QR: **style number, fabric quality, counters** — **pattern number is optional** (recorded when the pattern master exists; it no longer blocks the tag or an approval).
- **Counters** = how many physical pieces of the style exist.
- ⚠️ **Open:** exact numbering convention (prefix/format) — implemented configurable, pending the team's confirmed scheme.
+5 -4
View File
@@ -476,14 +476,14 @@ export interface ReviewItem {
* answer rather than a low-confidence model reading. Optional — absent means
* normal classification. */
capture_degraded?: boolean;
/** Capture drafts only: QR-mandatory garment fields still EMPTY, so the
/** Capture drafts only: mandatory garment fields still EMPTY, so the
* garment cannot be approved yet ([] ⇒ approvable + taggable). Names come
* from the backend's shared QR field list, so the row can warn before the
* reviewer clicks Approve. Absent on a backend that predates the gate. */
qr_missing?: string[];
/** Human labels for `qr_missing` ("Pattern no.", "Counters (pieces)"). */
qr_missing_labels?: string[];
/** The full QR-mandatory field list, so the row can say what IS required. */
/** The full mandatory field list, so the row can say what IS required. */
qr_required?: string[];
}
@@ -667,7 +667,8 @@ export async function nextStyleNumber(segment: string, category: string, reserve
return res.data;
}
// Freeze a QR for a garment (needs fabric quality + pattern no. + counters). 422 lists what's missing.
// Freeze a QR for a garment (needs style number + fabric quality + counters — pattern
// number is OPTIONAL since 2026-09-28). 422 lists what's missing.
export async function generateQr(garmentId: string, reissue = false): Promise<{ style_number: string; qr_payload: string; qr_generated_at: string | null; already_existed: boolean }> {
const res = await api.post(`/plm/${garmentId}/qr`, null, { params: { reissue } });
return res.data;
@@ -1316,7 +1317,7 @@ export async function getReviewQueue(): Promise<ReviewItem[]> {
* it. Optional: absent on a backend that predates the field.
*
* Throws the server's 422 `QR_FIELDS_MISSING` when a capture draft's
* QR-mandatory fields are still empty (nothing is committed) — read it with
* mandatory fields are still empty (nothing is committed) — read it with
* `qrApprovalBlock(e)` in lib/qrFields to name the fields to the reviewer. */
export async function approveReviewItem(itemId: string): Promise<{ possible_duplicates?: BulkDuplicateMatch[] }> {
const res = await api.put(`/review/${itemId}/approve`);
+11 -7
View File
@@ -77,32 +77,36 @@ interface GroupFields {
isCoord: boolean;
}
/** QR readiness of one capture group's metadata — the same trio the Review Queue
/** QR readiness of one capture group's metadata — the same fields the Review Queue
* refuses to approve a garment without (lib/qrFields mirrors the backend). The
* capture itself is never blocked; this is what tells the uploader, while the
* sample's hangtag is still in hand, why a later approval would stop. */
* sample's hangtag is still in hand, why a later approval would stop.
*
* `styleNumberAuto`: this flow reserves the group's style number server-side at
* SAVE time, so it is never the uploader's job to type one — passing the flag
* keeps the note from accusing the form of a field it does not own. */
function qrMissingFor(fields: GroupFields): string[] {
return missingQrFields({
fabric_quality: fields.fabricQuality,
pattern_number: fields.patternNumber,
counters: fields.counters.trim() === '' ? null : Number(fields.counters),
});
}, { styleNumberAuto: true });
}
/** '· QR' marker on a QR field's label, amber + 'required' while it is empty. */
/** '· QR' marker on a mandatory field's label, amber + 'required' while it is empty. */
function QrMark({ missing, field }: { readonly missing: string[]; readonly field: string }) {
return missing.includes(field)
? <span className={'normal-case tracking-normal ' + QR_REQUIRED_TEXT}>· QR · required</span>
: <span className="normal-case tracking-normal text-muted/70">· QR</span>;
}
/** The readiness line under the QR trio of one group. */
/** The readiness line under the mandatory fields of one group. */
function QrReadinessNote({ gid, fields }: { readonly gid: number; readonly fields: GroupFields }) {
const missing = qrMissingFor(fields);
if (missing.length === 0) {
return (
<p className={'font-plex text-[9px] uppercase tracking-[0.1em] -mt-2 ' + QR_READY_TEXT} data-testid={`bk-qr-ready-${gid}`}>
<span aria-hidden="true">{QR_READY_GLYPH}</span> QR-ready — quality · pattern · counters filled
<span aria-hidden="true">{QR_READY_GLYPH}</span> QR-ready — quality · counters filled
</p>
);
}
@@ -1741,7 +1745,7 @@ export default function BulkGarmentUpload() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Pattern no. <QrMark missing={qrMissingFor(g.fields)} field="pattern_number" /></label>
<label className={labelCls}>Pattern no. <span className="normal-case tracking-normal text-muted/70">· optional</span></label>
<input className={inputCls} placeholder="PTN-4471" value={g.fields.patternNumber} onChange={(e) => toggleGroupField(g.id, 'patternNumber', e.target.value)} data-testid={`bk-pattern-${g.id}`} />
</div>
<div>
@@ -5,7 +5,7 @@ import Dropdown from './shared/Dropdown';
import CloseButton from './shared/CloseButton';
import ColourPaletteInput from './shared/ColourPaletteInput';
import { useToast } from '../context/ToastContext';
import { QR_FIELDS, missingQrFields, qrMissingSentence } from '../lib/qrFields';
import { QR_FIELDS, QR_OPTIONAL_FIELDS, missingQrFields, qrMissingSentence } from '../lib/qrFields';
import {
AI_FIELD_ORDER, prettify, aiInputCls, aiLabelCls,
parseColourValue, serializeColour, type ColourEntry,
@@ -35,8 +35,9 @@ interface UploadTags {
rejected?: Record<string, TagEntry>;
}
/** Per-field input config for the QR trio (the row order comes from QR_FIELDS). */
/** Per-field input config for the mandatory fields (the row order comes from QR_FIELDS). */
const QR_INPUT: Record<string, { type?: string; placeholder: string }> = {
style_number: { placeholder: 'W1234' },
fabric_quality: { placeholder: 'Cotton Poplin 120gsm' },
pattern_number: { placeholder: 'PTN-4471' },
counters: { type: 'number', placeholder: '3' },
@@ -105,6 +106,7 @@ export default function ReviewAiDetailsModal({ result, previews, onClose }: Revi
.then((g) => {
if (!alive) return;
const next = {
style_number: g.style_number ?? '',
fabric_quality: g.fabric_quality ?? '',
pattern_number: g.pattern_number ?? '',
counters: g.counters != null ? String(g.counters) : '',
@@ -121,7 +123,7 @@ export default function ReviewAiDetailsModal({ result, previews, onClose }: Revi
counters: (qr.counters ?? '').trim() === '' ? null : Number(qr.counters),
}) : [];
const qrDirty = qr !== null && qrInitial !== null
&& QR_FIELDS.some((f) => (qr[f.key] ?? '') !== (qrInitial[f.key] ?? ''));
&& [...QR_FIELDS, ...QR_OPTIONAL_FIELDS].some((f) => (qr[f.key] ?? '') !== (qrInitial[f.key] ?? ''));
// Ordered field list: canonical taxonomy first, then any extra keys returned.
// `colour` is excluded — it renders as the palette editor below, not a text input.
@@ -153,7 +155,12 @@ export default function ReviewAiDetailsModal({ result, previews, onClose }: Revi
counters = parsed;
}
const cleared = (v: string) => (v === '' ? null : v);
const styleNumber = (qr.style_number ?? '').trim();
await updateGarment(result.garment_id, {
// The style number is mandatory: only send it when it actually carries a
// value, so an untouched field never trips the server's 422
// STYLE_NUMBER_REQUIRED — the readiness note below states the gap instead.
...(styleNumber ? { style_number: styleNumber } : {}),
fabric_quality: cleared(qr.fabric_quality ?? ''),
pattern_number: cleared(qr.pattern_number ?? ''),
counters,
@@ -231,7 +238,7 @@ export default function ReviewAiDetailsModal({ result, previews, onClose }: Revi
without them can never be approved out of the queue. */}
{qr !== null && (
<div className="flex flex-col gap-2.5" data-testid="qr-block">
<span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">QR fields · required for approval</span>
<span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Mandatory fields · required for approval</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{QR_FIELDS.map((f) => (
<div key={f.key} className="flex flex-col">
@@ -253,6 +260,24 @@ export default function ReviewAiDetailsModal({ result, previews, onClose }: Revi
</div>
))}
</div>
{/* Pattern number is recorded when it exists but never blocks an
approval (2026-09-28) — still editable here, while the sample's
hangtag is in hand, just no longer part of the gate. */}
{QR_OPTIONAL_FIELDS.map((f) => (
<div key={f.key} className="flex flex-col">
<label className={aiLabelCls + ' flex items-center gap-1.5'}>
{f.label}
<span className="normal-case tracking-normal text-muted/70">· optional</span>
</label>
<input
className={aiInputCls}
placeholder={QR_INPUT[f.key]?.placeholder}
value={qr[f.key] ?? ''}
onChange={(e) => setQr((prev) => (prev ? { ...prev, [f.key]: e.target.value } : prev))}
data-testid={`qr-field-${f.key}`}
/>
</div>
))}
{qrMissing.length > 0 ? (
<p className="font-plex text-[10px] leading-snug text-[#7c5b15] bg-[#f7f1e2] border border-[#9a7322]/40 px-3 py-2 normal-case" data-testid="qr-review-blocked">
Review approval blocked · {qrMissingSentence(qrMissing)} still empty. The garment saves and reaches the queue either way — it just can’t be approved until these are filled.
@@ -273,9 +273,19 @@ export default function GarmentDetailModal({ g, onClose, onSave, onOpenGarment,
async function save() {
if (!form) return;
// The style number is MANDATORY (2026-09-28). It is the garment's identity —
// the QR scan URL, the hangtag and every TNA join key off it — so the form
// refuses to save without one instead of sending the null that used to clear
// it (the server rejects an empty/null number with 422 STYLE_NUMBER_REQUIRED
// too; this is the same rule stated before the request leaves the browser).
const styleNumber = (form.style_number ?? '').trim();
if (!styleNumber) {
setErr('Style number is required — it is the garment’s identity (tag, scan, TNA).');
return;
}
// Validate before any request goes out: Number('12 pcs') is NaN, and
// JSON.stringify turns NaN into null, which would silently CLEAR a
// QR-mandatory field while the form reports a successful save (HG-105).
// mandatory field while the form reports a successful save (HG-105).
const countersRaw = (form.counters ?? '').trim();
let counters: number | null = null;
if (countersRaw !== '') {
@@ -293,7 +303,7 @@ export default function GarmentDetailModal({ g, onClose, onSave, onOpenGarment,
// successful (HG-106). Selects can't be emptied, so they keep "unchanged".
const cleared = (v: string) => (v === '' ? null : v);
await updateGarment(g.id, {
style_number: cleared(form.style_number),
style_number: styleNumber, // never null/blank — validated above
season: cleared(form.season),
design_team_city: cleared(form.design_team_city),
collection_name: cleared(form.collection_name),
@@ -524,7 +534,7 @@ export default function GarmentDetailModal({ g, onClose, onSave, onOpenGarment,
<div data-testid="garment-edit-form" ref={editFormRef} className="flex flex-col gap-5">
<p className="font-plex text-[9px] uppercase tracking-[0.2em] text-muted pb-2 border-b border-line">Edit details</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-3.5">
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Style number</span><input value={form.style_number} onChange={(e) => set('style_number', e.target.value)} className={input} /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Style number {qrRequiredTag('style_number')}</span><input value={form.style_number} onChange={(e) => set('style_number', e.target.value)} className={input} data-testid="form-style-number" /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Season</span><input value={form.season} onChange={(e) => set('season', e.target.value)} className={input} /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Team</span><input value={form.design_team_city} onChange={(e) => set('design_team_city', e.target.value)} className={input} /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Collection</span><input value={form.collection_name} onChange={(e) => set('collection_name', e.target.value)} className={input} /></label>
@@ -557,7 +567,7 @@ export default function GarmentDetailModal({ g, onClose, onSave, onOpenGarment,
/>
</label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Fabric quality <span className="normal-case tracking-normal text-muted/70">· QR</span> {qrRequiredTag('fabric_quality')}</span><input value={form.fabric_quality} onChange={(e) => set('fabric_quality', e.target.value)} placeholder="Cotton Poplin 120gsm" className={input} data-testid="form-fabric-quality" /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Pattern no. <span className="normal-case tracking-normal text-muted/70">· QR</span> {qrRequiredTag('pattern_number')}</span><input value={form.pattern_number} onChange={(e) => set('pattern_number', e.target.value)} className={input} data-testid="form-pattern-number" /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Pattern no.</span><input value={form.pattern_number} onChange={(e) => set('pattern_number', e.target.value)} className={input} data-testid="form-pattern-number" /></label>
<label className="flex flex-col gap-1"><span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">Counters <span className="normal-case tracking-normal text-muted/70">· pieces · QR</span> {qrRequiredTag('counters')}</span><input type="number" min="0" value={form.counters} onChange={(e) => set('counters', e.target.value)} className={input} data-testid="form-counters" /></label>
<label className="flex items-center gap-2 mt-5"><input type="checkbox" checked={form.is_co_ord_set} onChange={(e) => set('is_co_ord_set', e.target.checked)} className="w-4 h-4 accent-ink" /><span className="text-[12.5px] text-ink">Co-ord piece</span></label>
{/* Why an approval (and the QR tag itself) will be refused — said
+37 -17
View File
@@ -1,19 +1,23 @@
/**
* QR-mandatory garment fields — the three fields a garment needs before it can
* be issued a QR tag AND before a capture draft can be approved out of the
* Review Queue.
* The mandatory garment fields — what a garment needs before it can be issued a
* QR tag AND before a capture draft can be approved out of the Review Queue.
*
* ⚠️ These are the SAME three fields the backend enforces
* (`backend/services/plm_service.py`: `QR_MANDATORY_FIELDS` /
* `missing_qr_fields()`), and the queue gate reads them from there. The two
* lists must stay in step: the backend is the authority (it refuses the write),
* this copy is what lets the UI name the fields BEFORE the reviewer clicks and
* highlight them in the forms.
* ⚠️ These are the SAME fields the backend enforces
* (`backend/services/qr_fields.py`: `MANDATORY_FIELDS` / `missing_qr_fields()`),
* and the queue gate reads them from there. The two lists must stay in step: the
* backend is the authority (it refuses the write), this copy is what lets the UI
* name the fields BEFORE the reviewer clicks and highlight them in the forms.
*
* Why the rule exists (design-team rule, Notes/Features.md): the QR is the
* garment's identity — quality + pattern number + counters are what the printed
* hangtag shows and what the phone scan resolves to, so a garment that reaches
* the library without them can never be tagged or counted upstream.
* garment's identity. The STYLE NUMBER is that identity (scan URL, hangtag, every
* TNA join) — it is mandatory and can no longer be cleared — and fabric quality +
* counters are what the printed tag shows and what the phone scan resolves to.
*
* PATTERN NO. IS NOT MANDATORY (2026-09-28, design team): it is recorded when it
* exists and stays editable everywhere, but the sample routinely reaches the
* studio before the pattern master is keyed, and blocking on it stalled garments
* that were otherwise complete. It lives in `QR_OPTIONAL_FIELDS` — still rendered,
* never required, never named in a refusal.
*/
export interface QrFieldDef {
@@ -26,11 +30,19 @@ export interface QrFieldDef {
}
export const QR_FIELDS: readonly QrFieldDef[] = [
{ key: 'style_number', label: 'Style number', short: 'Style number' },
{ key: 'fabric_quality', label: 'Fabric quality', short: 'Fabric quality' },
{ key: 'pattern_number', label: 'Pattern no.', short: 'Pattern no.' },
{ key: 'counters', label: 'Counters (pieces)', short: 'Counters' },
];
/**
* Editable in the same forms, but NEVER required — an empty one is not a reason
* to refuse an approval and must not be reported as "missing".
*/
export const QR_OPTIONAL_FIELDS: readonly QrFieldDef[] = [
{ key: 'pattern_number', label: 'Pattern no.', short: 'Pattern no.' },
];
/** The label for one field key, tolerating anything the server sends back. */
export function qrFieldLabel(key: string): string {
return QR_FIELDS.find((f) => f.key === key)?.label ?? key.replace(/_/g, ' ');
@@ -43,6 +55,7 @@ export function qrFieldShort(key: string): string {
/** The garment fields the readiness check reads (all optional: a partial
* garment object from a list endpoint is honest about what it has). */
export interface QrFieldsInput {
readonly style_number?: string | null;
readonly fabric_quality?: string | null;
readonly pattern_number?: string | null;
readonly counters?: number | string | null;
@@ -52,12 +65,19 @@ export interface QrFieldsInput {
* Mirror of the backend's `missing_qr_fields()`.
*
* `counters` is an Integer column: 0 is a real answer ("none made yet"), only a
* null/blank one is missing. The two text columns count as missing when blank —
* the same test the QR endpoint has always applied.
* null/blank one is missing. The text columns (style number, fabric quality)
* count as missing when blank — the same test the QR endpoint has always applied.
*
* `styleNumberAuto`: the capture/bulk flows reserve the style number at SAVE time,
* so the number cannot be empty in a form that is still being filled in. Those
* callers pass this flag instead of being told their style number is missing.
*/
export function missingQrFields(g: QrFieldsInput): string[] {
export function missingQrFields(g: QrFieldsInput, opts?: {
readonly styleNumberAuto?: boolean;
}): string[] {
const missing: string[] = [];
for (const { key } of QR_FIELDS) {
if (key === 'style_number' && opts?.styleNumberAuto) continue;
const v = g[key as keyof QrFieldsInput];
if (v == null) missing.push(key);
else if (typeof v === 'string' && !v.trim()) missing.push(key);
@@ -65,7 +85,7 @@ export function missingQrFields(g: QrFieldsInput): string[] {
return missing;
}
/** "Pattern no. and Counters" / "Fabric quality, Pattern no. and Counters". */
/** "Style number and Counters" / "Style number, Fabric quality and Counters". */
export function qrMissingSentence(missing: readonly string[]): string {
const names = missing.map(qrFieldShort);
if (names.length === 0) return '';