From 27ca1fd9f1870af4fdf67928b068840a958e7895 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Thu, 27 Aug 2026 14:05:58 -0400 Subject: [PATCH 1/5] chore(agent): rules become principles, and the learning loop closes Applies the two-skill pattern from Warp's self-improving agents to this repo's agent layer, and restructures CLAUDE.md around principles. PRINCIPLES AS A SPINE, NOT A REPLACEMENT. Three independent restructures were proposed and then attacked for lost enforcement. All three lost it, in the same way: generalising a rule into prose reads better and enforces less. Rule 33's greppable literal and its "No exceptions"; rule 47's polarity, which is OPPOSITE to 33 (cancelled lines out, returned orders in) so a polarity-neutral merger permits both errors; rule 31's stated default; rule 39's "both numerator and denominator"; rule 42's "every mutation path". In a repo where hooks cite these numbers and 203 files quote them, that is a regression wearing a cleanup's clothes. So the rules keep their exact text and their numbers, and ten principles sit above them. Each principle names a failure mode generally enough to recognise a NEW instance, and carries a "how it hides" line -- the recurring theme here is damage that passes every check. Verified mechanically: every rule number is present exactly once, and every surviving rule's text is byte-identical to before, except five that gained clarifying additions and none that lost a clause. Rule 1 (KISS) retired. Zero citations, no incident, no enforcement home, and RULE-PROVENANCE.md already said to delete it -- it was the one rule that contradicted the doctrine it sat under. Number retired, not reused. THE LOOP. The gap that produced 65 numbers with gaps and five conflicting citations was never a missing rule. It was that "does this deserve a rule?" got decided in-session by the agent that had just been burned, and every session got to append. That is accretion, not learning. - docs/RULE-FEEDBACK.md is now the sink. Costs one line, commits to nothing, so the signal keeps flowing. Seeded with three entries from this session. - .claude/skills/improve-rules/SKILL.md is the observer. Runs later, over the accumulated pile, and proposes ONE focused edit as a PR. "No change warranted" is an explicit success case. Retirement runs the same bar in reverse, because a loop that only adds repeats the original failure. - post-failure and end-of-session no longer edit CLAUDE.md. They append. ONE DELIBERATE DEVIATION FROM WARP. Their loop feeds on human PR review comments. Measured here: 20 merged PRs, 0 reviews, 1 comment -- FRAMEWORK.md describes a solo project with no second pair of eyes, so that well is dry. The substitute is the commit that had to clean up after the last one: a fix/revert commit is literally what the agent proposed versus what reality required, with a diff attached, and roughly one commit in four is one. rules-improver-check.sh counts them and nudges past a threshold -- event-driven, so the pass fires when there is something to read rather than on a calendar. It never blocks. 3,479 unit tests pass; markdownlint clean. --- .claude/hooks/rules-improver-check.sh | 64 +++++ .claude/settings.json | 6 + .claude/skills/end-of-session/SKILL.md | 13 +- .claude/skills/improve-rules/SKILL.md | 132 ++++++++++ .claude/skills/post-failure/SKILL.md | 9 +- CLAUDE.md | 349 ++++++++++++++++++------- docs/FRAMEWORK.md | 19 ++ docs/RULE-FEEDBACK.md | 75 ++++++ docs/RULE-PROVENANCE.md | 17 +- 9 files changed, 582 insertions(+), 102 deletions(-) create mode 100755 .claude/hooks/rules-improver-check.sh create mode 100644 .claude/skills/improve-rules/SKILL.md create mode 100644 docs/RULE-FEEDBACK.md diff --git a/.claude/hooks/rules-improver-check.sh b/.claude/hooks/rules-improver-check.sh new file mode 100755 index 00000000..016bbbb3 --- /dev/null +++ b/.claude/hooks/rules-improver-check.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# .claude/hooks/rules-improver-check.sh +# +# Nudges toward `.claude/skills/improve-rules/SKILL.md` once enough evidence has +# accumulated to be worth a pass. +# +# Event-driven, not time-driven, on purpose. A weekly cron runs whether or not +# anything happened, and a pass with nothing to read either returns "no change" +# (wasted) or invents one (worse). A fix commit is the signal that the rules met +# reality and something gave, so counting those fires the improver exactly when +# there is something to learn from. +# +# NEVER blocks. This is a prompt for a human decision, not a gate — the whole +# point of the improver is that it runs with distance, so forcing it mid-session +# would defeat it. + +set -euo pipefail +cd "$(dirname "$0")/../.." + +THRESHOLD="${RULES_IMPROVER_THRESHOLD:-8}" +STAMP=".claude/.rules-last-run" +SINCE=$(cat "$STAMP" 2>/dev/null || echo "") + +if [ -n "$SINCE" ]; then + RANGE=(--since="$SINCE") +else + # No stamp yet: look back a sensible window rather than all of history. + RANGE=(--since="6 weeks ago") +fi + +FIXES=$(git log "${RANGE[@]}" --format='%s' 2>/dev/null \ + | grep -icE '^(fix|revert)' || true) +FIXES=${FIXES:-0} + +LEDGER=0 +if [ -f docs/RULE-FEEDBACK.md ]; then + if [ -n "$SINCE" ]; then + # Ledger entries are "### YYYY-MM-DD — ..."; count those dated after the stamp. + LEDGER=$(grep -oE '^### [0-9]{4}-[0-9]{2}-[0-9]{2}' docs/RULE-FEEDBACK.md \ + | awk -v s="$SINCE" '{ if (substr($2,1,10) > s) n++ } END { print n+0 }') + else + LEDGER=$(grep -cE '^### [0-9]{4}-[0-9]{2}-[0-9]{2}' docs/RULE-FEEDBACK.md || true) + fi +fi +LEDGER=${LEDGER:-0} + +SIGNAL=$(( FIXES + LEDGER )) + +if [ "$SIGNAL" -ge "$THRESHOLD" ]; then + cat < .claude/.rules-last-run + ``` + +Then stop. A human merges it, and the next session inherits the improvement. diff --git a/.claude/skills/post-failure/SKILL.md b/.claude/skills/post-failure/SKILL.md index 998b9571..726429e0 100644 --- a/.claude/skills/post-failure/SKILL.md +++ b/.claude/skills/post-failure/SKILL.md @@ -35,8 +35,13 @@ Fix the actual issue. Verify with `cd app && npm run validate && npm test`. section, or a tripwire test naming the bug class (prefer a real-DB integration test over a source-text tripwire where the behavior is testable — rule 57). -- **Business rule / cross-cutting invariant** → propose a CLAUDE.md rule - update with an origin pointing at this entry. +- **Business rule / cross-cutting invariant** → append an entry to + `docs/RULE-FEEDBACK.md`, not a rule to CLAUDE.md. Say which principle this + is an instance of, or `NEW` if none fits, and answer "would a rule have + caught it?" — the answers "no" and "only if enforced differently" are the + valuable ones. `.claude/skills/improve-rules/SKILL.md` reads the ledger + later and proposes the constitutional change as a PR, with the distance that + makes the judgment worth trusting. - **Data assumption** → update the relevant `docs/domains/*.md` runbook. ## Step 5: Update the Failure Log diff --git a/CLAUDE.md b/CLAUDE.md index 2b5f7a8c..41379049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,8 @@ truth. ## The agent layer -Per `docs/FRAMEWORK.md`, this file is the **constitution**: short numbered rules, -read every session. Detail lives elsewhere, and this file points at it rather -than repeating it. +Per `docs/FRAMEWORK.md`, this file is the **constitution**, read every session. +Detail lives elsewhere, and this file points at it rather than repeating it. | Layer | Where | When to read | |---|---|---| @@ -22,71 +21,112 @@ than repeating it. | Procedures | `.claude/skills/*/SKILL.md` | at the moment they apply | | Hard gates | `.claude/hooks/*.sh` | enforced automatically | | Rule provenance + conflicts | `docs/RULE-PROVENANCE.md` | when a rule's origin matters | +| How rules change | `.claude/skills/improve-rules/SKILL.md` | when the ledger has accumulated | -**Numbering has gaps.** Rules were reconstructed from citations across the repo; -where no citation resolved to a number, nothing was invented. Five numbers have -conflicting citations — see `docs/RULE-PROVENANCE.md`. +## How to read this file -## Rules +**Principles are the spine; rules are the instances.** Each principle names a +way this system fails, general enough that you can recognise a NEW instance +before it ships. Beneath it sit the numbered rules that earned it — each one a +specific incident, stated in the exact operational terms that make it +checkable. -### Engineering standards +Read the principles to know what to look for. Read the rule under it for what +to actually write. **The rule text is the enforceable one** — where a principle +and a rule seem to differ, the rule wins, because the rule is what the hooks, +tripwires and code comments cite. -1. **KISS.** Prefer the straightforward solution over the clever one. -5. **LTS only** for every runtime and dependency. Pre-release only to close an - active CVE with confirmed exposure — document it, revert on the next GA. - Majors get their own planned session. -6. **One source of truth per concept.** Shared logic lives in `src/lib/`. -7. **Shared client/server contracts live in one file** and are imported by both - sides, so contract drift is a compile error rather than a runtime 400. +**Numbers are permanent.** 203 files cite rules by number; rule 33 alone is +cited 65 times. A number is never reused, never renumbered, and never removed +while anything cites it — a rule that stops earning a constitutional slot is +demoted to its runbook with the number preserved so the citation still +resolves. Numbering has gaps because rules were reconstructed from citations +and nothing was invented to fill them. Five numbers have conflicting citations +— see `docs/RULE-PROVENANCE.md`. -### Code quality and testing +**Rules earn their place by surviving incidents** (`docs/FRAMEWORK.md`). A rule +with no incident behind it is a preference, and preferences do not belong here. +That is also the test for adding one — see "Changing this file" at the end. -11. **Surface backend error messages** via `getErrorMessage(err, fallback)`, - never a generic "Failed to X." -12. **Tests exercise actual behaviour.** Real-DB integration tests are the - default behind Prisma. Source-text tripwires are for "someone removed the - guard" classes only. See also rule 57. -13. **Restoration migrations derive values from a column the corruption didn't - touch** — never from a memo. → `docs/domains/import-pipeline.md` -14. **Test the logic, not the wrapper.** Branching logic belongs in pure - helpers in `lib/`; handlers shrink to auth, Prisma, and error handling. +## Principles + +### 1. NULL is an escape hatch, not a value + +A nullable column is neither equal nor unequal to anything, so a row holding +NULL escapes a negative filter and never matches a unique key. Any predicate or +key touching a nullable column must name NULL explicitly, or it silently means +something other than what it reads as. + +**How it hides:** the code says what you meant. It compiles, it reads correctly, +and only the database disagrees. On the read side the total is merely low; on +the write side an operation meant to converge instead inserts a fresh row every +run. + +51. **Never use a naked `not:`/`notIn:` on a nullable column** — three-valued + logic drops NULL rows silently. Use `OR: [{ col: null }, { col: { not: X } }]`. +64. **Never upsert through a compound unique key containing a nullable column.** + Postgres treats NULLs as DISTINCT in a unique index unless it is declared + `NULLS NOT DISTINCT` — none of ours are. So the constraint does not prevent + duplicate rows, and the upsert never matches: it creates another row every + time. Use `findFirst` + increment-or-create with an explicit `col: null`. + `InventoryPosition`'s key has two nullable columns and shipped with exactly + this bug in both `allocate` and `release`; free stock would have multiplied + on every cancel. **Tell:** if you need `null as unknown as number` to make + an upsert compile, the operation is invalid, not the types. + +### 2. An aggregate must declare its population + +A row's presence in a table is not a claim that it counts. Every sum, count and +status recalculation states which rows it includes — in numerator and +denominator alike — or it silently answers a different question than the one +asked. Note the two directions are opposite and both are errors: cancelled +lines must come **out**, returned orders must stay **in**. -### Money and reporting invariants +**How it hides:** the number stays plausible. Nothing in the system knows the +right answer to compare against, so a total that is confidently wrong by a +believable margin survives indefinitely. The highest-consequence cluster in the codebase. Detail and worked examples: → `docs/domains/reporting.md`, `docs/domains/accounting.md` +31. **Zero-quantity source rows are cancelled lines.** Every aggregation decides + explicitly whether to include them. Default: exclude. 33. **Exclude cancelled lines from every sum and count** — `lineItemStatus: { not: "CANCELLED" }`. No exceptions. -37. **Business-rule definition catalogs live in exactly one file**; every - consumer imports from it. +39. **PO receiving-status recalculation excludes zero-quantity lines from both + numerator and denominator** — otherwise a lingering zero-qty line traps a PO + at `RECEIVED_PARTIAL` forever. Corollary of 31. + → `docs/domains/purchasing.md` 40. **Status is a broad hammer.** Fix a bad import at the import boundary; never patch a reporting symptom by mutating status on good rows. 41. **Ground defaults and thresholds in production data**, and hand-classify the near-boundary cases before shipping — an aggregate-only check hides cases that split both ways. -42. **A safety guard is one shared function on every mutation path that needs - it.** Present on one path and missing on another is no guard at all. - → `docs/domains/commission.md` 47. **Revenue queries include RETURNED orders** — `status: { in: SALES_REVENUE_STATUSES }`. Sister rule to 33: that one governs `lineItemStatus` (cancelled out), this governs `SalesOrder.status` (returned in). -51. **Never use a naked `not:`/`notIn:` on a nullable column** — three-valued - logic drops NULL rows silently. Use `OR: [{ col: null }, { col: { not: X } }]`. -64. **Never upsert through a compound unique key containing a nullable column.** - Postgres treats NULLs as DISTINCT in a unique index unless it is declared - `NULLS NOT DISTINCT` — none of ours are. So the constraint does not prevent - duplicate rows, and the upsert never matches: it creates another row every - time. Use `findFirst` + increment-or-create with an explicit `col: null`. - `InventoryPosition`'s key has two nullable columns and shipped with exactly - this bug in both `allocate` and `release`; free stock would have multiplied - on every cancel. **Tell:** if you need `null as unknown as number` to make - an upsert compile, the operation is invalid, not the types. -60. **Route by recorded fact, not current config.** Refunds and webhooks resolve - the processor from the payment's stored `processorType`, never from whichever - provider is active now. +### 3. A guard on one path is no guard + +An invariant that must hold at more than one site is only as strong as the site +you forgot. Enumerate the set mechanically — callers, mutation paths, models, +routes — put the invariant in one shared function, and add a check that fails +when the enumeration and reality disagree. + +**How it hides:** the reported path gets fixed and the duplicate-shaped path +does not, so the symptom returns days later through a different route and reads +as a new bug. No test catches it, because the uncovered site is the one nobody +listed. + +42. **A safety guard is one shared function on every mutation path that needs + it.** Present on one path and missing on another is no guard at all. + → `docs/domains/commission.md` +45. **Trace before refactoring shared infrastructure.** A "mechanical" change to + auth, payments, import runners, or uploads carries a `grep -rn` for callers + in the PR. +49. **Self-heal as you go.** Fixed a bug shape → grep for it elsewhere. + Mechanical sweep → fix every site in the same PR with one regression test. 65. **Anything countable gets a manifest and a tripwire.** When the repo has a set of things that must stay covered — every model seeded, every migration guard applied, every route gated — do not rely on noticing. Write down the @@ -113,7 +153,9 @@ The highest-consequence cluster in the codebase. Detail and worked examples: `prisma/testing/db-guards.sql` + `__tests__/dbGuardsCoverage.test.ts`; `__tests__/schemaNormalization.test.ts` (text-beside-its-own-FK columns, each accepted pair carrying the measurement that justified it); - `__tests__/fixtures/ungated-read-api-routes.txt`. + `__tests__/fixtures/ungated-read-api-routes.txt`; + `__tests__/clientDataTripwire.test.ts` (patterns base64-encoded — a guard + listing a real company's identifiers in plaintext is itself the leak). A ratchet beats a cleanup. Where the debt is real but removing it is not worth it today, freeze the set and require a written argument to grow it — @@ -122,7 +164,79 @@ The highest-consequence cluster in the codebase. Detail and worked examples: units of work, name them — a named unit is delegable; "the rest of the gap" is not. -### Imports and legacy data +### 4. Verify with an instrument that can disagree + +A check counts as evidence only if it was structurally capable of failing for +the real reason. A mock returns what you stubbed; an incremental install is not +the clean tree CI builds; a source-text scan agrees as long as the text is +there. Each is a proxy — fine where the proxy is the point, wrong where it +stands in for the thing that ships. + +**How it hides:** green. The signal you look at cannot contradict you, so +confidence rises exactly as the evidence stops meaning anything. + +Note this does not demote tripwires generally — rule 65 requires them for +coverage sets. The failure is using one where a real assertion was possible, +and never proving it can fail. + +12. **Tests exercise actual behaviour.** Real-DB integration tests are the + default behind Prisma. Source-text tripwires are for "someone removed the + guard" classes only. See also rule 57. +14. **Test the logic, not the wrapper.** Branching logic belongs in pure + helpers in `lib/`; handlers shrink to auth, Prisma, and error handling. +52. **Verify dependency and lockfile changes with `npm ci`, never `npm + install`.** CI installs clean from the lockfile; an incremental install + hides breakage that only appears on a clean one. +57. **Prefer behavioural tests over source-text tripwires** where the behaviour + is testable. Tripwires stay correct for "this guard must exist everywhere" + invariants — the failure mode is using one where a real assertion was + possible. + +### 5. A claim is exactly as strong as its evidence + +Never stronger than what was verified, never weaker than what is already known. +This governs messages on screen, lines in runbooks, and sentences in a PR body +alike. + +**How it hides:** the claim is the only artifact, and it does not drift when +reality does. Nobody gets a signal that it went false — they just act on it. + +11. **Surface backend error messages** via `getErrorMessage(err, fallback)`, + never a generic "Failed to X." (The under-claiming direction: a catch that + renders "Failed to save" discards a backend message that said precisely + what was wrong.) +19. **Runbooks are pinned against source**, not plausibility — cite source + opened this session or write `[NEEDS VERIFICATION]`. +56. **Verify claims against code, not docs.** Docs drift; source cannot lie + about its own current state. +58. **Report unverifiable work as unverified.** If a claim can't be exercised in + this environment, say so rather than implying it was tested. + +### 6. One home per fact + +A fact with two homes has one home and one copy, and the copy is wrong in +whichever direction nobody looked. + +**How it hides:** both copies were right when written. Drift is invisible until +the two are compared, and nothing compares them. + +6. **One source of truth per concept.** Shared logic lives in `src/lib/`. +7. **Shared client/server contracts live in one file** and are imported by both + sides, so contract drift is a compile error rather than a runtime 400. +37. **Business-rule definition catalogs live in exactly one file**; every + consumer imports from it. (Specialisation of 6 and 7 for enum-like + catalogs — `permissionCatalog`, the runner registry, + `SALES_REVENUE_STATUSES`.) + +### 7. Deployment facts are configuration + +Anything true of one deployment and not of the product is data, not code. This +is the rule the codebase violates most often and most quietly, because a +hardcoded literal works perfectly for the deployment it was written for. + +**How it hides:** it is correct — for one tenant. The second deployment does not +error; it silently routes to the wrong runner, classifies an ordinary order as a +return, or imports nothing and logs "skipped". → `docs/domains/import-pipeline.md`, `docs/domains/imports-overview.md`, `docs/domains/config-presets.md` @@ -141,40 +255,83 @@ The highest-consequence cluster in the codebase. Detail and worked examples: before writing; a second apply must write nothing. A preset is desired state, so a mapping deleted from the file is deleted from the database. -31. **Zero-quantity source rows are cancelled lines.** Every aggregation decides - explicitly whether to include them. Default: exclude. -39. **PO receiving-status recalculation excludes zero-quantity lines from both - numerator and denominator** — otherwise a lingering zero-qty line traps a PO - at `RECEIVED_PARTIAL` forever. Corollary of 31. - → `docs/domains/purchasing.md` + **Unconfigured must fail closed, not guess.** A permissive default is a + deployment fact you invented. `[A-Z]{2,}` as a store code classified `SOFA1` + and `MEGA1234` as returns; `.+_` as a report prefix routed + `Deleted_Customers.csv` into the customer master. Where there is no safe + universal value, match nothing and say so. + +### 8. Irreversible paths fail closed + +Destructive and unrecoverable operations refuse by default and require an +explicit, separately-named opt-in. Allowlist, never blocklist: a blocklist of +known-dangerous cases fails open for the one nobody thought of, which is always +the one that costs someone their data. + +**How it hides:** it works every time you run it correctly. The failure needs +one unfamiliar input — a database name nobody listed, a processor that has since +been switched — and by then it has already happened. + +13. **Restoration migrations derive values from a column the corruption didn't + touch** — never from a memo. → `docs/domains/import-pipeline.md` +59. **`fbc_test_db` is the only database tests may write**, and the demo seed + writes only a database whose NAME says it exists to be seeded (`holt_demo`, + `holt_seed_demo`, `ci`). The token seed/demo/scratch/sandbox/sample/ci must + be delimited by `_` or the ends of the name, so `holt-demo`, `demo2` and + `holt_samples` are all refused — near-misses are refused on purpose, since + a name that only nearly says "scratch" is exactly the one that turns out to + hold something. Every other database is assumed to hold restored, curated or + live local data and needs an explicit `--force-unsafe-db`; the integration + test database is refused even with it. Allowlist, not blocklist: a blocklist + of known-dangerous names fails open for the one nobody thought of, which is + always the one that costs someone their data. The `DATABASE_URL must contain + 'test'` guard in `src/lib/testing/withTestDb.ts` is a floor, not a substitute + for pointing at the right database. Enforced by + `prisma/seed/demo/guard.ts`, tested in `__tests__/seedTargetGuard.test.ts`. +60. **Route by recorded fact, not current config.** Refunds and webhooks resolve + the processor from the payment's stored `processorType`, never from whichever + provider is active now. + +### 9. Nothing leaves the session unaddressed -### Session and workflow discipline +Every finding, every deferral, every learning ends somewhere durable. The +alternative is not "we'll get to it" — it is silently dropped, and nobody knows +the difference. -→ `docs/WORKFLOW.md`, and the `pre-pr` / `pre-commit` skills +**How it hides:** intent feels like completion. A verbal "I'll sweep the rest" +and a tracked plan are indistinguishable at the end of a session and completely +different a week later. + +**18 vs 49 — which wins.** 18 says ship the simplest fix to the symptom; 49 says +fix every site of a bug shape in one PR. They pull opposite ways on the same PR +and 50 is the resolution: fix the reported symptom now, sweep only what the same +PR can prove, and the unswept sites go into a tracked plan — never a verbal +promise. Scope of the fix is 18's call; scope of the *record* is 50's, and 50 is +not optional. 18. **Ship the simplest fix to the reported symptom.** Bundle prevention layers only when asked; spawn the rest. -19. **Runbooks are pinned against source**, not plausibility — cite source - opened this session or write `[NEEDS VERIFICATION]`. 36. **Read before working, update after learning.** Read the domain runbook before touching a domain; update it before closing the session. -45. **Trace before refactoring shared infrastructure.** A "mechanical" change to - auth, payments, import runners, or uploads carries a `grep -rn` for callers - in the PR. 48. **Every scan finding ends in one of three states:** fixed, tripwire-tested, or explicitly won't-fix with rationale. Never silent-ignore. -49. **Self-heal as you go.** Fixed a bug shape → grep for it elsewhere. - Mechanical sweep → fix every site in the same PR with one regression test. 50. **Deferred work goes into a tracked plan**, never a verbal promise. The PR body says where. -### Dependencies and CI hygiene +### 10. Own the version state you depend on + +You are responsible for the tree that ships, not the one that resolved on your +machine. A suppression without an expiry is a permanent decision made by +someone who thought it was temporary. + +**How it hides:** it resolves locally. The gate is green until an unrelated PR +turns it red for reasons that have nothing to do with that PR. Full playbook: → `.claude/skills/dependency-sweep/SKILL.md` -52. **Verify dependency and lockfile changes with `npm ci`, never `npm - install`.** CI installs clean from the lockfile; an incremental install - hides breakage that only appears on a clean one. +5. **LTS only** for every runtime and dependency. Pre-release only to close an + active CVE with confirmed exposure — document it, revert on the next GA. + Majors get their own planned session. 53. **Never blanket-override a package with incompatible major lines.** Use version-scoped (`pkg@1`) or path-scoped (`minimatch@10>brace-expansion`) overrides. @@ -183,33 +340,39 @@ Full playbook: → `.claude/skills/dependency-sweep/SKILL.md` 55. **Sweep CVEs before starting a merge train.** New advisories turn the gate red for reasons unrelated to any pending PR. -### Verification and honesty - -56. **Verify claims against code, not docs.** Docs drift; source cannot lie - about its own current state. -57. **Prefer behavioural tests over source-text tripwires** where the behaviour - is testable. Tripwires stay correct for "this guard must exist everywhere" - invariants — the failure mode is using one where a real assertion was - possible. -58. **Report unverifiable work as unverified.** If a claim can't be exercised in - this environment, say so rather than implying it was tested. - -### Data safety - -59. **`fbc_test_db` is the only database tests may write**, and the demo seed - writes only a database whose NAME says it exists to be seeded (`holt_demo`, - `holt_seed_demo`, `ci`). The token seed/demo/scratch/sandbox/sample/ci must - be delimited by `_` or the ends of the name, so `holt-demo`, `demo2` and - `holt_samples` are all refused -- near-misses are refused on purpose, since - a name that only nearly says "scratch" is exactly the one that turns out to - hold something. Every other database is assumed to hold restored, curated or - live local data and needs an explicit `--force-unsafe-db`; the integration - test database is refused even with it. Allowlist, not blocklist: a blocklist - of known-dangerous names fails open for the one nobody thought of, which is - always the one that costs someone their data. The `DATABASE_URL must contain - 'test'` guard in `src/lib/testing/withTestDb.ts` is a floor, not a substitute - for pointing at the right database. Enforced by - `prisma/seed/demo/guard.ts`, tested in `__tests__/seedTargetGuard.test.ts`. +## Retired + +**1. KISS.** Retired 2026-08-26. Zero citations across the 203 rule-citing +files; no incident, no origin link, and no enforcement home in a skill, hook or +tripwire. `docs/RULE-PROVENANCE.md` already flagged it as deletable. It was also +the one rule that contradicted the doctrine it sat under — a preference, not +something an incident taught. The number is retired, not reused. + +## Changing this file + +A rule enters only by surviving an incident, and it enters through +`.claude/skills/improve-rules/SKILL.md` — an observer pass that runs over +accumulated evidence, not a decision made mid-task while the incident is still +warm. That distance is the point: deciding in the moment is how this file +reached 65 numbers with gaps and five conflicting citations. + +The bar for a proposed change: + +- **Cite the incident.** A commit SHA, a PR number, or a ledger entry. No + citation, no rule. +- **Say where it is enforced.** Skill (persuasive), hook (hard gate) or tripwire + (backstop) — per `docs/FRAMEWORK.md` §3. A rule with no enforcement home is a + wish. +- **Prefer strengthening a principle to adding a number.** Most new incidents + are new instances of a failure mode already named here. A new number is for a + failure mode that is genuinely new. +- **One change per PR.** The improver proposes a single focused edit so it can + be judged on its own. + +Retirement runs the same way and needs the same evidence: a rule whose code path +is gone, whose guard has never fired, or which no longer has citations. Demote +it to its runbook with the number preserved so existing citations still resolve; +delete a number only when nothing cites it. ## Stack and gates diff --git a/docs/FRAMEWORK.md b/docs/FRAMEWORK.md index 63e5a5d5..c0f2eb8e 100644 --- a/docs/FRAMEWORK.md +++ b/docs/FRAMEWORK.md @@ -85,6 +85,25 @@ Concrete artifact: `.claude/skills/post-failure/SKILL.md`. Every incident in the **Rules earn their place by surviving incidents.** Don't add a rule because it sounds good; add it when an incident proves it's needed. The rule then has gravitas because it has a story behind it. +### 4a. Closing the loop: the improver pass + +The loop above has a gap that took 65 numbered rules to become obvious. The decision *"is this recurring shape worth a rule?"* was made in-session, by the agent that had just been burned by it. That agent is the worst-placed judge in the system: the incident is warm, the lesson feels universal, and every session gets to append. The result is CLAUDE.md with numbering gaps and five conflicting citations — accretion, not learning. + +The fix borrows the two-skill split from [how Warp builds self-improving agents](https://claude.com/blog/how-warp-builds-self-improving-agents-on-claude): an **inner** skill that does the work, and an **outer** skill that observes how it went and proposes a change to the inner one. + +| | Inner | Outer | +|---|---|---| +| What | `CLAUDE.md` + runbooks | `.claude/skills/improve-rules/SKILL.md` | +| Runs | every session | when evidence has accumulated | +| Reads | the rules | how the rules performed | +| Writes | code | a PR against `CLAUDE.md` | + +Sessions no longer edit CLAUDE.md. They append to **`docs/RULE-FEEDBACK.md`**, a ledger that costs one line and commits to nothing — low friction is what keeps signal flowing. The improver reads the accumulated pile later, with distance, and proposes **one focused edit** as a pull request. A human merges it; the next session inherits it. + +**One deliberate deviation from Warp's design.** Their loop is fed by human PR review comments — "what the agent suggested versus how humans responded". That signal does not exist here: 20 merged PRs carry 0 reviews and 1 comment between them, because §1 of this document describes a solo project with no second pair of eyes. The substitute is **the commit that had to clean up after the last one**. A `fix:` or `revert:` commit is literally what the agent proposed versus what reality required, with a diff attached and zero friction to capture, and roughly one commit in four is one. `.claude/hooks/rules-improver-check.sh` counts them and nudges once enough accumulate — event-driven rather than scheduled, so the pass fires when there is something to read rather than on a calendar. + +**Retirement is part of the loop, not an afterthought.** Accretion was the failure mode; a loop that only adds repeats it. The same evidence bar runs in reverse — a rule whose code path is gone, whose guard has never fired, or which nothing cites. Numbers are never reused: a rule that stops earning a constitutional slot is demoted to its runbook with the number intact, so the 203 files citing rules by number keep resolving. + --- ## 5. The four discipline anti-patterns diff --git a/docs/RULE-FEEDBACK.md b/docs/RULE-FEEDBACK.md new file mode 100644 index 00000000..566ad4f4 --- /dev/null +++ b/docs/RULE-FEEDBACK.md @@ -0,0 +1,75 @@ +# Rule feedback ledger + +Append-only. **Observations go here, not into CLAUDE.md.** + +That split is the point. Deciding what deserves a constitutional rule *while the +incident is still warm* is how CLAUDE.md reached 65 numbers with gaps and five +conflicting citations — every session, in the moment, judged its own bruise +worth a rule. This ledger costs nothing to write and commits to nothing. The +judgment happens later, with distance, in +`.claude/skills/improve-rules/SKILL.md`. + +So: write the entry even when you are not sure it matters. An entry that turns +out to be noise costs one line. The entry you skipped because it felt too small +is the one the next incident needed. + +## Format + +One entry per observation, newest at the bottom. + +``` +### YYYY-MM-DD — one-line summary + +- **Signal:** fix-commit | CI failure | user correction | tripwire fired | near miss +- **What happened:** what was proposed or shipped, and what reality required instead. +- **Principle:** the CLAUDE.md principle this is an instance of, or `NEW` if none fits. +- **Would a rule have caught it?** yes (which) / no / only if enforced differently (how). +``` + +The last field is the one that matters. "No" is a useful answer — it means the +rules were fine and something else failed. "Only if enforced differently" is the +most useful of all: it says the rule exists but lives in the wrong layer, which +is a change to *where* it is enforced, not to *what* it says +(`docs/FRAMEWORK.md` §3: skill / hook / tripwire). + +## Entries + +### 2026-08-26 — a drift test compared words instead of decisions, and passed while broken + +- **Signal:** user correction + fix-commit `412a314` +- **What happened:** `setup.sh` reimplements `guard.ts`'s database allowlist in + shell. The test asserting the two stayed in step checked that the shell file + *contained the same tokens*. It did — while using unanchored substring globs + against the guard's word-bounded regex, so `setup.sh` accepted `holt_samples` + and `demolition_prod`, ran migrate and seed:roles into them, and only then did + the seed refuse. The test passed the entire time this was broken. +- **Principle:** 4 (verify with an instrument that can disagree). +- **Would a rule have caught it?** Only if enforced differently. Rule 12 already + says tests exercise actual behaviour; nothing says that when one rule has two + implementations, the test must compare their *decisions* over a shared input + set rather than their text. Candidate strengthening of principle 4. + +### 2026-08-26 — permissive defaults invented a deployment fact + +- **Signal:** fix-commit `412a314` +- **What happened:** de-tenanting turned hardcoded literals into config with + permissive defaults so nothing broke unconfigured. `[A-Z]{2,}` as a store code + classified `SOFA1` and `MEGA1234` as returns and subtracted them from revenue; + `.+_` as a report-name prefix routed `Deleted_Customers.csv` into the customer + master. Both were invisible: no fixture used a non-store word. +- **Principle:** 7 (deployment facts are configuration). +- **Would a rule have caught it?** No — rules 61-63 covered moving the literal + out, and said nothing about what the default should be. Added to 63 as + "unconfigured must fail closed, not guess", cited to this incident. + +### 2026-08-26 — the guard against publishing identifiers published them + +- **Signal:** tripwire fired (own audit) +- **What happened:** `clientDataTripwire.test.ts` listed, in plaintext in a + public repo, the surnames, town names, ZIP and phone numbers it existed to + keep out — concentrating in one indexed file exactly what every other file had + been scrubbed of. +- **Principle:** 3 (a guard on one path is no guard) — obliquely; the real shape + is "the guard is inside the set it guards". +- **Would a rule have caught it?** No. Possibly too narrow to generalise; left + here rather than promoted, as a data point in case a second instance appears. diff --git a/docs/RULE-PROVENANCE.md b/docs/RULE-PROVENANCE.md index f8fc4f8e..7f1b5678 100644 --- a/docs/RULE-PROVENANCE.md +++ b/docs/RULE-PROVENANCE.md @@ -33,12 +33,21 @@ remember the original intent, this is the list to settle. | 47 | Revenue queries include RETURNED | The sibling repo's `pre-pr` skill numbers a different rule 47 ("don't relitigate user-empirical claims"); Holt's code-level citation won | | 49 | Self-heal as you go | `detailedSalesVendorPivot.test.ts` cites 49 for "vendor join required on pivot select" — kept as a `reporting.md` note | -**Rule 1 (KISS)** has no surviving Holt citation at all — it is carried by -convention from the sibling constitution. Delete it if you disagree; nothing -depends on it. +**Rule 1 (KISS)** — **retired 2026-08-26**, on the invitation this entry +already extended. It had no surviving Holt citation, no incident, and no +enforcement home in a skill, hook or tripwire; it was carried by convention +from the sibling constitution. It was also the one rule that contradicted the +doctrine it sat under — a preference, not something an incident taught. The +number is retired, not reused; see `## Retired` in `CLAUDE.md`. (The single +`git grep` hit for "rule 1" is `sameDayRewriteCleanup.ts:59`, which refers to a +rule numbered locally inside that file's own comment, not to this one.) **Rule 37** is evidenced only in Holt, with no sibling counterpart. It reads as a -specialization of rules 6 and 7. +specialization of rules 6 and 7 — and now sits beneath them, under principle 6 +("one home per fact") in `CLAUDE.md`, with its number and its ten citations +intact. Kept rather than merged away because those citations point at concrete +instances (`permissionCatalog`, the runner registry, `SALES_REVENUE_STATUSES`) +that a reader arriving from one of them needs to land on. ## Origins From 735d6b306c4289f4e88902208a3d90c2c574b467 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 1 Sep 2026 14:26:46 -0400 Subject: [PATCH 2/5] feat(pricing): one engine for wholesale price books, vendors declare themselves A manufacturer prints one book layout and puts several brands on it. Hooker Furnishings prints Hooker, Sam Moore and Bradington-Young on the same column-transposed grid, so the reusable unit is the LAYOUT and a vendor is a set of parameters against it. Adding a vendor is now a profile module plus a registry line, not another branch in the import route. DECLARATION REPLACES INFERENCE, which is the point of the change. partitionGrades() guessed a grade's material from the SHAPE of its code: a bare letter meant leather, digits meant fabric. Both Sam Moore and Hooker ladder FABRIC as B..J, so that guess files their entire fabric range as leather at the wrong tier -- and it still imports, and the numbers still look plausible, and nothing downstream holds the right answer to compare against. In the sibling repo the guess had already collected two patches living in the import route: FABRIC_LETTER_GRADE_VENDORS and COMBINED_LEATHER_VENDORS. This lands the seam before that debt arrives rather than porting it and removing it after. Every rung now declares its own kind, partitionGradesFor() uses the vendor's declaration verbatim, and the shape rules stay only as a labelled fallback for vendors that have no profile yet. Profiles are CODE, not config -- compiled, typed, reviewed. A config format expressive enough to describe a PDF layout is a programming language with a worse type checker, which is the road rule 62 exists to close. A deployment configures which vendors it carries and what markup it applies; a vendor fixes its grade ladder and label spellings, and those are identical for every dealer opening the same book. Three vendors, ~150 lines of profile between them, replacing three near-identical 200-line extractors. The engine keeps what actually repeats: the column walk, the money and dimension parsers, page splitting, and rendering through columnAwarePageRenderer (raw pdf-parse output is unparseable here -- "$500$540$470" arrives as one token). Lookup fails closed on an unknown vendor rather than falling back to a reader that is probably close. It also folds "sam-moore" and "Sam Moore" to one key: the upload form posts the id, the database holds the name, and a lookup matching only one would silently report "no profile" and drop back to guessing. The bug would have been a hyphen. Every price in the fixtures is invented; the layout is what the parser keys on. Both-directions verified: filing Sam Moore's letters as leather fails one case, and matching "LEATHER - NOVELTY" before "LEATHER - NOVELTY PREMIUM" prices the most expensive rung in the book one tier low and fails another. 3,489 unit tests pass; tsc, prettier, lint and markdownlint clean. --- app/__tests__/wholesaleVendorProfiles.test.ts | 197 +++++++++++++++ app/src/lib/pricing/wholesale/columnGrid.ts | 227 ++++++++++++++++++ app/src/lib/pricing/wholesale/profile.ts | 98 ++++++++ app/src/lib/pricing/wholesale/registry.ts | 50 ++++ .../wholesale/vendors/bradingtonYoung.ts | 82 +++++++ .../lib/pricing/wholesale/vendors/hooker.ts | 59 +++++ .../lib/pricing/wholesale/vendors/samMoore.ts | 48 ++++ .../api/pricing/import/wholesale-prices.ts | 63 ++++- app/src/pages/api/pricing/parse-pdf.ts | 22 ++ docs/domains/vendors-wholesale.md | 103 ++++++++ 10 files changed, 946 insertions(+), 3 deletions(-) create mode 100644 app/__tests__/wholesaleVendorProfiles.test.ts create mode 100644 app/src/lib/pricing/wholesale/columnGrid.ts create mode 100644 app/src/lib/pricing/wholesale/profile.ts create mode 100644 app/src/lib/pricing/wholesale/registry.ts create mode 100644 app/src/lib/pricing/wholesale/vendors/bradingtonYoung.ts create mode 100644 app/src/lib/pricing/wholesale/vendors/hooker.ts create mode 100644 app/src/lib/pricing/wholesale/vendors/samMoore.ts create mode 100644 docs/domains/vendors-wholesale.md diff --git a/app/__tests__/wholesaleVendorProfiles.test.ts b/app/__tests__/wholesaleVendorProfiles.test.ts new file mode 100644 index 00000000..79427ed2 --- /dev/null +++ b/app/__tests__/wholesaleVendorProfiles.test.ts @@ -0,0 +1,197 @@ +// /app/__tests__/wholesaleVendorProfiles.test.ts +// +// Three vendors read by one engine. Every fixture mirrors a real book's LAYOUT; +// every price in it is invented. This repo is public and a vendor's dealer costs +// are confidential -- the layout is what the parser keys on, and the layout is +// what these prove. +// +// What each case is really guarding is a SILENT failure. A price book that +// parses into the wrong tier still imports, still shows a number, and the number +// is plausible. Nothing downstream knows the right answer to compare against, so +// a misread survives until someone quotes a customer from it. + +import { parseRenderedGrid } from "@/lib/pricing/wholesale/columnGrid"; +import { + supportedWholesaleVendorIds, + wholesaleProfileFor, + WHOLESALE_VENDOR_PROFILES, +} from "@/lib/pricing/wholesale/registry"; + +const page = (n: number, body: string) => `<>\n${body}`; + +// Letter-laddered fabric book. Two styles side by side. +const SAM_MOORE = page( + 4, + [ + "STYLE NUMBER:\t1034\t1035", + "STYLE NAME:\tNova\tOrion", + "STYLE DESCRIPTION:\tSwivel Chair\tClub Chair", + 'COM\t54" PLAIN COM FABRIC Required (Yds.):\t6 1/2\t7', + "OVERALL Width:\t31 1/2\t33", + "OVERALL Depth:\t34\t35", + "OVERALL Height:\t30\t31", + "SEAT Height:\t20\t21", + "Grade: B\t$500\t$540", + "Grade: C\t$525\t$565", + "Grade: E and COM\t$575\t$615", + "Grade: J\t$700\t--", + "Premium: Prem 1\t$800\t$840", + "Premium: Prem 2\t$850\t$890", + ].join("\n"), +); + +// Two ladders on one frame: fabric letters plus a true leather grid. +const HOOKER = page( + 13, + [ + "STYLE NUMBER:\t1344-005", + "STYLE NAME:\tPercy", + "OVERALL Width:\t30 1/2", + "Fabric - Grade B:\t$400", + "Fabric - Grade E (COM):\t$450", + "Fabric - Grade J:\t$600", + "Leather - L1:\t$900", + "Leather - L4:\t$1,100", + "Leather - NV:\t$1,200", + "Leather - NVPR:\t$1,350", + ].join("\n"), +); + +// Leather-only, and a "/"-joined SKU family sharing one price column. +const BRADINGTON_YOUNG = page( + 7, + [ + "ITEM NUMBER:\t770/771/772/773/774\t880", + "SUFFIX:\t-87\t", + "STYLE NAME:\tMadison\tHalstead", + "OVERALL Width:\t32\t30", + "LEATHER - GRADE 1\t1,000\t900", + "LEATHER - GRADE 4\t1,300\tN/A", + "LEATHER - NOVELTY\t1,400\t1,250", + "LEATHER - NOVELTY PREMIUM\t1,600\t1,450", + ].join("\n"), +); + +const profile = (id: string) => { + const p = wholesaleProfileFor(id); + if (!p) throw new Error(`missing profile: ${id}`); + return p; +}; +const gradeMap = (prices: { grade: string; cost: number }[]) => + Object.fromEntries(prices.map((g) => [g.grade, g.cost])); + +describe("one engine, three books", () => { + it("reads a letter-laddered fabric book", () => { + const out = parseRenderedGrid(SAM_MOORE, profile("sam-moore")); + expect(out.map((p) => p.styleNumber)).toEqual(["1034", "1035"]); + + const nova = gradeMap(out[0].gradePrices); + expect(nova).toMatchObject({ B: 500, C: 525, E: 575, J: 700, "Prem 1": 800, "Prem 2": 850 }); + + // "--" is this book's no-price cell, and it must yield no rung rather than 0. + expect(gradeMap(out[1].gradePrices)).not.toHaveProperty("J"); + + // Fractions are real measurements: 31 1/2 is 31.5, not 31. + expect(out[0].overallWidth).toBe(31.5); + expect(out[0].yardagePlain).toBe(6.5); + }); + + // The failure this exists for: an importer that guesses material from the + // SHAPE of a code reads a bare letter as leather. Sam Moore and Hooker both + // ladder fabric as B..J, so under that guess their whole range files as + // leather at the wrong tier -- and still imports, and still looks fine. + it("declares letter grades as FABRIC, never inferring from the code's shape", () => { + for (const id of ["sam-moore", "hooker"]) { + const letters = profile(id).grades.filter((g) => /^[A-Z]$/.test(g.code)); + expect(letters.length).toBeGreaterThan(0); + expect(letters.every((g) => g.kind === "fabric")).toBe(true); + } + // And the leather-only book declares the opposite, with no shared heuristic. + expect(profile("bradington-young").grades.every((g) => g.kind === "leather")).toBe(true); + }); + + it("carries two ladders on one frame, each correctly kinded", () => { + const out = parseRenderedGrid(HOOKER, profile("hooker")); + expect(out).toHaveLength(1); + const g = gradeMap(out[0].gradePrices); + expect(g).toMatchObject({ B: 400, E: 450, J: 600, L1: 900, L4: 1100, NV: 1200, NVPR: 1350 }); + + // Leather belongs to this style, not a style of its own. + expect(profile("hooker").leatherPlacement).toBe("combined"); + }); + + it("prices COM at its own rung rather than as a second price", () => { + const out = parseRenderedGrid(SAM_MOORE, profile("sam-moore")); + const g = gradeMap(out[0].gradePrices); + // The book prints "Grade: E and COM" -- one rung, two names, one number. + expect(g.COM).toBe(g.E); + }); + + it("splits a slash-joined SKU family into one style per real SKU", () => { + const out = parseRenderedGrid(BRADINGTON_YOUNG, profile("bradington-young")); + expect(out.map((p) => p.styleNumber)).toEqual([ + "770-87", + "771-87", + "772-87", + "773-87", + "774-87", + "880", + ]); + // Every SKU in the family carries that column's prices. + for (const p of out.slice(0, 5)) { + expect(gradeMap(p.gradePrices)).toMatchObject({ L1: 1000, L4: 1300, NV: 1400, NVPR: 1600 }); + } + // "N/A" is this book's no-price cell -- a different token from Sam Moore's. + expect(gradeMap(out[5].gradePrices)).not.toHaveProperty("L4"); + }); + + // "LEATHER - NOVELTY PREMIUM" starts with "LEATHER - NOVELTY". Match the short + // label first and every premium row silently prices as plain novelty -- one + // tier low, on the most expensive rung in the book. + it("matches the longer grade label first", () => { + const p = profile("bradington-young"); + expect(p.gradeOfRow("LEATHER - NOVELTY PREMIUM")).toBe("NVPR"); + expect(p.gradeOfRow("LEATHER - NOVELTY")).toBe("NV"); + const g = gradeMap(parseRenderedGrid(BRADINGTON_YOUNG, p)[0].gradePrices); + expect(g.NVPR).toBe(1600); + expect(g.NV).toBe(1400); + }); + + it("skips a page that has a grid header but no prices", () => { + const schematic = page(3, ["ITEM NUMBER:\t900", "STYLE NAME:\tDiagram only"].join("\n")); + expect(parseRenderedGrid(schematic, profile("bradington-young"))).toEqual([]); + }); +}); + +describe("the registry", () => { + it("refuses an unknown vendor instead of guessing a reader", () => { + expect(wholesaleProfileFor("not-a-vendor")).toBeUndefined(); + expect(supportedWholesaleVendorIds()).toEqual(["bradington-young", "hooker", "sam-moore"]); + }); + + it("holds no duplicate ids, and every profile declares a kind for every grade", () => { + const ids = WHOLESALE_VENDOR_PROFILES.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + for (const p of WHOLESALE_VENDOR_PROFILES) { + expect(p.grades.length).toBeGreaterThan(0); + expect(p.grades.every((g) => g.kind === "fabric" || g.kind === "leather")).toBe(true); + // A ladder with a repeated code would silently overwrite a tier. + const codes = p.grades.map((g) => g.code); + expect(new Set(codes).size).toBe(codes.length); + } + }); +}); + +describe("vendor id and vendor name are the same vendor", () => { + // The upload form posts "sam-moore"; the database holds "Sam Moore". A lookup + // that only matched one of them would not error -- it would report "no + // profile", drop back to shape-guessing, and file the fabric ladder as + // leather. The bug would be a hyphen. + it("resolves a profile from either spelling", () => { + for (const spelling of ["sam-moore", "Sam Moore", "SAM MOORE", " sam_moore "]) { + expect(wholesaleProfileFor(spelling)?.id).toBe("sam-moore"); + } + expect(wholesaleProfileFor("Bradington-Young")?.id).toBe("bradington-young"); + expect(wholesaleProfileFor("Hooker Custom Upholstery")).toBeUndefined(); + }); +}); diff --git a/app/src/lib/pricing/wholesale/columnGrid.ts b/app/src/lib/pricing/wholesale/columnGrid.ts new file mode 100644 index 00000000..eb86bc70 --- /dev/null +++ b/app/src/lib/pricing/wholesale/columnGrid.ts @@ -0,0 +1,227 @@ +// /app/src/lib/pricing/wholesale/columnGrid.ts +// +// The column-transposed price-grid reader. One layout, many vendors. +// +// The layout: a page holds several STYLE COLUMNS side by side, and every row is +// labelled. Reading down a column gives one style; reading across a row gives +// one attribute for every style on the page. +// +// STYLE NUMBER: 1034 1035 1036 +// STYLE NAME: Nova Orion Pike +// Grade: B $804 $601 $712 +// Grade: C $839 $636 $747 +// +// Raw pdf-parse output is unusable here — glyphs butt together with no +// delimiter, so "$804$601$712" arrives as one token. `columnAwarePageRenderer` +// rebuilds the columns from glyph x-coordinates and inserts real tabs, which is +// why every vendor on this layout must render through it. +// +// Everything vendor-specific is in the profile. This file knows about tabs, +// columns and pages; it does not know any vendor's name. + +import { columnAwarePageRenderer } from "../pdfUtils"; +import type { ParsedWholesaleProduct } from "../wesleyHallParser"; +import type { WholesaleVendorProfile } from "./profile"; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const pdf = require("pdf-parse"); + +/** Money cell to a number, or null when the vendor's book says "no price". */ +export function parseMoney(raw: string, emptyCells: readonly string[]): number | null { + const trimmed = raw.trim(); + if (emptyCells.includes(trimmed)) return null; + const cleaned = trimmed.replace(/[$,\s]/g, ""); + if (!cleaned) return null; + const n = Number.parseFloat(cleaned); + return Number.isFinite(n) ? n : null; +} + +/** + * Dimension cell to a number, carrying the vulgar fractions these books print + * ("31 1/2" -> 31.5). Returns null rather than 0 for an absent value: 0 is a + * real width and would be indistinguishable from "not stated". + */ +export function parseDimension(raw: string, emptyCells: readonly string[]): number | null { + const s = raw.trim(); + if (!s || emptyCells.includes(s)) return null; + const m = /^(\d+)(?:\s+(\d+)\/(\d+))?/.exec(s); + if (!m) return null; + let val = Number.parseInt(m[1], 10); + if (m[2] && m[3]) { + const denom = Number.parseInt(m[3], 10); + if (denom !== 0) val += Number.parseInt(m[2], 10) / denom; + } + return Number.isFinite(val) ? val : null; +} + +/** Values after the label: everything past the first tab. */ +function cells(line: string): string[] { + return line + .split("\t") + .slice(1) + .map((c) => c.trim()); +} + +/** Values after a nested label: everything past the second tab. */ +function deepCells(line: string): string[] { + return line + .split("\t") + .slice(2) + .map((c) => c.trim()); +} + +/** Split a page into one chunk per style grid, starting at each header row. */ +function splitIntoGrids(text: string, header: RegExp): string[] { + const lines = text.split("\n"); + const starts: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (header.test(lines[i])) starts.push(i); + } + return starts.map((start, idx) => + lines.slice(start, idx + 1 < starts.length ? starts[idx + 1] : lines.length).join("\n"), + ); +} + +interface Collected { + rows: Record; + grades: Record; +} + +/** Walk a grid's lines into a row map and a grade map, both column-indexed. */ +function collectRows(lines: string[], profile: WholesaleVendorProfile): Collected { + const rows: Record = {}; + const grades: Record = {}; + + for (const line of lines) { + if (!line.includes("\t")) continue; + const label = line.split("\t")[0]; + + const grade = profile.gradeOfRow(label); + if (grade) { + grades[grade] = cells(line); + continue; + } + + // A deep row's label is nested, so match against the whole line. + const spec = profile.rows.find((r) => r.match.test(r.deep ? line : label)); + if (spec) rows[spec.key] = spec.deep ? deepCells(line) : cells(line); + } + + return { rows, grades }; +} + +/** One column's prices, in the vendor's declared ladder order. */ +function gradePricesFor( + column: number, + grades: Record, + profile: WholesaleVendorProfile, +): { grade: string; cost: number }[] { + const out: { grade: string; cost: number }[] = []; + for (const spec of profile.grades) { + const cost = parseMoney((grades[spec.code] || [])[column] || "", profile.emptyCells); + if (cost === null) continue; + out.push({ grade: spec.code, cost }); + // COM is not a separate price — it is the same rung, named for the customer + // supplying the material. + if (profile.comGrade && spec.code === profile.comGrade) { + out.push({ grade: "COM", cost }); + } + } + return out; +} + +function parseGrid( + chunk: string, + pageNumber: number, + profile: WholesaleVendorProfile, +): ParsedWholesaleProduct[] { + const lines = chunk.split("\n"); + const { rows, grades } = collectRows(lines, profile); + const headerCells = rows.style || []; + const products: ParsedWholesaleProduct[] = []; + + for (let col = 0; col < headerCells.length; col++) { + const cell = (headerCells[col] || "").trim(); + if (!cell || profile.emptyCells.includes(cell)) continue; + + const gradePrices = gradePricesFor(col, grades, profile); + // A column with no prices is a layout artifact, not a style. + if (gradePrices.length === 0) continue; + + const skus = profile.expandSkus ? profile.expandSkus(cell, lines, col) : [cell]; + const dim = (key: string) => parseDimension(rows[key]?.[col] || "", profile.emptyCells); + + for (const styleNumber of skus) { + products.push({ + styleNumber, + styleName: (rows.name?.[col] || "").trim(), + description: (rows.desc?.[col] || "").trim(), + leatherStyleNumber: null, + finish: null, + decorativeFinishSurcharge: null, + standardPillows: null, + gradeRiser: null, + standardSeat: null, + standardBack: null, + springDownBdbSurcharge: null, + comfortDownBdbSurcharge: null, + yardagePlain: dim("yardage"), + yardagePattern: null, + yardageRepeat: null, + gradePrices, + overallWidth: dim("width"), + overallDepth: dim("depth"), + overallHeight: dim("height"), + seatHeight: dim("seatHeight"), + seatDepth: dim("seatDepth"), + armHeight: dim("armHeight"), + pageNumber, + } as ParsedWholesaleProduct); + } + } + + return products; +} + +/** + * Parse already-rendered text (page markers + tabs) for one vendor. + * + * Exported separately from the PDF entry point so tests drive the parsing with a + * text fixture and never need a binary. That split is why these are testable at + * all — a fixture is readable in a diff, a PDF is not. + */ +export function parseRenderedGrid( + text: string, + profile: WholesaleVendorProfile, +): ParsedWholesaleProduct[] { + const products: ParsedWholesaleProduct[] = []; + const segments = text.split(/<>\n/); + + for (let i = 1; i < segments.length; i += 2) { + const pageNumber = Number.parseInt(segments[i], 10); + const pageText = segments[i + 1] || ""; + + if (profile.pageRequires && !profile.pageRequires.every((re) => re.test(pageText))) { + continue; + } + for (const chunk of splitIntoGrids(pageText, profile.gridHeader)) { + products.push(...parseGrid(chunk, pageNumber, profile)); + } + } + + return products; +} + +/** Render a price-book PDF through the column-aware renderer, then parse it. */ +export async function extractWholesaleGrid( + pdfBuffer: Buffer, + profile: WholesaleVendorProfile, +): Promise { + const data = await pdf(pdfBuffer, { + pagerender: (pageData: { pageNumber: number }) => + columnAwarePageRenderer(pageData).then( + (text: string) => `<>\n${text}`, + ), + }); + return parseRenderedGrid(data.text, profile); +} diff --git a/app/src/lib/pricing/wholesale/profile.ts b/app/src/lib/pricing/wholesale/profile.ts new file mode 100644 index 00000000..9b547c61 --- /dev/null +++ b/app/src/lib/pricing/wholesale/profile.ts @@ -0,0 +1,98 @@ +// /app/src/lib/pricing/wholesale/profile.ts +// +// What a wholesale price book needs to declare about itself. +// +// A manufacturer prints one book layout and puts several brands on it — Hooker +// Furnishings prints Hooker, Sam Moore and Bradington-Young on the same +// column-transposed grid. So the reusable unit is the LAYOUT, and a vendor is a +// set of parameters against it. Before this seam existed there were three +// near-identical extractor modules, each with its own copy of the money parser, +// the dimension parser and the grid walk. +// +// A profile is CODE, not config: it lives in `vendors/`, it is compiled, and it +// goes through review. That is deliberate. The alternative — a YAML schema +// expressive enough to describe a PDF layout — is a programming language with a +// worse type checker, and CLAUDE.md rule 62 exists because that road ends at an +// RCE surface wearing a config file's clothes. What a DEPLOYMENT configures is +// which vendors it carries and what markup it applies; what a VENDOR fixes is +// its grade ladder and its label spellings, and those are the same for every +// dealer who opens the same book. + +/** + * One rung of a vendor's price ladder. + * + * `kind` is DECLARED, never inferred. The import engine used to guess it from + * the shape of the code — a single letter meant leather, digits meant fabric — + * and then patch the guess with per-vendor allowlists when a vendor disagreed. + * Sam Moore and Hooker both ladder fabric as B..J, so under that guess their + * entire fabric range filed as leather. A vendor that states its own grades + * cannot be misread, and there is nothing left to patch. + */ +export interface GradeSpec { + /** Code as stored and displayed: "B", "L1", "NVPR", "Prem 1". */ + readonly code: string; + readonly kind: "fabric" | "leather"; + /** Display name, when the code alone reads badly ("NVPR"). */ + readonly displayName?: string; +} + +/** Maps a labelled row to the field it fills. */ +export interface RowSpec { + /** Field on the parsed product. */ + readonly key: string; + /** Matched against the row's label cell. */ + readonly match: RegExp; + /** + * Values start after a SECOND tab rather than the first. Some books nest a + * label inside a labelled row, e.g. + * `COM\t54" PLAIN COM FABRIC Required (Yds.):\t7\t...`. + */ + readonly deep?: boolean; +} + +export interface WholesaleVendorProfile { + /** Stable key. Also the value the upload UI posts as `vendor`. */ + readonly id: string; + /** Vendor's own name, for humans. */ + readonly label: string; + /** Ordered ladder, in book order. Order is meaningful: it is tier order. */ + readonly grades: readonly GradeSpec[]; + /** Starts a new style grid. Every line matching it begins a chunk. */ + readonly gridHeader: RegExp; + /** Grade code this row prices, or null when the row is not a price row. */ + gradeOfRow(label: string): string | null; + /** Non-price rows worth keeping. */ + readonly rows: readonly RowSpec[]; + /** Cell values meaning "no price at this grade": "--", "N/A". */ + readonly emptyCells: readonly string[]; + /** + * Grade whose price is also emitted as COM (Customer's Own Material). On the + * books that have it the row reads "Grade: E and COM", so E and COM are one + * price, not two. + */ + readonly comGrade?: string; + /** + * Every pattern must appear on a page for it to be treated as a price grid. + * Books that mix schematic and price pages need this; others leave it unset + * and every page with a grid header is tried. + */ + readonly pageRequires?: readonly RegExp[]; + /** + * Where this vendor's leather prices live. + * + * `combined` — leather is a higher tier of the SAME frame, so both ladders + * land on one style. + * `separate` — leather is its own style, keyed off the base style number. + * `none` — no leather ladder, or no fabric ladder to be separate from. + * + * Declared, because it used to be a one-vendor allowlist in the import route. + */ + readonly leatherPlacement: "combined" | "separate" | "none"; + /** + * Expand one price column into the SKUs it covers. Default is one SKU per + * column. Bradington-Young overrides it: a column headed "770/771/772/773/774" + * with a per-column suffix row "-87" covers five real SKUs that share a frame + * and a price. + */ + expandSkus?(itemCell: string, gridLines: readonly string[], column: number): string[]; +} diff --git a/app/src/lib/pricing/wholesale/registry.ts b/app/src/lib/pricing/wholesale/registry.ts new file mode 100644 index 00000000..04ce5cb6 --- /dev/null +++ b/app/src/lib/pricing/wholesale/registry.ts @@ -0,0 +1,50 @@ +// /app/src/lib/pricing/wholesale/registry.ts +// +// Every wholesale price book this build can read. +// +// Adding a vendor is a profile module plus a line here — not an edit to the +// import route. That is the whole point of the seam: the route asks the registry +// what it knows, so a new vendor cannot require touching code that already works +// for the others. +// +// Lookup FAILS CLOSED. An unknown id returns undefined and the caller refuses +// the upload, rather than falling back to a "probably close enough" reader. +// Guessing at a price book produces plausible numbers at the wrong tiers, which +// is worse than a rejection because nobody goes looking (CLAUDE.md rule 63). + +import type { WholesaleVendorProfile } from "./profile"; +import { bradingtonYoung } from "./vendors/bradingtonYoung"; +import { hooker } from "./vendors/hooker"; +import { samMoore } from "./vendors/samMoore"; + +export const WHOLESALE_VENDOR_PROFILES: readonly WholesaleVendorProfile[] = [ + bradingtonYoung, + hooker, + samMoore, +]; + +/** + * The same vendor arrives spelled two ways: the upload form posts the id + * ("sam-moore") while the database holds the name ("Sam Moore"). Fold both to + * one key, because a near-miss here does not error -- it silently reports "no + * profile" and drops the caller back onto the shape-guessing fallback, which is + * the misclassification this registry exists to prevent. + */ +function normalizeKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-"); +} + +const BY_KEY = new Map(WHOLESALE_VENDOR_PROFILES.map((p) => [normalizeKey(p.id), p])); + +/** The profile for a vendor id or name, or undefined when this build cannot read it. */ +export function wholesaleProfileFor(vendorIdOrName: string): WholesaleVendorProfile | undefined { + return BY_KEY.get(normalizeKey(vendorIdOrName)); +} + +/** Ids this build can read, for the upload UI and for error messages. */ +export function supportedWholesaleVendorIds(): string[] { + return WHOLESALE_VENDOR_PROFILES.map((p) => p.id).sort(); +} diff --git a/app/src/lib/pricing/wholesale/vendors/bradingtonYoung.ts b/app/src/lib/pricing/wholesale/vendors/bradingtonYoung.ts new file mode 100644 index 00000000..6516b1bf --- /dev/null +++ b/app/src/lib/pricing/wholesale/vendors/bradingtonYoung.ts @@ -0,0 +1,82 @@ +// /app/src/lib/pricing/wholesale/vendors/bradingtonYoung.ts +// +// Bradington-Young wholesale price books. Same layout again, leather only — +// six rungs, no fabric ladder. +// +// THE QUIRK this vendor adds: "/"-joined SKU families. One price column can +// cover several style lines that share a frame and a price, printed as an item +// cell like "770/771/772/773/774" with a per-column suffix row "-87". That is +// five real SKUs (770-87 ... 774-87), and each wants its own style downstream, +// so this profile expands the column. Columns holding a single style carry the +// whole SKU in the item cell and print no suffix row. + +import type { WholesaleVendorProfile } from "../profile"; + +/** + * Row labels, longest first. "LEATHER - NOVELTY PREMIUM" starts with + * "LEATHER - NOVELTY", so testing the short label first would classify every + * premium row as plain novelty — a silent one-tier price error. + */ +const GRADE_ROWS: { label: string; code: string; displayName: string }[] = [ + { label: "LEATHER - NOVELTY PREMIUM", code: "NVPR", displayName: "Novelty Premium" }, + { label: "LEATHER - NOVELTY", code: "NV", displayName: "Novelty" }, + { label: "LEATHER - GRADE 1", code: "L1", displayName: "Leather 1" }, + { label: "LEATHER - GRADE 2", code: "L2", displayName: "Leather 2" }, + { label: "LEATHER - GRADE 3", code: "L3", displayName: "Leather 3" }, + { label: "LEATHER - GRADE 4", code: "L4", displayName: "Leather 4" }, +]; + +/** Ladder order for output is book order, which is grade 1..4 then novelty. */ +const LADDER = ["L1", "L2", "L3", "L4", "NV", "NVPR"]; + +export const bradingtonYoung: WholesaleVendorProfile = { + id: "bradington-young", + label: "Bradington-Young", + grades: LADDER.map((code) => { + const row = GRADE_ROWS.find((r) => r.code === code)!; + return { code, kind: "leather" as const, displayName: row.displayName }; + }), + leatherPlacement: "none", + gridHeader: /ITEM NUMBER:\t/, + emptyCells: ["N/A", "--"], + // Schematic pages carry an item header but no prices; requiring a grade row + // as well keeps them out rather than emitting priceless styles. + pageRequires: [/^ITEM NUMBER:\t/m, /LEATHER - GRADE 1/], + + gradeOfRow(label) { + for (const row of GRADE_ROWS) { + if (label.startsWith(row.label)) return row.code; + } + return null; + }, + + rows: [ + { key: "style", match: /ITEM NUMBER:/ }, + { key: "name", match: /^STYLE NAME:/ }, + { key: "desc", match: /^DESCRIPTION:/ }, + { key: "width", match: /^OVERALL Width/ }, + { key: "depth", match: /^OVERALL Depth/ }, + { key: "height", match: /^OVERALL Height/ }, + { key: "seatDepth", match: /^SEAT Depth/ }, + { key: "seatHeight", match: /^SEAT Height/ }, + { key: "armHeight", match: /^ARM Height/ }, + ], + + expandSkus(itemCell, gridLines, column) { + const parts = itemCell + .split("/") + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length <= 1) return [itemCell.trim()]; + + // The suffix sits on the line after the item header, in this column. + const headerIdx = gridLines.findIndex((l) => /ITEM NUMBER:\t/.test(l)); + const suffixLine = headerIdx >= 0 ? gridLines[headerIdx + 1] : undefined; + const suffix = suffixLine ? (suffixLine.split("\t").slice(1)[column] || "").trim() : ""; + + // A family with no suffix is still a family; emit the bare numbers rather + // than dropping four of five SKUs. + if (!/^-\S+$/.test(suffix)) return parts; + return parts.map((p) => `${p}${suffix}`); + }, +}; diff --git a/app/src/lib/pricing/wholesale/vendors/hooker.ts b/app/src/lib/pricing/wholesale/vendors/hooker.ts new file mode 100644 index 00000000..5902e920 --- /dev/null +++ b/app/src/lib/pricing/wholesale/vendors/hooker.ts @@ -0,0 +1,59 @@ +// /app/src/lib/pricing/wholesale/vendors/hooker.ts +// +// Hooker Custom Upholstery wholesale price books. Same layout as Sam Moore, but +// this book carries TWO ladders on one style: a letter-coded fabric range and a +// true leather grid. +// +// That is why `leatherPlacement` exists. Leather here is a higher tier of the +// same frame, so both ladders belong to one style — as opposed to books that +// print leather as its own style keyed off the base number. It used to be a +// one-vendor allowlist in the import route. + +import type { WholesaleVendorProfile } from "../profile"; + +const FABRIC = ["B", "C", "D", "E", "F", "G", "H", "I", "J"]; +const LEATHER = ["L1", "L2", "L3", "L4", "NV", "NVPR"]; + +export const hooker: WholesaleVendorProfile = { + id: "hooker", + label: "Hooker Custom Upholstery", + grades: [ + ...FABRIC.map((code) => ({ code, kind: "fabric" as const })), + ...LEATHER.map((code) => ({ + code, + kind: "leather" as const, + displayName: + code === "NV" + ? "Novelty" + : code === "NVPR" + ? "Novelty Premium" + : `Leather ${code.slice(1)}`, + })), + ], + leatherPlacement: "combined", + gridHeader: /STYLE NUMBER:\t/, + emptyCells: ["--"], + comGrade: "E", + + gradeOfRow(label) { + // Fabric rows name the ladder explicitly ("Fabric - Grade E (COM)"). + const fabric = /^Fabric\s*-\s*Grade\s+([A-Z])\b/.exec(label); + if (fabric && FABRIC.includes(fabric[1])) return fabric[1]; + const leather = /^Leather\s*-\s*(?:Grade\s*)?(L[1-4]|NVPR|NV)\b/.exec(label); + if (leather && LEATHER.includes(leather[1])) return leather[1]; + return null; + }, + + rows: [ + { key: "style", match: /STYLE NUMBER:/ }, + { key: "name", match: /^STYLE NAME:/ }, + { key: "desc", match: /^STYLE DESCRIPTION:/ }, + { key: "yardage", match: /PLAIN COM FABRIC Required/, deep: true }, + { key: "width", match: /^OVERALL Width/ }, + { key: "depth", match: /^OVERALL Depth/ }, + { key: "height", match: /^OVERALL Height/ }, + { key: "seatDepth", match: /^SEAT Depth/ }, + { key: "seatHeight", match: /^SEAT Height/ }, + { key: "armHeight", match: /^ARM Height/ }, + ], +}; diff --git a/app/src/lib/pricing/wholesale/vendors/samMoore.ts b/app/src/lib/pricing/wholesale/vendors/samMoore.ts new file mode 100644 index 00000000..2156be70 --- /dev/null +++ b/app/src/lib/pricing/wholesale/vendors/samMoore.ts @@ -0,0 +1,48 @@ +// /app/src/lib/pricing/wholesale/vendors/samMoore.ts +// +// Sam Moore wholesale price books. Column-transposed grid, one style per column. +// +// Thirteen FABRIC tiers, letter-coded, and no leather ladder at all. The letters +// are the thing to be careful about: an import that guesses material from the +// shape of a code reads a bare letter as leather, which would file this vendor's +// entire range as leather at the wrong tier. Hence `kind` on every rung. + +import type { WholesaleVendorProfile } from "../profile"; + +/** In book order — the order here is the tier order downstream. */ +const GRADES = ["B", "C", "D", "E", "F", "G", "H", "I", "J", "Z", "ZZ", "Prem 1", "Prem 2"]; + +export const samMoore: WholesaleVendorProfile = { + id: "sam-moore", + label: "Sam Moore", + grades: GRADES.map((code) => ({ code, kind: "fabric" as const })), + leatherPlacement: "none", + gridHeader: /STYLE NUMBER:\t/, + emptyCells: ["--"], + // The book prints "Grade: E and COM" — one rung, two names. + comGrade: "E", + + gradeOfRow(label) { + // Premium tiers are labelled differently from the letter ladder. + const prem = /Premium:\s*(Prem [12])/.exec(label); + if (prem) return prem[1]; + // The renderer glitches labels ("FABRICGrade: I"), so match loosely and + // validate against the declared ladder rather than trusting the capture. + const grade = /Grade:\s*([A-Z]{1,2})\b/.exec(label); + if (grade && GRADES.includes(grade[1])) return grade[1]; + return null; + }, + + rows: [ + { key: "style", match: /STYLE NUMBER:/ }, + { key: "name", match: /^STYLE NAME:/ }, + { key: "desc", match: /^STYLE DESCRIPTION:/ }, + { key: "yardage", match: /PLAIN COM FABRIC Required/, deep: true }, + { key: "width", match: /^OVERALL Width/ }, + { key: "depth", match: /^OVERALL Depth/ }, + { key: "height", match: /^OVERALL Height/ }, + { key: "seatDepth", match: /^SEAT Depth/ }, + { key: "seatHeight", match: /^SEAT Height/ }, + { key: "armHeight", match: /^ARM Height/ }, + ], +}; diff --git a/app/src/pages/api/pricing/import/wholesale-prices.ts b/app/src/pages/api/pricing/import/wholesale-prices.ts index 3d283ae9..a36721bf 100644 --- a/app/src/pages/api/pricing/import/wholesale-prices.ts +++ b/app/src/pages/api/pricing/import/wholesale-prices.ts @@ -6,6 +6,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { prisma, TX_TIMEOUT } from "@/lib/prisma"; +import { wholesaleProfileFor } from "@/lib/pricing/wholesale/registry"; import type { SurchargeType, DimensionType } from "@prisma/client"; import { wholesaleImportSchema } from "@/lib/validation/schemas"; import { validateBody } from "@/lib/validation/validate"; @@ -535,8 +536,21 @@ function sortGrades(a: string, b: string): number { /** * Partition grade codes into fabric and leather groups. - * Fabric: COM + bare numeric grades (7, 8, 14, 15, ...) - * Leather: COL + single-letter grades (C, D, ...) + L-prefixed numeric (L7, L8, ...) + * + * PREFER THE VENDOR'S OWN DECLARATION. A vendor with a profile in + * `lib/pricing/wholesale/registry` states the material of every rung, and that + * is used verbatim -- see `partitionGradesFor` below. The shape-based rules here + * are the fallback for the older vendors that have no profile yet: + * + * Fabric: COM + bare numeric grades (7, 8, 14, 15, ...) + * Leather: COL + single-letter grades (C, D, ...) + L-prefixed numeric (L7, ...) + * + * Those rules are a GUESS, and it is worth being honest about how it fails: a + * bare letter reads as leather here, but Sam Moore and Hooker both ladder FABRIC + * as B..J. Guessing on either of those books files an entire fabric range as + * leather at the wrong tier -- and it still imports, and the numbers still look + * plausible. Nothing downstream has the right answer to compare against. That is + * why new vendors declare instead of being inferred. */ function partitionGrades(grades: string[]): { fabricGrades: string[]; @@ -562,6 +576,49 @@ function partitionGrades(grades: string[]): { return { fabricGrades, leatherGrades }; } +/** + * Fabric/leather split for one vendor's grades. + * + * A registered vendor's profile is authoritative: each rung declares its own + * material, so there is nothing to infer and no per-vendor special case to + * maintain. Anything the profile does not mention still falls through to the + * shape rules, which keeps a book that prints an unexpected rung importable + * rather than dropping it silently. + */ +function partitionGradesFor( + vendorId: string, + grades: string[], +): { fabricGrades: string[]; leatherGrades: string[] } { + const profile = wholesaleProfileFor(vendorId); + if (!profile) return partitionGrades(grades); + + const declared = new Map( + profile.grades.map((g) => [g.code, g.kind] as const), + ); + // COM and COL are the customer's own material and leather on every book. + declared.set("COM", "fabric"); + declared.set("COL", "leather"); + + const fabricGrades: string[] = []; + const leatherGrades: string[] = []; + const undeclared: string[] = []; + + for (const g of grades) { + const kind = declared.get(g); + if (kind === "fabric") fabricGrades.push(g); + else if (kind === "leather") leatherGrades.push(g); + else undeclared.push(g); + } + + if (undeclared.length > 0) { + const fallback = partitionGrades(undeclared); + fabricGrades.push(...fallback.fabricGrades); + leatherGrades.push(...fallback.leatherGrades); + } + + return { fabricGrades, leatherGrades }; +} + /** * Generate a human-readable tier name from a grade code. */ @@ -668,7 +725,7 @@ export default requirePermission( } } const sortedGrades = Array.from(allGrades).sort(sortGrades); - const { fabricGrades, leatherGrades } = partitionGrades(sortedGrades); + const { fabricGrades, leatherGrades } = partitionGradesFor(vendor.name, sortedGrades); // Seed vendor-level option groups and options before the transaction // so DEC finish options exist when the product loop needs them. diff --git a/app/src/pages/api/pricing/parse-pdf.ts b/app/src/pages/api/pricing/parse-pdf.ts index 6639918c..ccec3bec 100644 --- a/app/src/pages/api/pricing/parse-pdf.ts +++ b/app/src/pages/api/pricing/parse-pdf.ts @@ -12,6 +12,8 @@ import { extractWholesalePricing, extractFabricCatalog } from "@/lib/pricing/pdf import { parseWholesaleRows, parseFoundationsRows } from "@/lib/pricing/wesleyHallParser"; import { parseSEPricing } from "@/lib/pricing/seParser"; import { extractCrLaineWholesale, extractCrLaineSimplicity } from "@/lib/pricing/crLaineExtractor"; +import { extractWholesaleGrid } from "@/lib/pricing/wholesale/columnGrid"; +import { wholesaleProfileFor } from "@/lib/pricing/wholesale/registry"; import { extractGatCreekPricing } from "@/lib/pricing/gatCreekExtractor"; import { parseKingsleyBatePriceList } from "@/lib/pricing/kingsleyBateParser"; import { getErrorMessage } from "@/lib/toastError"; @@ -45,6 +47,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { let diagnostics: any[] = []; let parseSummary: any = null; + // Registry first. Vendors on the shared column-transposed grid declare + // themselves in `lib/pricing/wholesale/vendors/`, so adding one is a profile + // module and a registry line -- not another branch here. The chain below is + // the pre-registry vendors, each of which has its own distinct layout. + const gridProfile = wholesaleProfileFor(vendor); + if (gridProfile && type === "wholesale") { + parsedData = await extractWholesaleGrid(buffer, gridProfile); + + fs.unlinkSync(uploadedFile.filepath); + + return res.status(200).json({ + success: true, + vendor, + type: "wholesale", + count: parsedData.length, + data: parsedData, + meta: { vendorLabel: gridProfile.label }, + }); + } + if (vendor === "brown-jordan") { const { parseBrownJordanPriceList } = await import("@/lib/pricing/brownJordanParser"); const bjData = await parseBrownJordanPriceList(buffer); diff --git a/docs/domains/vendors-wholesale.md b/docs/domains/vendors-wholesale.md new file mode 100644 index 00000000..1b4eb673 --- /dev/null +++ b/docs/domains/vendors-wholesale.md @@ -0,0 +1,103 @@ +# Wholesale price books + +How a vendor's price book becomes styles and grade prices, and what it takes to +add one. + +## The shape of the problem + +A furniture manufacturer prints one book layout and puts several brands on it. +Hooker Furnishings prints Hooker, Sam Moore and Bradington-Young on the same +column-transposed grid, so the reusable unit is the **layout**, not the vendor. +A vendor is a set of parameters against a layout. + +Before this seam there were three near-identical extractor modules, each with +its own copy of the money parser, the dimension parser and the grid walk — and +the differences between them were four lines each. + +## The layout + +A page holds several style **columns** side by side. Every row is labelled. +Reading down a column gives one style; reading across a row gives one attribute +for every style on the page. + +``` +STYLE NUMBER: 1034 1035 1036 +STYLE NAME: Nova Orion Pike +Grade: B $500 $540 $470 +Grade: C $525 $565 $495 +``` + +Raw `pdf-parse` output is unusable here: glyphs butt together with no delimiter, +so `$500$540$470` arrives as one token. `columnAwarePageRenderer` rebuilds the +columns from glyph x-coordinates and inserts real tabs. Every vendor on this +layout must render through it — that is not a preference, the text is otherwise +unparseable. + +## Adding a vendor + +Two steps. Neither touches the import route. + +1. Write `src/lib/pricing/wholesale/vendors/.ts` exporting a + `WholesaleVendorProfile`. +2. Add it to `WHOLESALE_VENDOR_PROFILES` in `wholesale/registry.ts`. + +A profile is **code, not config** — it is compiled, typed and reviewed. That is +deliberate: a config format expressive enough to describe a PDF layout is a +programming language with a worse type checker, which is the road rule 62 +exists to close. What a *deployment* configures is which vendors it carries and +what markup it applies. What a *vendor* fixes is its grade ladder and its label +spellings, and those are identical for every dealer who opens the same book. + +### What a profile declares + +| Field | Why it varies | +|---|---| +| `grades` | The ladder, in book order, each rung declaring `fabric` or `leather` | +| `gridHeader` | The row that starts a style grid (`STYLE NUMBER:` / `ITEM NUMBER:`) | +| `gradeOfRow` | How a price row names its grade — spellings differ per book | +| `rows` | Which labelled rows fill which product field | +| `emptyCells` | This book's "no price" token — `--` on one, `N/A` on another | +| `comGrade` | The rung that is also COM, where the book prints "Grade: E and COM" | +| `pageRequires` | Patterns a page must carry to be a price grid, not a schematic | +| `leatherPlacement` | Whether leather is a tier of the same frame or its own style | +| `expandSkus` | For books where one price column covers several SKUs | + +## Grades are declared, never inferred + +This is the part worth understanding before adding a vendor. + +The import used to guess a grade's material from the **shape of its code**: a +bare letter meant leather, digits meant fabric, `L`-prefixed meant leather. Then +vendors disagreed, and the guess got patched with allowlists — +`FABRIC_LETTER_GRADE_VENDORS`, `COMBINED_LEATHER_VENDORS` — living in the import +route itself. + +Both Sam Moore and Hooker ladder **fabric** as B..J. Under the guess, their +entire fabric range files as leather at the wrong tier. And it still imports. +The numbers still look plausible. Nothing downstream holds the right answer to +compare against, so the error survives until someone quotes a customer from it. + +So every rung declares its own `kind`, `partitionGradesFor()` uses the vendor's +declaration verbatim, and there is nothing left to patch. Vendors with no +profile still fall through to the shape rules, which are kept and labelled as a +guess. + +## Lookup fails closed + +`wholesaleProfileFor()` returns `undefined` for an unknown vendor and the caller +refuses the upload. It does not fall back to a reader that is "probably close". +Guessing at a price book produces plausible numbers at wrong tiers — worse than +a rejection, because nobody goes looking (rule 63). + +It also folds `sam-moore`, `Sam Moore` and `SAM_MOORE` to one key. The upload +form posts the id and the database holds the name; a lookup matching only one +would silently report "no profile" and drop back to guessing. The bug would have +been a hyphen. + +## Testing + +Drive `parseRenderedGrid()` with a text fixture, never a PDF — a fixture is +readable in a diff. **Every price in a fixture is invented.** This repo is +public and a vendor's dealer costs are confidential; the layout is what the +parser keys on and the layout is what a fixture needs to reproduce. +→ `app/__tests__/wholesaleVendorProfiles.test.ts` From 7e0cb9f8f33186a9ffce0cb976b00b96677c0253 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Thu, 3 Sep 2026 14:25:14 -0400 Subject: [PATCH 3/5] feat(pricing): read per-style options from the book, wholesale only Two additions to the wholesale seam, both measured against the real July-2026 Sam Moore book (175 styles, matching the sibling repo's verified anchor; Nova 1034 B $804 / E $909 = COM / J $1,084 / Prem 2 $1,224). OPTIONS ARE PER STYLE, AND THE BOOK ALREADY SAYS WHICH FRAMES TAKE WHICH. A book prints ~20 priced option rows beside each frame and they genuinely differ -- an ottoman's contrast welt is not a sofa's, and some frames cannot take one at all. Seeding one flat price per vendor asserts every frame carries every option at one price, which is wrong on most rows and offers a designer things the vendor will not build. Applicability needs no curating. It is in the data: row absent, or cell "--" -> not applicable (the book prints "NOTE: -- means Not Available" on every page) cell "N/C" or "Standard" -> applicable, free, standard cell a number -> applicable, that surcharge Measured on the real book: 162 of 175 styles carry options, Contrast Welt on 125, Inside Back Cushion on 80, Top Arm or Panel on 4. Those are the book's numbers, not a guess. Three parsing defects found by running it rather than reading it: - The renderer glues a section heading onto the next row's label, so "CONTRAST TOP ARM or PANEL" arrives as "STANDARD TRIM & AVAILABLE OPTIONSCONTRAST TOP ARM or PANEL". An anchored pattern missed 66 of 86 pages, and the miss read as the option being rare. Profiles now declare their glued headings and the engine strips them. - The book writes "Standard" where other rows write "N/C". Reading only "N/C" dropped Welt Only from all 97 styles that carry it. - One option is printed two ways across the book -- 58 pages say "CONTRAST INSIDE BACK CUSHION", 28 say "CONTRAST INSIDE BACK". Matching the longer spelling alone lost a $30 upcharge on 80 frames. Each looked like an option being rare rather than a parser missing it, which is why none would surface without extracting and counting. WHOLESALE ONLY. An extractor reads the vendor's COST. Books also print MSRP, MAP and suggested retail; none is imported as truth, because retail is a business decision and not ours to make for someone else's shop -- two dealers carrying the same book price differently and both are right. A deployment configures its own markup, discount, and whether it honours MAP; many now just set a flat markup and never use anchor pricing at all. `msrpRow` captures a printed MSRP as REFERENCE only, for seeding demo data and sanity-checking a markup, never as the retail. 3,494 unit tests pass; tsc and prettier clean. --- app/__tests__/wholesaleVendorProfiles.test.ts | 69 +++++++++++ app/src/lib/pricing/wholesale/columnGrid.ts | 80 +++++++++++- app/src/lib/pricing/wholesale/profile.ts | 90 ++++++++++++++ .../lib/pricing/wholesale/vendors/samMoore.ts | 116 ++++++++++++++++++ 4 files changed, 351 insertions(+), 4 deletions(-) diff --git a/app/__tests__/wholesaleVendorProfiles.test.ts b/app/__tests__/wholesaleVendorProfiles.test.ts index 79427ed2..7f3cc6ad 100644 --- a/app/__tests__/wholesaleVendorProfiles.test.ts +++ b/app/__tests__/wholesaleVendorProfiles.test.ts @@ -195,3 +195,72 @@ describe("vendor id and vendor name are the same vendor", () => { expect(wholesaleProfileFor("Hooker Custom Upholstery")).toBeUndefined(); }); }); + +describe("per-style options come from the book, not a seed table", () => { + // Options are per STYLE. A frame that cannot take one must not appear to take + // it for free -- that is the difference between an accurate import and a + // plausible one, and it decides what a designer can actually order. + const OPTIONS = page( + 9, + [ + "STYLE NUMBER:\t1034\t1035\t1036", + "STYLE NAME:\tNova\tOrion\tPike", + "Grade: B\t$500\t$540\t$470", + "CONTRAST WELT (B - ZZ fabric)\t$20\t--\tN/C", + "CONTRAST INSIDE BACK (B - ZZ fabric)\t$30\t$30\t--", + "WELT ONLY (delete Nails):\tStandard\tStandard\tStandard", + "STANDARD TRIM & AVAILABLE OPTIONSCONTRAST TOP ARM or PANEL (B - ZZ fabric)\t$45\t--\t--", + ].join("\n"), + ); + const opts = (n: string) => { + const p = parseRenderedGrid(OPTIONS, profile("sam-moore")).find((x) => x.styleNumber === n); + return Object.fromEntries( + ( + ( + p as unknown as { + styleOptions: { optionName: string; surcharge: number; isStandard: boolean }[]; + } + ).styleOptions ?? [] + ).map((o) => [o.optionName, o]), + ); + }; + + it("prices an option only on the frames the book prices it on", () => { + expect(opts("1034")["Contrast Welt"].surcharge).toBe(20); + // "--" is the book's own Not Available token, printed on every page. + expect(opts("1035")["Contrast Welt"]).toBeUndefined(); + expect(opts("1036")["Inside Back Cushion"]).toBeUndefined(); + }); + + it("distinguishes included-at-no-charge from not-available", () => { + // N/C means the frame HAS it, free. Absent means it cannot have it at all. + // Collapsing the two would offer a designer an option the vendor will not build. + const free = opts("1036")["Contrast Welt"]; + expect(free).toMatchObject({ surcharge: 0, isStandard: true }); + expect(opts("1036")["Contrast Welt"]).not.toBeUndefined(); + }); + + // The book writes "Standard" where other rows write "N/C". Reading only "N/C" + // dropped this option from all 97 styles that carry it, and the loss looked + // like the option being rare rather than a token we did not know. + it("reads every word this book uses for included", () => { + for (const s of ["1034", "1035", "1036"]) { + expect(opts(s)["Welt Only (delete nails)"]).toMatchObject({ surcharge: 0, isStandard: true }); + } + }); + + // The renderer glues a section heading onto the next label. An anchored + // pattern then misses exactly the rows following a heading, and the miss reads + // as the option being rare -- it cost 66 of 86 pages before this was stripped. + it("still matches a row whose label carries a glued section heading", () => { + expect(opts("1034")["Top Arm or Panel"].surcharge).toBe(45); + }); + + // One option, two spellings across the book's pages. + it("matches both spellings the book uses for one option", () => { + const p = profile("sam-moore"); + const spec = (p.options ?? []).find((o) => o.optionName === "Inside Back Cushion")!; + expect(spec.match.test("CONTRAST INSIDE BACK CUSHION (B - ZZ fabric)")).toBe(true); + expect(spec.match.test("CONTRAST INSIDE BACK (B - ZZ fabric)")).toBe(true); + }); +}); diff --git a/app/src/lib/pricing/wholesale/columnGrid.ts b/app/src/lib/pricing/wholesale/columnGrid.ts index eb86bc70..e018b141 100644 --- a/app/src/lib/pricing/wholesale/columnGrid.ts +++ b/app/src/lib/pricing/wholesale/columnGrid.ts @@ -21,7 +21,7 @@ import { columnAwarePageRenderer } from "../pdfUtils"; import type { ParsedWholesaleProduct } from "../wesleyHallParser"; -import type { WholesaleVendorProfile } from "./profile"; +import type { StyleOption, WholesaleVendorProfile } from "./profile"; /* eslint-disable @typescript-eslint/no-require-imports */ const pdf = require("pdf-parse"); @@ -82,19 +82,38 @@ function splitIntoGrids(text: string, header: RegExp): string[] { ); } +/** + * Strip a section heading the renderer glued onto a row label. + * + * Done before every label match, so profiles can anchor their patterns at ^ and + * still catch the glued rows. Without it an anchored pattern misses exactly the + * rows that follow a heading, and the miss reads as the option being rare rather + * than as a parsing failure. + */ +function deglue(label: string, profile: WholesaleVendorProfile): string { + let out = label.trim(); + for (const h of profile.gluedSectionHeaders ?? []) { + if (out.startsWith(h)) out = out.slice(h.length).trim(); + } + return out; +} + interface Collected { rows: Record; grades: Record; + /** Option row values, keyed by the option's index in the profile. */ + optionCells: Record; } /** Walk a grid's lines into a row map and a grade map, both column-indexed. */ function collectRows(lines: string[], profile: WholesaleVendorProfile): Collected { const rows: Record = {}; const grades: Record = {}; + const optionCells: Record = {}; for (const line of lines) { if (!line.includes("\t")) continue; - const label = line.split("\t")[0]; + const label = deglue(line.split("\t")[0], profile); const grade = profile.gradeOfRow(label); if (grade) { @@ -102,12 +121,18 @@ function collectRows(lines: string[], profile: WholesaleVendorProfile): Collecte continue; } + const optIdx = (profile.options ?? []).findIndex((o) => o.match.test(label)); + if (optIdx >= 0) { + optionCells[optIdx] = cells(line); + continue; + } + // A deep row's label is nested, so match against the whole line. const spec = profile.rows.find((r) => r.match.test(r.deep ? line : label)); if (spec) rows[spec.key] = spec.deep ? deepCells(line) : cells(line); } - return { rows, grades }; + return { rows, grades, optionCells }; } /** One column's prices, in the vendor's declared ladder order. */ @@ -130,13 +155,58 @@ function gradePricesFor( return out; } +/** + * One column's options, as the book states them for that frame. + * + * Three outcomes per option, and the difference between the last two is the + * whole point: a style that CANNOT take an option must not appear to take it + * for free. + * + * row absent, or cell is a no-price token -> not applicable, omitted + * cell is an included token ("N/C") -> applicable, surcharge 0, standard + * cell is a number -> applicable, that surcharge + */ +function optionsFor( + column: number, + optionCells: Record, + profile: WholesaleVendorProfile, +): StyleOption[] { + const out: StyleOption[] = []; + (profile.options ?? []).forEach((spec, i) => { + const raw = (optionCells[i] || [])[column]; + if (raw === undefined) return; + const cell = raw.trim(); + if (!cell || profile.emptyCells.includes(cell)) return; + + const included = (spec.includedTokens ?? []).some( + (t) => + cell.toUpperCase() === t.toUpperCase() || + cell.toUpperCase().endsWith(`- ${t.toUpperCase()}`), + ); + const cost = included ? 0 : parseMoney(cell, profile.emptyCells); + // A cell that is neither a price nor an included-token is prose the book put + // in a price column ("see front of pricelist"). Not a zero -- unknown. + if (cost === null) return; + + out.push({ + groupName: spec.groupName, + optionName: spec.optionName, + surcharge: cost, + surchargeType: spec.surchargeType ?? "FLAT", + isStandard: included, + sortOrder: spec.sortOrder ?? i, + }); + }); + return out; +} + function parseGrid( chunk: string, pageNumber: number, profile: WholesaleVendorProfile, ): ParsedWholesaleProduct[] { const lines = chunk.split("\n"); - const { rows, grades } = collectRows(lines, profile); + const { rows, grades, optionCells } = collectRows(lines, profile); const headerCells = rows.style || []; const products: ParsedWholesaleProduct[] = []; @@ -149,6 +219,7 @@ function parseGrid( if (gradePrices.length === 0) continue; const skus = profile.expandSkus ? profile.expandSkus(cell, lines, col) : [cell]; + const styleOptions = optionsFor(col, optionCells, profile); const dim = (key: string) => parseDimension(rows[key]?.[col] || "", profile.emptyCells); for (const styleNumber of skus) { @@ -176,6 +247,7 @@ function parseGrid( seatDepth: dim("seatDepth"), armHeight: dim("armHeight"), pageNumber, + styleOptions, } as ParsedWholesaleProduct); } } diff --git a/app/src/lib/pricing/wholesale/profile.ts b/app/src/lib/pricing/wholesale/profile.ts index 9b547c61..6cf3b559 100644 --- a/app/src/lib/pricing/wholesale/profile.ts +++ b/app/src/lib/pricing/wholesale/profile.ts @@ -18,6 +18,21 @@ // its grade ladder and its label spellings, and those are the same for every // dealer who opens the same book. +/** + * WHOLESALE ONLY. An extractor reads the vendor's COST and nothing else. + * + * Books print more than cost -- MSRP, MAP, custom-finish columns, suggested + * retail. None of it is imported as truth, because retail is not a vendor fact, + * it is a business decision, and it is not ours to make for someone else's shop. + * Two dealers carrying the same book price differently and both are right. + * + * So the deployment configures its own markup, its own discount, whether it + * honours MAP and at what number. Baking a markup, or storing a printed MSRP as + * the retail, hard-codes one shop's policy into the product -- exactly the class + * CLAUDE.md principle 7 exists to stop. Where a book prints several price + * columns, the profile names the wholesale one and ignores the rest. + */ + /** * One rung of a vendor's price ladder. * @@ -50,6 +65,51 @@ export interface RowSpec { readonly deep?: boolean; } +/** + * An option row the book prints per style. + * + * Options are PER STYLE, not per vendor. A book prints a column of option prices + * beside each frame, and they genuinely differ: an ottoman's contrast welt is not + * a sofa's, and a side table has neither. Seeding one flat price per vendor -- + * the thing this replaces -- asserts every frame carries every option at one + * price, which is wrong on almost every row. + * + * APPLICABILITY IS IN THE BOOK, and does not need curating. A style does not + * carry an option when the row is absent from its page, or when its cell holds + * the book's not-available token. Sam Moore prints the rule on every page: + * "NOTE: -- means Not Available", and its CONTRAST INSIDE BACK CUSHION row + * appears on 58 of 86 pages. Read it and the applicability comes free. + */ +export interface OptionSpec { + /** Matched against the row's label cell. */ + readonly match: RegExp; + /** Option group as shown to the user ("Cushion Upgrade"). */ + readonly groupName: string; + /** Option name within the group ("Down Plush"). */ + readonly optionName: string; + /** How the cell's number applies. Defaults to FLAT. */ + readonly surchargeType?: "FLAT" | "PERCENTAGE" | "PER_UNIT"; + /** Ordering within the group. */ + readonly sortOrder?: number; + /** + * Cell values meaning "included, no upcharge" -- distinct from unavailable. + * Sam Moore writes "N/C"; some books write "STD" or "INCL". A style with one + * of these HAS the option at zero, which is not the same as not having it. + */ + readonly includedTokens?: readonly string[]; +} + +/** One style's resolved option, ready for StyleOptionOverride. */ +export interface StyleOption { + groupName: string; + optionName: string; + surcharge: number; + surchargeType: "FLAT" | "PERCENTAGE" | "PER_UNIT"; + /** True when the book shows it included rather than priced. */ + isStandard: boolean; + sortOrder: number; +} + export interface WholesaleVendorProfile { /** Stable key. Also the value the upload UI posts as `vendor`. */ readonly id: string; @@ -95,4 +155,34 @@ export interface WholesaleVendorProfile { * and a price. */ expandSkus?(itemCell: string, gridLines: readonly string[], column: number): string[]; + /** + * A printed MSRP/retail row, where the book has one. + * + * Captured as REFERENCE, never as the deployment's retail. It seeds demo and + * test data with numbers that look like a real book, and it lets a deployment + * sanity-check its own markup against what the manufacturer suggests. It is + * not what a customer is charged: that stays the shop's configured markup, + * discount and MAP policy. Storing it as retail would decide another + * business's pricing for them. + */ + readonly msrpRow?: RowSpec; + /** + * Section headings the renderer glues onto the next row's label. + * + * The column-aware renderer sometimes concatenates a section heading with the + * label beneath it, so "CONTRAST TOP ARM or PANEL" arrives as + * "STANDARD TRIM & AVAILABLE OPTIONSCONTRAST TOP ARM or PANEL". A pattern + * anchored at ^ then silently misses that row -- Sam Moore's Top Arm option + * matched 4 styles instead of 66 for exactly this reason, and the shortfall + * looks like the option genuinely being rare. + * + * Declared per vendor because the headings are the book's, and stripped before + * any label match. + */ + readonly gluedSectionHeaders?: readonly string[]; + /** + * Option rows this book prints per style. Omitted entirely for books that + * price no options -- an empty list is honest, a guessed one is not. + */ + readonly options?: readonly OptionSpec[]; } diff --git a/app/src/lib/pricing/wholesale/vendors/samMoore.ts b/app/src/lib/pricing/wholesale/vendors/samMoore.ts index 2156be70..1da1ce8d 100644 --- a/app/src/lib/pricing/wholesale/vendors/samMoore.ts +++ b/app/src/lib/pricing/wholesale/vendors/samMoore.ts @@ -33,6 +33,122 @@ export const samMoore: WholesaleVendorProfile = { return null; }, + // Headings the renderer glues onto the following row's label. + gluedSectionHeaders: [ + "STANDARD TRIM & AVAILABLE OPTIONS", + "DIMENSIONS / WEIGHTS", + "SPECIFICATIONS", + "FABRIC", + ], + + // Row labels are quoted from the July-2026 book. Anchored with ^ on purpose: + // "BIAS WELT" is a substring of "CONTRAST PILLOW BIAS WELT", and "CONTRAST + // WELT" of "CONTRAST PILLOW WELT". An unanchored match would price a sofa's + // welt from the pillow row -- the same substring trap as Bradington-Young's + // NOVELTY / NOVELTY PREMIUM, and just as silent. + // + // "N/C" means included at no charge; the book's "--" means Not Available and + // is handled by `emptyCells`, so a frame that cannot take an option simply + // does not carry it. + options: [ + { + match: /^SM - Down Plush - Upcharge/, + groupName: "Cushion Upgrade", + optionName: "Down Plush", + sortOrder: 0, + }, + { + match: /^SM - Spring Down Luxe - Upcharge/, + groupName: "Cushion Upgrade", + optionName: "Spring Down Luxe", + sortOrder: 1, + }, + { + match: /^CONTRAST WELT\b/, + groupName: "Contrast Options", + optionName: "Contrast Welt", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 0, + }, + { + match: /^BIAS WELT\b/, + groupName: "Contrast Options", + optionName: "Bias Welt", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 1, + }, + { + match: /^CONTRAST OUT ARM & BACK \(B - ZZ/, + groupName: "Contrast Options", + optionName: "Out Arm & Back", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 2, + }, + { + match: /^CONTRAST OUT ARM & BACK \(Prem/, + groupName: "Contrast Options", + optionName: "Out Arm & Back (Premium fabric)", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 3, + }, + { + match: /^CONTRAST SEAT CUSHION\b/, + groupName: "Contrast Options", + optionName: "Seat Cushion", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 4, + }, + { + // Two spellings across the book: 58 pages say CUSHION, 28 do not. + match: /^CONTRAST INSIDE BACK( CUSHION)?\b/, + groupName: "Contrast Options", + optionName: "Inside Back Cushion", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 5, + }, + { + match: /^CONTRAST TOP ARM or PANEL\b/i, + groupName: "Contrast Options", + optionName: "Top Arm or Panel", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 6, + }, + { + match: /^CONTRAST PILLOW FABRIC\b/, + groupName: "Throw Pillows", + optionName: "Contrast Pillow Fabric", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 0, + }, + { + match: /^CONTRAST PILLOW WELT\b/, + groupName: "Throw Pillows", + optionName: "Contrast Pillow Welt", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 1, + }, + { + match: /^CONTRAST PILLOW BIAS WELT\b/, + groupName: "Throw Pillows", + optionName: "Contrast Pillow Bias Welt", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 2, + }, + { + match: /^WELT ONLY \(delete Nails\)/, + groupName: "Trim & Finishing", + optionName: "Welt Only (delete nails)", + includedTokens: ["N/C", "Standard", "STD"], + sortOrder: 0, + }, + { + match: /^CARTON OPTION Up-charge/, + groupName: "Packaging", + optionName: "Carton", + sortOrder: 0, + }, + ], + rows: [ { key: "style", match: /STYLE NUMBER:/ }, { key: "name", match: /^STYLE NAME:/ }, From 1c6a411fc66e48a8e3de8a18e5b9d1705be73852 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Thu, 3 Sep 2026 14:28:58 -0400 Subject: [PATCH 4/5] feat(pricing): import writes the book's per-style options, not a vendor default Closes the chain: book row -> engine -> StyleOptionOverride. The extractor read the option rows in the previous commit; the import still took its options from VENDOR_SURCHARGE_MAP, a vendor-level flat default that asserts one price for every frame. Where the book states a per-frame price the book now wins, and the legacy map stays as the fallback for vendors whose extractor does not yet read option rows. Dogfooded end to end against the real July-2026 Sam Moore book, into a scratch database, then queried as the configurator would. 40 styles produced 275 overrides, averaging 8.1 options per frame and ranging 1 to 10 -- and the two frames below are why a flat vendor default is not good enough: 1034 "Nova" Contrast Welt $20 Bias Welt $40 Seat Cushion $30 Out Arm & Back $60 Out Arm & Back (Premium) $300 Inside Back Cushion $30 Welt Only included Carton $25 1061 "Urban" Contrast Welt $15 Bias Welt $30 Seat Cushion $20 Welt Only included Carton $20 -- no Out Arm & Back, no Inside Back Cushion at all Same vendor, same book, four different prices and three options one frame cannot take. A vendor-level seed gets 1061 wrong on every row, and offers a designer two upcharges the vendor will not build. The $300 premium-fabric row is one the sibling repo's verify-sam-moore skill has carried as a known gap ("not seeded"); it now imports. 3,494 unit tests pass; tsc and prettier clean. --- .../api/pricing/import/wholesale-prices.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/app/src/pages/api/pricing/import/wholesale-prices.ts b/app/src/pages/api/pricing/import/wholesale-prices.ts index a36721bf..937160ec 100644 --- a/app/src/pages/api/pricing/import/wholesale-prices.ts +++ b/app/src/pages/api/pricing/import/wholesale-prices.ts @@ -271,6 +271,16 @@ interface SurchargeMapping { sortOrder: number; } +/** One option the book priced for a specific style (see wholesale/profile.ts). */ +interface BookStyleOption { + groupName: string; + optionName: string; + surcharge: number; + surchargeType: "FLAT" | "PERCENTAGE" | "PER_UNIT"; + isStandard: boolean; + sortOrder: number; +} + const VENDOR_SURCHARGE_MAP: Record = { "wesley hall": [ { @@ -1117,7 +1127,53 @@ export default requirePermission( // standard" (per Wesley Hall front-of-book convention). We create // an override with surcharge=null so the configurator falls back // to VendorOption.defaultSurcharge. - const surchargeMap = VENDOR_SURCHARGE_MAP[resolveVendorKey(vendor.name)] || []; + // Options the BOOK priced for THIS style, read off its own page by the + // wholesale engine. Already filtered: a frame the book marks "--" + // (Not Available) carries no entry, so it gets no row and the + // configurator will not offer a designer something the vendor will + // not build. + // + // Preferred over VENDOR_SURCHARGE_MAP below, which asserts one flat + // price for every frame of a vendor. Where the book states a + // per-frame price, the book wins. + const bookOptions = (p as unknown as { styleOptions?: BookStyleOption[] }).styleOptions; + for (const opt of bookOptions ?? []) { + const group = await tx.vendorOptionGroup.upsert({ + where: { vendorId_name: { vendorId, name: opt.groupName } }, + create: { vendorId, name: opt.groupName }, + update: {}, + }); + const option = await tx.vendorOption.upsert({ + where: { groupId_name: { groupId: group.id, name: opt.optionName } }, + create: { + groupId: group.id, + name: opt.optionName, + surchargeType: opt.surchargeType, + defaultSurcharge: 0, + sortOrder: opt.sortOrder, + }, + update: {}, + }); + await tx.styleOptionOverride.upsert({ + where: { + vendorStyleId_optionId: { vendorStyleId: vendorStyle.id, optionId: option.id }, + }, + create: { + vendorStyleId: vendorStyle.id, + optionId: option.id, + surcharge: opt.surcharge, + isAvailable: true, + isStandard: opt.isStandard, + }, + update: { surcharge: opt.surcharge, isAvailable: true, isStandard: opt.isStandard }, + }); + } + + // Legacy path: vendors whose extractor does not yet read the book's + // option rows fall back to the hand-maintained vendor-level map. + const surchargeMap = bookOptions?.length + ? [] + : VENDOR_SURCHARGE_MAP[resolveVendorKey(vendor.name)] || []; for (const mapping of surchargeMap) { const surchargeValue = p[mapping.productField] as number | null | undefined; const isStandard = mapping.isStandardField From 4e6ba4e1b99cfe54901de5b36ca6ff74a411ef07 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Fri, 4 Sep 2026 09:49:05 -0400 Subject: [PATCH 5/5] fix(pricing): adopt the sibling repo's option semantics -- theirs is better Checked what furniture-configurator had done before going further, and it had independently landed the same feature two days ago (#606). Its version is better than mine on the same book, so this takes theirs rather than defending mine. Measured side by side on the real July-2026 Sam Moore book: Carton Packaging fc 175 mine 125 Welt Only fc 109 mine 97 Nail Trim 1/2" / 3/4" fc 46/22 mine not read at all Power Mechanism fc 6 mine not read at all Their regexes are simply more permissive where the book varies, and they read three option rows I never looked for. Adopted their row definitions verbatim. Three semantic improvements taken with it: - "Standard" and "N/C" are NOT the same and were collapsed here. Standard means the frame ships with it; N/C means a choice that costs nothing. Saying the first when the book means the second tells a customer something is fitted when it is merely free to add. - "--" (Not Available) is now kept as an explicit row with isAvailable=false rather than dropped, so the configurator can grey an option with a reason instead of leaving a designer wondering whether it was missed. - Contrast options carry requiresTextInput: applied in a different fabric from the body, so the order is not orderable until the designer names it. After adopting all of it, holt's generic engine reproduces fc's hand-written vendor extractor EXACTLY -- 175 styles, and all 14 options matching on both the available and not-available counts, 0 differences. The abstraction costs nothing in accuracy, which is the result worth having before porting more vendors onto it. Also correcting a claim from the previous commit: the glued-section-header stripping did NOT recover 66 pages of Contrast Top Arm. Both implementations report 4 of 175, because those cells are "--" regardless. The deglue is still right to have, but it changed no counts and the earlier message overstated it. 3,493 unit tests pass; tsc, prettier and lint clean. --- app/__tests__/wholesaleVendorProfiles.test.ts | 71 ++++++----- app/src/lib/pricing/wholesale/columnGrid.ts | 65 +++++++--- app/src/lib/pricing/wholesale/profile.ts | 24 +++- .../lib/pricing/wholesale/vendors/samMoore.ts | 120 +++++++++--------- .../api/pricing/import/wholesale-prices.ts | 13 +- 5 files changed, 179 insertions(+), 114 deletions(-) diff --git a/app/__tests__/wholesaleVendorProfiles.test.ts b/app/__tests__/wholesaleVendorProfiles.test.ts index 7f3cc6ad..b80a1115 100644 --- a/app/__tests__/wholesaleVendorProfiles.test.ts +++ b/app/__tests__/wholesaleVendorProfiles.test.ts @@ -197,9 +197,9 @@ describe("vendor id and vendor name are the same vendor", () => { }); describe("per-style options come from the book, not a seed table", () => { - // Options are per STYLE. A frame that cannot take one must not appear to take - // it for free -- that is the difference between an accurate import and a - // plausible one, and it decides what a designer can actually order. + // Options are per STYLE, and the book's own legend says which frames take + // which. Getting this wrong offers a designer an upcharge the vendor will not + // build, or hides one they would have sold. const OPTIONS = page( 9, [ @@ -209,7 +209,6 @@ describe("per-style options come from the book, not a seed table", () => { "CONTRAST WELT (B - ZZ fabric)\t$20\t--\tN/C", "CONTRAST INSIDE BACK (B - ZZ fabric)\t$30\t$30\t--", "WELT ONLY (delete Nails):\tStandard\tStandard\tStandard", - "STANDARD TRIM & AVAILABLE OPTIONSCONTRAST TOP ARM or PANEL (B - ZZ fabric)\t$45\t--\t--", ].join("\n"), ); const opts = (n: string) => { @@ -218,7 +217,13 @@ describe("per-style options come from the book, not a seed table", () => { ( ( p as unknown as { - styleOptions: { optionName: string; surcharge: number; isStandard: boolean }[]; + styleOptions: { + optionName: string; + surcharge: number; + isStandard: boolean; + isAvailable: boolean; + requiresTextInput: boolean; + }[]; } ).styleOptions ?? [] ).map((o) => [o.optionName, o]), @@ -226,41 +231,37 @@ describe("per-style options come from the book, not a seed table", () => { }; it("prices an option only on the frames the book prices it on", () => { - expect(opts("1034")["Contrast Welt"].surcharge).toBe(20); - // "--" is the book's own Not Available token, printed on every page. - expect(opts("1035")["Contrast Welt"]).toBeUndefined(); - expect(opts("1036")["Inside Back Cushion"]).toBeUndefined(); + expect(opts("1034")["Contrast Welt"]).toMatchObject({ surcharge: 20, isAvailable: true }); }); - it("distinguishes included-at-no-charge from not-available", () => { - // N/C means the frame HAS it, free. Absent means it cannot have it at all. - // Collapsing the two would offer a designer an option the vendor will not build. - const free = opts("1036")["Contrast Welt"]; - expect(free).toMatchObject({ surcharge: 0, isStandard: true }); - expect(opts("1036")["Contrast Welt"]).not.toBeUndefined(); + // "--" is the book's Not Available token, printed on every page. Kept as a row + // rather than dropped, so the UI can grey it out with a reason instead of + // leaving a designer wondering whether it was simply missed. + it("keeps a not-available option as an explicit no, not a silence", () => { + expect(opts("1035")["Contrast Welt"]).toMatchObject({ isAvailable: false }); + expect(opts("1036")["Contrast Inside Back"]).toMatchObject({ isAvailable: false }); }); - // The book writes "Standard" where other rows write "N/C". Reading only "N/C" - // dropped this option from all 97 styles that carry it, and the loss looked - // like the option being rare rather than a token we did not know. - it("reads every word this book uses for included", () => { - for (const s of ["1034", "1035", "1036"]) { - expect(opts(s)["Welt Only (delete nails)"]).toMatchObject({ surcharge: 0, isStandard: true }); - } - }); - - // The renderer glues a section heading onto the next label. An anchored - // pattern then misses exactly the rows following a heading, and the miss reads - // as the option being rare -- it cost 66 of 86 pages before this was stripped. - it("still matches a row whose label carries a glued section heading", () => { - expect(opts("1034")["Top Arm or Panel"].surcharge).toBe(45); + // The book prints two different zero-cost words and they mean different + // things. Collapsing them tells a customer something is fitted when it is + // merely free to add. + it("separates standard equipment from a free choice", () => { + expect(opts("1036")["Contrast Welt"]).toMatchObject({ + surcharge: 0, + isStandard: false, + isAvailable: true, + }); + expect(opts("1034")["Welt Only (delete nails)"]).toMatchObject({ + surcharge: 0, + isStandard: true, + isAvailable: true, + }); }); - // One option, two spellings across the book's pages. - it("matches both spellings the book uses for one option", () => { - const p = profile("sam-moore"); - const spec = (p.options ?? []).find((o) => o.optionName === "Inside Back Cushion")!; - expect(spec.match.test("CONTRAST INSIDE BACK CUSHION (B - ZZ fabric)")).toBe(true); - expect(spec.match.test("CONTRAST INSIDE BACK (B - ZZ fabric)")).toBe(true); + // A contrast option is applied in a different fabric from the body, so the + // order is not orderable until the designer names it. + it("flags the options that need the designer to name a fabric", () => { + expect(opts("1034")["Contrast Welt"].requiresTextInput).toBe(true); + expect(opts("1034")["Welt Only (delete nails)"].requiresTextInput).toBe(false); }); }); diff --git a/app/src/lib/pricing/wholesale/columnGrid.ts b/app/src/lib/pricing/wholesale/columnGrid.ts index e018b141..a6575a65 100644 --- a/app/src/lib/pricing/wholesale/columnGrid.ts +++ b/app/src/lib/pricing/wholesale/columnGrid.ts @@ -166,6 +166,47 @@ function gradePricesFor( * cell is an included token ("N/C") -> applicable, surcharge 0, standard * cell is a number -> applicable, that surcharge */ +/** + * Classify one option cell against the book's own legend. + * + * Four outcomes, and the distinctions matter to a designer: + * + * blank / row absent -> the book says nothing; no row at all + * "--" (Not Available)-> explicitly not offered on this frame; kept as a row + * with isAvailable=false so the UI can grey it with a + * reason rather than leave it silently missing + * "Standard" -> the frame SHIPS with it + * "N/C" -> a choice that costs nothing -- NOT the same as + * standard, and saying so would tell a customer + * something is fitted when it is merely free to add + * a number -> that surcharge + */ +function classifyCell( + raw: string | undefined, + spec: { includedTokens?: readonly string[] }, + emptyCells: readonly string[], +): { surcharge: number; isStandard: boolean; isAvailable: boolean } | null { + if (raw === undefined) return null; + const cell = raw.trim(); + if (!cell) return null; + if (emptyCells.includes(cell)) return { surcharge: 0, isStandard: false, isAvailable: false }; + if (/^(Standard|Included|STD)$/i.test(cell)) { + return { surcharge: 0, isStandard: true, isAvailable: true }; + } + if (/^(N\/C|No Charge)$/i.test(cell)) { + return { surcharge: 0, isStandard: false, isAvailable: true }; + } + for (const t of spec.includedTokens ?? []) { + if (cell.toUpperCase() === t.toUpperCase()) { + return { surcharge: 0, isStandard: false, isAvailable: true }; + } + } + const money = parseMoney(cell, emptyCells); + if (money === null) return null; // prose in a price column ("see front of list") + return { surcharge: money, isStandard: false, isAvailable: true }; +} + +/** One column's options, as the book states them for that frame. */ function optionsFor( column: number, optionCells: Record, @@ -173,27 +214,17 @@ function optionsFor( ): StyleOption[] { const out: StyleOption[] = []; (profile.options ?? []).forEach((spec, i) => { - const raw = (optionCells[i] || [])[column]; - if (raw === undefined) return; - const cell = raw.trim(); - if (!cell || profile.emptyCells.includes(cell)) return; - - const included = (spec.includedTokens ?? []).some( - (t) => - cell.toUpperCase() === t.toUpperCase() || - cell.toUpperCase().endsWith(`- ${t.toUpperCase()}`), - ); - const cost = included ? 0 : parseMoney(cell, profile.emptyCells); - // A cell that is neither a price nor an included-token is prose the book put - // in a price column ("see front of pricelist"). Not a zero -- unknown. - if (cost === null) return; - + const c = classifyCell((optionCells[i] || [])[column], spec, profile.emptyCells); + if (!c) return; out.push({ groupName: spec.groupName, optionName: spec.optionName, - surcharge: cost, + surcharge: c.surcharge, surchargeType: spec.surchargeType ?? "FLAT", - isStandard: included, + isStandard: c.isStandard, + isAvailable: c.isAvailable, + requiresTextInput: spec.requiresTextInput ?? false, + textInputLabel: spec.textInputLabel ?? null, sortOrder: spec.sortOrder ?? i, }); }); diff --git a/app/src/lib/pricing/wholesale/profile.ts b/app/src/lib/pricing/wholesale/profile.ts index 6cf3b559..091135e1 100644 --- a/app/src/lib/pricing/wholesale/profile.ts +++ b/app/src/lib/pricing/wholesale/profile.ts @@ -97,6 +97,12 @@ export interface OptionSpec { * of these HAS the option at zero, which is not the same as not having it. */ readonly includedTokens?: readonly string[]; + /** + * The option is applied in a different fabric from the body, so the order is + * not complete until the designer names it. + */ + readonly requiresTextInput?: boolean; + readonly textInputLabel?: string; } /** One style's resolved option, ready for StyleOptionOverride. */ @@ -105,8 +111,24 @@ export interface StyleOption { optionName: string; surcharge: number; surchargeType: "FLAT" | "PERCENTAGE" | "PER_UNIT"; - /** True when the book shows it included rather than priced. */ + /** + * The book prints two different zero-cost words and they are NOT the same + * thing. "Standard"/"Included" means the frame ships with it. "N/C"/"No + * Charge" means it is a choice the designer makes that happens to cost + * nothing. Collapsing them tells a customer something is fitted when it is + * merely free to add. + */ isStandard: boolean; + /** + * False when the book explicitly marks the option not available on this frame + * ("--"), as opposed to simply not mentioning it. Kept rather than dropped so + * the configurator can show it greyed with a reason instead of leaving a + * designer wondering whether it was missed. + */ + isAvailable: boolean; + /** The option needs the designer to name something (which contrast fabric). */ + requiresTextInput: boolean; + textInputLabel: string | null; sortOrder: number; } diff --git a/app/src/lib/pricing/wholesale/vendors/samMoore.ts b/app/src/lib/pricing/wholesale/vendors/samMoore.ts index 1da1ce8d..c09e6742 100644 --- a/app/src/lib/pricing/wholesale/vendors/samMoore.ts +++ b/app/src/lib/pricing/wholesale/vendors/samMoore.ts @@ -52,101 +52,103 @@ export const samMoore: WholesaleVendorProfile = { // does not carry it. options: [ { - match: /^SM - Down Plush - Upcharge/, + match: /^SM - Down Plush - Upcharge/i, groupName: "Cushion Upgrade", - optionName: "Down Plush", + optionName: "SM Down Plush", sortOrder: 0, }, { - match: /^SM - Spring Down Luxe - Upcharge/, + match: /^SM - Spring Down Luxe - Upcharge/i, groupName: "Cushion Upgrade", - optionName: "Spring Down Luxe", + optionName: "SM Spring Down Luxe", sortOrder: 1, }, { - match: /^CONTRAST WELT\b/, + match: /^POWER MECHANISM UPCHARGE/i, + groupName: "Mechanism", + optionName: "Power Mechanism", + sortOrder: 0, + }, + { + match: /^WELT ONLY\s*\(delete Nails\)/i, + groupName: "Trim & Finishing", + optionName: "Welt Only (delete nails)", + sortOrder: 0, + }, + { + match: /^CARTON OPTION Up-charge/i, + groupName: "Trim & Finishing", + optionName: "Carton Packaging", + sortOrder: 1, + }, + { + match: /^1\/2"\s*-\s*7\s*\(Include Alpha/i, + groupName: "Nail Trim", + optionName: 'Nail Trim 1/2"', + sortOrder: 0, + }, + { + match: /^3\/4"\s*-\s*6\s*\(Include Alpha/i, + groupName: "Nail Trim", + optionName: 'Nail Trim 3/4"', + sortOrder: 1, + }, + { + match: /^CONTRAST WELT\s+\(B - ZZ/i, groupName: "Contrast Options", optionName: "Contrast Welt", - includedTokens: ["N/C", "Standard", "STD"], + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 0, }, { - match: /^BIAS WELT\b/, + match: /^BIAS WELT\s+\(B - ZZ/i, groupName: "Contrast Options", - optionName: "Bias Welt", - includedTokens: ["N/C", "Standard", "STD"], + optionName: "Contrast Bias Welt", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 1, }, { - match: /^CONTRAST OUT ARM & BACK \(B - ZZ/, + match: /^CONTRAST INSIDE BACK\s+\(B - ZZ/i, groupName: "Contrast Options", - optionName: "Out Arm & Back", - includedTokens: ["N/C", "Standard", "STD"], + optionName: "Contrast Inside Back", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 2, }, { - match: /^CONTRAST OUT ARM & BACK \(Prem/, + match: /^CONTRAST OUT ARM & BACK\s+\(B - ZZ/i, groupName: "Contrast Options", - optionName: "Out Arm & Back (Premium fabric)", - includedTokens: ["N/C", "Standard", "STD"], + optionName: "Contrast Out Arm & Back", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 3, }, { - match: /^CONTRAST SEAT CUSHION\b/, + match: /^CONTRAST OUT ARM & BACK\s+\(Prem/i, groupName: "Contrast Options", - optionName: "Seat Cushion", - includedTokens: ["N/C", "Standard", "STD"], + optionName: "Contrast Out Arm & Back (Premium fabric)", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 4, }, { - // Two spellings across the book: 58 pages say CUSHION, 28 do not. - match: /^CONTRAST INSIDE BACK( CUSHION)?\b/, + match: /^CONTRAST SEAT CUSHION\s+\(B - ZZ/i, groupName: "Contrast Options", - optionName: "Inside Back Cushion", - includedTokens: ["N/C", "Standard", "STD"], + optionName: "Contrast Seat Cushion", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 5, }, { - match: /^CONTRAST TOP ARM or PANEL\b/i, + match: /^CONTRAST TOP ARM or PANEL\s+\(B - ZZ/i, groupName: "Contrast Options", - optionName: "Top Arm or Panel", - includedTokens: ["N/C", "Standard", "STD"], + optionName: "Contrast Top Arm or Panel", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", sortOrder: 6, }, - { - match: /^CONTRAST PILLOW FABRIC\b/, - groupName: "Throw Pillows", - optionName: "Contrast Pillow Fabric", - includedTokens: ["N/C", "Standard", "STD"], - sortOrder: 0, - }, - { - match: /^CONTRAST PILLOW WELT\b/, - groupName: "Throw Pillows", - optionName: "Contrast Pillow Welt", - includedTokens: ["N/C", "Standard", "STD"], - sortOrder: 1, - }, - { - match: /^CONTRAST PILLOW BIAS WELT\b/, - groupName: "Throw Pillows", - optionName: "Contrast Pillow Bias Welt", - includedTokens: ["N/C", "Standard", "STD"], - sortOrder: 2, - }, - { - match: /^WELT ONLY \(delete Nails\)/, - groupName: "Trim & Finishing", - optionName: "Welt Only (delete nails)", - includedTokens: ["N/C", "Standard", "STD"], - sortOrder: 0, - }, - { - match: /^CARTON OPTION Up-charge/, - groupName: "Packaging", - optionName: "Carton", - sortOrder: 0, - }, ], rows: [ diff --git a/app/src/pages/api/pricing/import/wholesale-prices.ts b/app/src/pages/api/pricing/import/wholesale-prices.ts index 937160ec..51cf1ffa 100644 --- a/app/src/pages/api/pricing/import/wholesale-prices.ts +++ b/app/src/pages/api/pricing/import/wholesale-prices.ts @@ -278,6 +278,9 @@ interface BookStyleOption { surcharge: number; surchargeType: "FLAT" | "PERCENTAGE" | "PER_UNIT"; isStandard: boolean; + isAvailable: boolean; + requiresTextInput: boolean; + textInputLabel: string | null; sortOrder: number; } @@ -1151,6 +1154,8 @@ export default requirePermission( surchargeType: opt.surchargeType, defaultSurcharge: 0, sortOrder: opt.sortOrder, + requiresTextInput: opt.requiresTextInput, + textInputLabel: opt.textInputLabel, }, update: {}, }); @@ -1162,10 +1167,14 @@ export default requirePermission( vendorStyleId: vendorStyle.id, optionId: option.id, surcharge: opt.surcharge, - isAvailable: true, + isAvailable: opt.isAvailable, + isStandard: opt.isStandard, + }, + update: { + surcharge: opt.surcharge, + isAvailable: opt.isAvailable, isStandard: opt.isStandard, }, - update: { surcharge: opt.surcharge, isAvailable: true, isStandard: opt.isStandard }, }); }