Product create can merge into an existing lot
Status: as-built, verified against code + a production trace on 26 Aug 2026. Raised by: QA test case PROD-01 — "a sub-window appeared after save that is not in any document; manually the product saves directly." Answer: the behaviour is intended. It was never written down, which is why it read as a defect. This document is that missing page.
1. What a tester sees
Saving a new product usually navigates straight to the product. Sometimes it instead shows a panel:
Stock added successfully Your new stock was merged into the existing lot. Choose the sale price for this lot: ( ) Keep existing price ₹450/kg ( ) Set a new price [ Confirm & go to product ]
Both outcomes are correct. They are different server paths, and which one runs is fully determined by the data you submit.
2. The rule, as built
POST /api/v1/products returns an action field, and the client branches on it. The server decides in productService.createProduct (the existingProduct block).
Step 1 — find a candidate product. Same userId, same taxonomyLeafId, same GI bucket — where the bucket is tagged (GI_TAGGED_VERIFIED or GI_TAGGED_SELF_CERTIFIED) vs not tagged (NOT_TAGGED or INELIGIBLE). Newest first.
Step 2 — merge or not. Merge into that product's newest batch only when all three hold:
| # | Condition |
|---|---|
| 1 | qualityMetricsFingerprint matches exactly, at the same fingerprint version |
| 2 | giStatus matches exactly — not just the bucket used in step 1 |
| 3 | The product actually has a batch |
| Result | action | What the user sees |
|---|---|---|
| All three hold | merged | Quantity added to the existing lot. Price panel — but only if the prices differ, see §3 |
| Any one fails, product exists | new_batch | A new lot under the same product. Saves directly |
| No candidate product | (create) | New product and first lot. Saves directly |
Price is deliberately not applied on merge. combineIntoBatch is called without it, because the lot already has a price and the platform will not overwrite one silently. Resolving that is the client's job — which is the whole reason the panel exists.
3. When the panel appears (changed 26 Aug 2026)
Until 26 Aug the panel appeared on every merge. That produced PROD-01: the trace has existingBatchPrice: 450 and submittedPrice: 450, and the panel hides its "use submitted price" option when the two agree — so the tester was asked to choose between keeping ₹450 and typing a new price, having just typed ₹450. A prompt whose default is already the user's own answer.
Now:
- prices differ → panel, on web and mobile
- prices equal → no panel; straight to the product, with a toast saying the stock merged into an existing lot at ₹X
The toast matters. Skipping the question must not also skip the explanation: the user asked for a product and got quantity added to an existing lot instead.
4. Reproducing each path deterministically
The fingerprint is computed from the quality metrics (spec), so metrics are the lever.
| To get | Do |
|---|---|
merged with panel | Same leaf + same GI status + identical metrics as an existing lot, different price |
merged without panel | As above, same price |
new_batch | Same leaf, but change any metric value, or the GI status |
| New product | A leaf you have not used, or the other GI bucket |
Verified live, 26 Aug 2026, against QA Test Pool Farmer 10: existing NOT_TAGGED product "Roasted Makhana Seed 10 No." at ₹420 / 300 kg. Created 25 kg at ₹500, same leaf, same GI status, identical metrics (variety: Swarn Vadehi, harvested: Yes, place_of_origin: QA, processing_date: 2026-08-19). The panel offered ₹420 and ₹500; choosing the entered price left the lot at ₹500 / 325 kg — both the merge and the chosen price applied.
Two traps when building the fixture, both of which silently produce new_batch instead of merged:
- Brand Name sits directly above Place of Origin. Typing the origin into the wrong one changes the fingerprint.
harvestedis an ENUM (Yes/No), not the Harvested Week and Month date field immediately below it. They look adjacent and interchangeable; they are not.
5. Where the written rule and the code disagree
Found while answering PROD-01. Recorded rather than fixed, because two of the three need a product decision.
(a) packingMode is in the identity but not in the lookup. Decision 14 in end-to-end-trading-flow.md states the match key as (leafId, packingMode, giStatus, qualityMetricsFingerprint), and the DB agrees — the partial unique index is:
CREATE UNIQUE INDEX "product_aggregate_key"
ON "Product"("userId","taxonomyLeafId","packingMode","giStatus")
WHERE "deletedAt" IS NULL;createProduct's lookup filters on userId, taxonomyLeafId and the GI bucket only — not packingMode — then takes the newest. A user holding both a BULK and a RETAIL product on one leaf could therefore have stock merged into the wrong packing mode.
Latent, not live: a check on 26 Aug found 0 aggregates where one user+leaf+GI has more than one packingMode.
(b) packingMode is documented as never-null, and is null on 39 of 41 products. CLAUDE.md (CHG-018) says: "❌ Creating a Product with packingMode = null — always BULK … null defeats the unique key + aggregate find." Measured on 26 Aug: BULK 2, NULL 39. Postgres treats NULLs as distinct in a unique index, so for those 39 rows the aggregate key enforces nothing. Merging still works because the create lookup ignores packingMode anyway — which is (a) masking (b).
(c) The price-choice step was in no spec at all. No OpenSpec change folder describes it; it exists only in code comments referencing "smart-product- creation". That absence is what turned intended behaviour into a filed defect.
6. This keeps happening — the same defect, five times
PROD-01's mobile half was: mobile ignored action entirely and navigated away, so a farmer entering ₹500 into a lot priced ₹420 had their price dropped with no panel and no toast. That is not an isolated slip. It is the fifth instance of one shape in two days:
A decision or a failure that the user should have seen was resolved silently, and the silence looked exactly like success.
| Commit | Date | What was silent |
|---|---|---|
36447d5f | 25 Aug | Confirm-receipt on mobile auto-selected combine when a matching lot existed; web defaults to separate and makes the buyer choose. Merging blends cost basis irreversibly and moves realised P&L — a decision that fell out of a fast tap-through |
b9af0cb9 | 25 Aug | Accepting an offer/bid had no onError; the panel closed either way. A rejected accept and a successful one were pixel-identical. The server's actual sentence — "Only the lead owner can accept" — was thrown away |
f51112bf | 26 Aug | Add Product's Quantity was contract-required, unmarked, and nothing said why the button was grey. The web fix had been closed "Mobile: N/A" on the grounds that mobile "gates its own submit" — judging implementation, not outcome |
bf1007eb | 26 Aug | packages/api-client cleared the session on any non-OK response from /auth/refresh, so a 502 logged people out. CLAUDE.md already documented the fix — applied to web only, in a file no test ever executed |
a7ccd357 | 26 Aug | This one: merged create discarded the submitted price on mobile |
Two things recur across all five:
- Web asks; mobile assumes. Three of the five are mobile resolving a choice web puts to the user. The mobile-parity rule exists for this and is judged on outcome, not implementation —
f51112bfis a correction of a parity call that got that exact distinction wrong. - The written rule was right and unexecuted.
bf1007eband this issue both had correct documentation; the code that most requests actually travel through was never covered by a test.
7. Consequences
For QA. PROD-01 is expected. Update the case to assert the panel only when the fixture's price differs from the existing lot's — with the fixture as written (450 → 450) the panel will no longer appear at all.
For engineering. When a server response carries a decision field, every client must branch on it or explicitly record why it does not. action was added for web; mobile silently inherited "navigate away" as its behaviour for all three cases.
Still open. §5(a) and §5(b) are recorded, not fixed. (a) needs a decision on whether packingMode belongs in the create lookup; (b) needs a backfill of the 39 null rows, or a decision that the column is vestigial.
