Skip to content

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
1qualityMetricsFingerprint matches exactly, at the same fingerprint version
2giStatus matches exactly — not just the bucket used in step 1
3The product actually has a batch
ResultactionWhat the user sees
All three holdmergedQuantity added to the existing lot. Price panel — but only if the prices differ, see §3
Any one fails, product existsnew_batchA 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 getDo
merged with panelSame leaf + same GI status + identical metrics as an existing lot, different price
merged without panelAs above, same price
new_batchSame leaf, but change any metric value, or the GI status
New productA 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.
  • harvested is 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:

sql
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.

CommitDateWhat was silent
36447d5f25 AugConfirm-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
b9af0cb925 AugAccepting 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
f51112bf26 AugAdd 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
bf1007eb26 Augpackages/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
a7ccd35726 AugThis one: merged create discarded the submitted price on mobile

Two things recur across all five:

  1. 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 — f51112bf is a correction of a parity call that got that exact distinction wrong.
  2. The written rule was right and unexecuted. bf1007eb and 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.

Last updated:

Internal technical documentation — Cropto