Skip to content

Commit e5d058e

Browse files
hiteshkrmsftCopilot
andcommitted
Fix stocks page: chronological replay + case-insensitive matching + orphan visibility
The stocks page was showing inflated quantities for items whose legacy sale records had a slightly-different-cased name (or stale itemId) than the canonical catalogue entry. Three independent bugs were collaborating to hide the discrepancy: 1. calculateStock applied stock adjustments in storage order (DESC by date) instead of chronological order, so a 'set' adjustment used a stale snapshot of stock and silently overwrote every event recorded after it. Refactored to a single chronological event timeline that replays purchases, sales and adjustments in real time order. A 'set' adjustment now uses adj.quantity (the user-entered target) rather than the stale adj.newStock snapshot. 2. getItemKey did a case-sensitive name fallback for records without itemId, so a sale saved as 'Piyar Dana' produced a different bucket from a purchase saved as 'piyar dana'. Normalised with trim() + toLowerCase() and added an itemId-fallthrough so stale itemIds also resolve to the right bucket. 3. renderStock then silently dropped any bucket that had no catalogue match (if (!item) return;). Orphans are now rendered as red/orange 'Unmatched' warning rows so data-integrity issues are visible instead of hidden, and console.warn lists the orphan keys. Also adds a debugItemForName helper (window.debugItem) that dumps the full event timeline for a named item from the DevTools console — used during diagnosis. Tests: 114/114 passing (was 112) — 11 chronological-replay tests in calculateStock.test.js cover bug fixes #1, #2 (case insensitivity for both name and hindiName), and the on-demand sale-bucket creation that prevents the silent-drop bug #3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bc2a434 commit e5d058e

4 files changed

Lines changed: 717 additions & 97 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/**
2+
* @fileoverview Tests for FirebaseService.calculateStock chronological replay.
3+
*
4+
* These tests pin the two bugs that the stock page suffered from before the
5+
* refactor:
6+
* - bug #1: stockAdjustments are loaded `orderBy('date','desc')`. Iterating
7+
* them in array order applies the *newest* first, inverting the meaning of
8+
* any `set` adjustment whenever older `add`/`remove` adjustments exist.
9+
* - bug #2: a `set` adjustment used `adj.newStock` (a stale snapshot of stock
10+
* at save time). After the snapshot, all later purchases/sales were
11+
* re-applied first and then silently overwritten by the snapshot — every
12+
* event recorded after the adjustment vanished from the displayed stock.
13+
*
14+
* They also cover a smaller fix:
15+
* - bug #3: a sale whose key didn't match any purchase was silently dropped,
16+
* so the displayed stock of a *different* item was incorrectly inflated.
17+
* Sales now create the entry on demand.
18+
*
19+
* The test imports the real `FirebaseService` and shares the singleton
20+
* `AppState` module with the production code, so we exercise the exact code
21+
* path that runs in the browser.
22+
*/
23+
24+
import { FirebaseService } from '../firebase/firestore-service.js';
25+
import { AppState } from '../utils/state.js';
26+
27+
const ITEM = { id: 'item_piyar', name: 'piyar dana', hindiName: 'पियर दाना' };
28+
29+
/** Reset the slices of AppState that calculateStock reads. */
30+
function resetState() {
31+
AppState.items = [ITEM];
32+
AppState.purchaseHistory = [];
33+
AppState.salesHistory = [];
34+
AppState.retailSalesHistory = [];
35+
AppState.stockAdjustments = [];
36+
}
37+
38+
/** Build a purchase document with a single line item. */
39+
function purchase(ts, qty, rate) {
40+
return {
41+
id: `purchase_${ts}`,
42+
timestamp: ts,
43+
date: new Date(ts).toLocaleString('en-IN'),
44+
items: [{ itemId: ITEM.id, name: ITEM.name, qty, rate }]
45+
};
46+
}
47+
48+
/** Build a wholesale sale document with a single line item. */
49+
function sale(ts, qty, rate) {
50+
return {
51+
id: `sale_${ts}`,
52+
timestamp: ts,
53+
date: new Date(ts).toLocaleString('en-IN'),
54+
items: [{ itemId: ITEM.id, name: ITEM.name, qty, rate }]
55+
};
56+
}
57+
58+
/** Build a stock adjustment document. */
59+
function adjustment(ts, adjustType, quantity, opts = {}) {
60+
return {
61+
id: `adj_${ts}`,
62+
timestamp: ts,
63+
date: new Date(ts).toLocaleString('en-IN'),
64+
itemId: ITEM.id,
65+
itemName: ITEM.name,
66+
adjustType,
67+
quantity,
68+
rate: opts.rate || 0,
69+
newStock: opts.newStock,
70+
reason: opts.reason || ''
71+
};
72+
}
73+
74+
describe('FirebaseService.calculateStock - chronological replay', () => {
75+
beforeEach(() => {
76+
resetState();
77+
});
78+
79+
it('sums simple purchases with weighted-average rate', async () => {
80+
AppState.purchaseHistory = [
81+
purchase(1000, 10, 100),
82+
purchase(2000, 10, 200)
83+
];
84+
const stock = await FirebaseService.calculateStock();
85+
expect(stock[ITEM.id].quantity).toBe(20);
86+
expect(stock[ITEM.id].rate).toBeCloseTo(150);
87+
});
88+
89+
it('subtracts sales using the running weighted-average rate', async () => {
90+
AppState.purchaseHistory = [purchase(1000, 10, 100), purchase(2000, 10, 200)];
91+
AppState.salesHistory = [sale(3000, 5, 250)];
92+
const stock = await FirebaseService.calculateStock();
93+
expect(stock[ITEM.id].quantity).toBe(15);
94+
// After 20kg @ avg 150, selling 5 at avg=150 leaves totalValue=2250 / 15 = 150
95+
expect(stock[ITEM.id].rate).toBeCloseTo(150);
96+
});
97+
98+
// ---------- Bug #1 ----------------------------------------------------
99+
it('applies adjustments in chronological order even when stored newest-first', async () => {
100+
// Simulate the production load order: orderBy('date','desc')
101+
AppState.stockAdjustments = [
102+
adjustment(3000, 'set', 100), // newest - last chronologically
103+
adjustment(2000, 'remove', 5),
104+
adjustment(1000, 'add', 50) // oldest - first chronologically
105+
];
106+
107+
const stock = await FirebaseService.calculateStock();
108+
109+
// Expected chronological replay:
110+
// t=1000 add 50 -> 50
111+
// t=2000 remove 5 -> 45
112+
// t=3000 set 100 -> 100 (the user's last word)
113+
expect(stock[ITEM.id].quantity).toBe(100);
114+
});
115+
116+
// ---------- Bug #2 ----------------------------------------------------
117+
it('preserves purchases recorded AFTER a set adjustment', async () => {
118+
AppState.stockAdjustments = [adjustment(2000, 'set', 20, { rate: 100 })];
119+
AppState.purchaseHistory = [purchase(3000, 5, 200)];
120+
121+
const stock = await FirebaseService.calculateStock();
122+
123+
// Replay:
124+
// t=2000 set 20 @ 100 -> qty=20, value=2000
125+
// t=3000 purchase 5 @ 200 -> qty=25, value=3000
126+
expect(stock[ITEM.id].quantity).toBe(25);
127+
expect(stock[ITEM.id].rate).toBeCloseTo(120);
128+
});
129+
130+
it('uses adj.quantity (target) for set adjustments, not the stale newStock snapshot', async () => {
131+
// newStock snapshot says 999 but quantity (the user-entered target) is 50.
132+
// With purchases happening before, the production bug would have used
133+
// the snapshot. The fix uses adj.quantity.
134+
AppState.purchaseHistory = [purchase(1000, 10, 100)];
135+
AppState.stockAdjustments = [adjustment(2000, 'set', 50, { rate: 100, newStock: 999 })];
136+
137+
const stock = await FirebaseService.calculateStock();
138+
139+
expect(stock[ITEM.id].quantity).toBe(50);
140+
});
141+
142+
it('handles set + later remove correctly', async () => {
143+
AppState.stockAdjustments = [
144+
adjustment(2000, 'remove', 5), // newest first (DESC load order)
145+
adjustment(1000, 'set', 30, { rate: 100 })
146+
];
147+
148+
const stock = await FirebaseService.calculateStock();
149+
150+
// t=1000 set 30, t=2000 remove 5 -> 25
151+
expect(stock[ITEM.id].quantity).toBe(25);
152+
});
153+
154+
// ---------- Bug #3 ----------------------------------------------------
155+
it('creates a stock entry for sales of an item that was never purchased', async () => {
156+
AppState.salesHistory = [sale(1000, 5, 200)];
157+
const stock = await FirebaseService.calculateStock();
158+
// The entry is created so its negative balance is visible, instead of
159+
// being silently dropped (which previously caused other items'
160+
// displayed stock to look wrong).
161+
expect(stock[ITEM.id]).toBeDefined();
162+
expect(stock[ITEM.id].quantity).toBe(-5);
163+
});
164+
165+
// ---------- Cross-source ordering ------------------------------------
166+
it('interleaves purchases, sales and adjustments in true chronological order', async () => {
167+
AppState.purchaseHistory = [purchase(1000, 10, 100), purchase(4000, 5, 300)];
168+
AppState.salesHistory = [sale(2000, 3, 150)];
169+
AppState.stockAdjustments = [
170+
adjustment(5000, 'remove', 2), // newest - DESC load order
171+
adjustment(3000, 'set', 20, { rate: 200 })
172+
];
173+
174+
const stock = await FirebaseService.calculateStock();
175+
176+
// Chronological replay:
177+
// t=1000 purchase 10 @ 100 -> qty=10, value=1000
178+
// t=2000 sale 3 @ avg(100) -> qty=7, value=700
179+
// t=3000 set 20 @ 200 -> qty=20, value=4000
180+
// t=4000 purchase 5 @ 300 -> qty=25, value=5500
181+
// t=5000 remove 2 (5/25=20%)-> qty=23, value=5060
182+
expect(stock[ITEM.id].quantity).toBe(23);
183+
expect(stock[ITEM.id].rate).toBeCloseTo(220);
184+
});
185+
186+
it('falls back to parsing the locale date string when timestamp is absent', async () => {
187+
// Two purchases with no numeric timestamp - only a parseable d/m/yyyy date.
188+
// First chronologically is the 1 Jan one even though the array lists Feb first.
189+
const p1 = { id: 'p1', date: '01/02/2026, 10:00:00 am', items: [{ itemId: ITEM.id, name: ITEM.name, qty: 5, rate: 200 }] };
190+
const p2 = { id: 'p2', date: '01/01/2026, 10:00:00 am', items: [{ itemId: ITEM.id, name: ITEM.name, qty: 5, rate: 100 }] };
191+
AppState.purchaseHistory = [p1, p2];
192+
193+
const stock = await FirebaseService.calculateStock();
194+
expect(stock[ITEM.id].quantity).toBe(10);
195+
expect(stock[ITEM.id].rate).toBeCloseTo(150);
196+
});
197+
198+
// ---------- Bug #4: case-insensitive name matching --------------------
199+
it('collapses legacy records without itemId to the same bucket regardless of name casing or whitespace', async () => {
200+
// A purchase using the canonical lower-case name + itemId, and a sale
201+
// recorded against the same item by legacy data that has no itemId and
202+
// a differently-cased / whitespace-padded name. Pre-fix, the sale was
203+
// bucketed under a separate orphan key ("Piyar Dana") and the purchase
204+
// bucket showed the full quantity uncorrected.
205+
AppState.purchaseHistory = [purchase(1000, 100, 200)];
206+
AppState.salesHistory = [
207+
{
208+
id: 'legacy_sale',
209+
timestamp: 2000,
210+
date: new Date(2000).toLocaleString('en-IN'),
211+
items: [{ name: ' Piyar Dana ', qty: 30, rate: 250 }]
212+
}
213+
];
214+
215+
const stock = await FirebaseService.calculateStock();
216+
217+
expect(stock[ITEM.id]).toBeDefined();
218+
expect(stock[ITEM.id].quantity).toBe(70);
219+
// No orphan bucket should exist under the legacy raw name.
220+
expect(stock[' Piyar Dana ']).toBeUndefined();
221+
expect(stock['Piyar Dana']).toBeUndefined();
222+
expect(stock['piyar dana']).toBeUndefined();
223+
});
224+
225+
it('matches by hindiName case-insensitively for legacy records without itemId', async () => {
226+
// The catalogue Hindi name is "पियर दाना" — exact match. Legacy data
227+
// commonly carries the same string with stray whitespace. Stripping +
228+
// case-folding should still produce a single canonical bucket.
229+
AppState.purchaseHistory = [purchase(1000, 50, 200)];
230+
AppState.salesHistory = [
231+
{
232+
id: 'legacy_sale_hindi',
233+
timestamp: 2000,
234+
date: new Date(2000).toLocaleString('en-IN'),
235+
items: [{ name: ' पियर दाना ', qty: 10, rate: 250 }]
236+
}
237+
];
238+
239+
const stock = await FirebaseService.calculateStock();
240+
expect(stock[ITEM.id].quantity).toBe(40);
241+
});
242+
});

0 commit comments

Comments
 (0)