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/app/__tests__/wholesaleVendorProfiles.test.ts b/app/__tests__/wholesaleVendorProfiles.test.ts new file mode 100644 index 00000000..b80a1115 --- /dev/null +++ b/app/__tests__/wholesaleVendorProfiles.test.ts @@ -0,0 +1,267 @@ +// /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(); + }); +}); + +describe("per-style options come from the book, not a seed table", () => { + // 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, + [ + "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", + ].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; + isAvailable: boolean; + requiresTextInput: 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"]).toMatchObject({ surcharge: 20, isAvailable: true }); + }); + + // "--" 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 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, + }); + }); + + // 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 new file mode 100644 index 00000000..a6575a65 --- /dev/null +++ b/app/src/lib/pricing/wholesale/columnGrid.ts @@ -0,0 +1,330 @@ +// /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 { StyleOption, 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"), + ); +} + +/** + * 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 = deglue(line.split("\t")[0], profile); + + const grade = profile.gradeOfRow(label); + if (grade) { + grades[grade] = cells(line); + 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, optionCells }; +} + +/** 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; +} + +/** + * 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 + */ +/** + * 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, + profile: WholesaleVendorProfile, +): StyleOption[] { + const out: StyleOption[] = []; + (profile.options ?? []).forEach((spec, i) => { + const c = classifyCell((optionCells[i] || [])[column], spec, profile.emptyCells); + if (!c) return; + out.push({ + groupName: spec.groupName, + optionName: spec.optionName, + surcharge: c.surcharge, + surchargeType: spec.surchargeType ?? "FLAT", + isStandard: c.isStandard, + isAvailable: c.isAvailable, + requiresTextInput: spec.requiresTextInput ?? false, + textInputLabel: spec.textInputLabel ?? null, + sortOrder: spec.sortOrder ?? i, + }); + }); + return out; +} + +function parseGrid( + chunk: string, + pageNumber: number, + profile: WholesaleVendorProfile, +): ParsedWholesaleProduct[] { + const lines = chunk.split("\n"); + const { rows, grades, optionCells } = 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 styleOptions = optionsFor(col, optionCells, profile); + 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, + styleOptions, + } 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..091135e1 --- /dev/null +++ b/app/src/lib/pricing/wholesale/profile.ts @@ -0,0 +1,210 @@ +// /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. + +/** + * 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. + * + * `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; +} + +/** + * 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[]; + /** + * 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. */ +export interface StyleOption { + groupName: string; + optionName: string; + surcharge: number; + surchargeType: "FLAT" | "PERCENTAGE" | "PER_UNIT"; + /** + * 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; +} + +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[]; + /** + * 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/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..c09e6742 --- /dev/null +++ b/app/src/lib/pricing/wholesale/vendors/samMoore.ts @@ -0,0 +1,166 @@ +// /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; + }, + + // 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/i, + groupName: "Cushion Upgrade", + optionName: "SM Down Plush", + sortOrder: 0, + }, + { + match: /^SM - Spring Down Luxe - Upcharge/i, + groupName: "Cushion Upgrade", + optionName: "SM Spring Down Luxe", + sortOrder: 1, + }, + { + 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", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 0, + }, + { + match: /^BIAS WELT\s+\(B - ZZ/i, + groupName: "Contrast Options", + optionName: "Contrast Bias Welt", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 1, + }, + { + match: /^CONTRAST INSIDE BACK\s+\(B - ZZ/i, + groupName: "Contrast Options", + optionName: "Contrast Inside Back", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 2, + }, + { + match: /^CONTRAST OUT ARM & BACK\s+\(B - ZZ/i, + groupName: "Contrast Options", + optionName: "Contrast Out Arm & Back", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 3, + }, + { + match: /^CONTRAST OUT ARM & BACK\s+\(Prem/i, + groupName: "Contrast Options", + optionName: "Contrast Out Arm & Back (Premium fabric)", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 4, + }, + { + match: /^CONTRAST SEAT CUSHION\s+\(B - ZZ/i, + groupName: "Contrast Options", + optionName: "Contrast Seat Cushion", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 5, + }, + { + match: /^CONTRAST TOP ARM or PANEL\s+\(B - ZZ/i, + groupName: "Contrast Options", + optionName: "Contrast Top Arm or Panel", + requiresTextInput: true, + textInputLabel: "Specify contrast fabric (grade B-ZZ)", + sortOrder: 6, + }, + ], + + 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..51cf1ffa 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"; @@ -270,6 +271,19 @@ 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; + isAvailable: boolean; + requiresTextInput: boolean; + textInputLabel: string | null; + sortOrder: number; +} + const VENDOR_SURCHARGE_MAP: Record = { "wesley hall": [ { @@ -535,8 +549,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 +589,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 +738,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. @@ -1060,7 +1130,59 @@ 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, + requiresTextInput: opt.requiresTextInput, + textInputLabel: opt.textInputLabel, + }, + 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: opt.isAvailable, + isStandard: opt.isStandard, + }, + update: { + surcharge: opt.surcharge, + isAvailable: opt.isAvailable, + 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 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/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 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`