bulk release
This commit is contained in:
@@ -4,101 +4,135 @@
|
||||
* `publishproduct` writes `products.retailprice` as well as the per-outlet
|
||||
* price, at every branch. So publishing the seventeen products R mart imported
|
||||
* on 2026-08-31 — ₹395 rice, ₹30 crisps, ₹105 cleaner — with the single figure
|
||||
* the dialog prefilled from the first row would have set all seventeen to ₹395
|
||||
* and left nothing to restore the real prices from.
|
||||
* a dialog prefilled from the first row would have set all seventeen to ₹395 and
|
||||
* left nothing to restore the real prices from.
|
||||
*
|
||||
* Each product now has its own field, so these tests pin two things: that a
|
||||
* figure typed into one row stays in that row, and that nothing is released
|
||||
* without a price at all.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { needingPrice, planPublish } from './publishPlan';
|
||||
import {
|
||||
needingPrice,
|
||||
planPublishEach,
|
||||
priceFieldFor,
|
||||
taxFieldFor,
|
||||
unpricedIn,
|
||||
type TypedPrice,
|
||||
} from './publishPlan';
|
||||
import type { Product } from '@/api/types';
|
||||
|
||||
const product = (id: number, price: number, retail = 0, tax?: number) =>
|
||||
({ productid: id, price, retailprice: retail, ...(tax === undefined ? {} : { taxpercent: tax }) }) as Product;
|
||||
({
|
||||
productid: id,
|
||||
price,
|
||||
retailprice: retail,
|
||||
...(tax === undefined ? {} : { taxpercent: tax }),
|
||||
}) as Product;
|
||||
|
||||
/** What the drawer holds once every row has been prefilled and none edited. */
|
||||
const asOpened = (products: Product[]): Map<number, TypedPrice> =>
|
||||
new Map(
|
||||
products.map((p) => [p.productid, { price: priceFieldFor(p), taxpercent: taxFieldFor(p) }]),
|
||||
);
|
||||
|
||||
/* ── A typed figure stays in the row it was typed into ────────────────────── */
|
||||
|
||||
test('each product is released at its own price', () => {
|
||||
const plan = planPublish(
|
||||
[product(7089, 395), product(7103, 30), product(7104, 105)],
|
||||
{ price: 395, taxpercent: 0 },
|
||||
);
|
||||
const products = [product(7089, 395), product(7103, 30), product(7104, 105)];
|
||||
const plan = planPublishEach(products, asOpened(products));
|
||||
assert.deepEqual(
|
||||
plan.map((line) => line.price),
|
||||
[395, 30, 105],
|
||||
);
|
||||
});
|
||||
|
||||
// The whole point of the fallback: a product with no price of its own is the
|
||||
// only one the typed figure may touch.
|
||||
test('only a product with no price uses the typed figure', () => {
|
||||
const plan = planPublish([product(1, 0), product(2, 250)], { price: 99, taxpercent: 5 });
|
||||
assert.equal(plan[0]?.price, 99);
|
||||
assert.equal(plan[1]?.price, 250);
|
||||
// The failure this guards: three products released together, all set to
|
||||
// whichever figure prefilled from the first.
|
||||
test('editing one row does not move the other prices', () => {
|
||||
const products = [product(1, 395), product(2, 30), product(3, 105)];
|
||||
const typed = asOpened(products);
|
||||
typed.set(1, { price: '999', taxpercent: '5' });
|
||||
const plan = planPublishEach(products, typed);
|
||||
assert.deepEqual(
|
||||
plan.map((line) => line.price),
|
||||
[999, 30, 105],
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
The per-outlet price and the tenant master are two columns and either can be the
|
||||
one that holds the figure — an imported product carries both, a hand-priced one
|
||||
may carry only the master. `effectivePrice` already resolves that, and publish
|
||||
has to use the same resolution or it would "fill in" a price that exists.
|
||||
*/
|
||||
test('the master price counts as having a price', () => {
|
||||
// Two products, because this is the BATCH rule: with one, the typed figure
|
||||
// is an edit of that product and wins by design. The behaviour under test —
|
||||
// that retailprice counts as "has a price" — is unchanged.
|
||||
const plan = planPublish([product(1, 0, 175), product(2, 0)], { price: 99, taxpercent: 0 });
|
||||
assert.equal(plan[0]?.price, 175);
|
||||
});
|
||||
|
||||
test('tax follows the same rule as price', () => {
|
||||
const plan = planPublish([product(1, 100, 0, 12), product(2, 100)], { price: 0, taxpercent: 5 });
|
||||
assert.equal(plan[0]?.taxpercent, 12);
|
||||
assert.equal(plan[1]?.taxpercent, 5);
|
||||
});
|
||||
|
||||
// Zero is a real tax rate and must survive, not fall through to the fallback.
|
||||
test('a tax rate of zero is kept, not treated as missing', () => {
|
||||
// Zero-rated goods are real — unpackaged staples are 0% GST — so 0 must not
|
||||
// be read as "no tax set". Batch context, as above.
|
||||
const plan = planPublish([product(1, 100, 0, 0), product(2, 100)], { price: 0, taxpercent: 18 });
|
||||
assert.equal(plan[0]?.taxpercent, 0);
|
||||
});
|
||||
|
||||
test('only the unpriced products are counted as needing one', () => {
|
||||
assert.equal(needingPrice([product(1, 395), product(2, 0), product(3, 0, 88)]).length, 1);
|
||||
});
|
||||
|
||||
/*
|
||||
Changing a price that already exists.
|
||||
|
||||
`own > 0 ? own : typed` protected a bulk release — seventeen imported products
|
||||
must not all take one typed figure — but it also made "Change price" impossible:
|
||||
the dialog sent the product's existing price straight back, so a price could be
|
||||
set once and never corrected.
|
||||
|
||||
One product is an edit of that product. Several is a release of a batch. The two
|
||||
need opposite defaults, and these tests pin both.
|
||||
*/
|
||||
|
||||
test('one product takes the typed price, even when it already has one', () => {
|
||||
const [line] = planPublish([product(7, 395)], { price: 250, taxpercent: 5 });
|
||||
// One product is an edit of that product: the merchant is looking at its price,
|
||||
// has typed another, and means it. `own > 0 ? own : typed` made that impossible
|
||||
// — a price could be set once and never corrected.
|
||||
test('a typed price replaces the one the product already had', () => {
|
||||
const products = [product(7, 395)];
|
||||
const typed = new Map([[7, { price: '250', taxpercent: '12' }]]);
|
||||
const [line] = planPublishEach(products, typed);
|
||||
assert.equal(line?.price, 250);
|
||||
});
|
||||
|
||||
test('one product takes the typed tax too', () => {
|
||||
const [line] = planPublish([product(7, 395)], { price: 250, taxpercent: 12 });
|
||||
assert.equal(line?.taxpercent, 12);
|
||||
});
|
||||
|
||||
test('a batch still protects the prices it already has', () => {
|
||||
// The failure this guards: ₹395 rice, ₹30 crisps and ₹105 cleaner released
|
||||
// together, all set to whichever figure prefilled from the first.
|
||||
const lines = planPublish(
|
||||
[product(1, 395), product(2, 30), product(3, 105)],
|
||||
{ price: 999, taxpercent: 5 },
|
||||
);
|
||||
assert.deepEqual(lines.map((line) => line.price), [395, 30, 105]);
|
||||
/* ── Prefill: what a row starts at ────────────────────────────────────────── */
|
||||
|
||||
// The per-outlet price and the tenant master are two columns and either can hold
|
||||
// the figure — an imported product carries both, a hand-priced one may carry
|
||||
// only the master. `effectivePrice` resolves that, and the prefill has to use
|
||||
// the same resolution or it would ask for a price that already exists.
|
||||
test('the master price counts as having a price', () => {
|
||||
assert.equal(priceFieldFor(product(1, 0, 175)), '175');
|
||||
assert.equal(priceFieldFor(product(2, 0)), '', 'nothing to prefill, so the field is empty');
|
||||
});
|
||||
|
||||
test('a batch still fills in the ones with no price', () => {
|
||||
const lines = planPublish([product(1, 395), product(2, 0)], { price: 60, taxpercent: 5 });
|
||||
assert.deepEqual(lines.map((line) => line.price), [395, 60]);
|
||||
// Zero-rated goods are real — unpackaged staples are 0% GST — so 0 must show as
|
||||
// a rate rather than as an empty field meaning "not set".
|
||||
test('a tax rate of zero is shown, not treated as missing', () => {
|
||||
assert.equal(taxFieldFor(product(1, 100, 0, 0)), '0');
|
||||
const plan = planPublishEach([product(1, 100, 0, 0)], asOpened([product(1, 100, 0, 0)]));
|
||||
assert.equal(plan[0]?.taxpercent, 0);
|
||||
});
|
||||
|
||||
// An empty tax field is zero-rated, which is a real answer. An empty PRICE
|
||||
// field is not — see below.
|
||||
test('an empty tax field releases at zero per cent rather than blocking', () => {
|
||||
const plan = planPublishEach([product(1, 100)], new Map([[1, { price: '100', taxpercent: '' }]]));
|
||||
assert.equal(plan[0]?.taxpercent, 0);
|
||||
});
|
||||
|
||||
/* ── Nothing is released without a price ──────────────────────────────────── */
|
||||
|
||||
test('a product with no price of its own is named as needing one', () => {
|
||||
assert.equal(needingPrice([product(1, 395), product(2, 0), product(3, 0, 88)]).length, 1);
|
||||
});
|
||||
|
||||
test('an unpriced row blocks the release until it is filled', () => {
|
||||
const products = [product(1, 395), product(2, 0)];
|
||||
const typed = asOpened(products);
|
||||
assert.deepEqual(
|
||||
unpricedIn(products, typed).map((p) => p.productid),
|
||||
[2],
|
||||
);
|
||||
typed.set(2, { price: '60', taxpercent: '5' });
|
||||
assert.deepEqual(unpricedIn(products, typed), []);
|
||||
});
|
||||
|
||||
// Clearing a field is not "keep what it had". What the row shows is what gets
|
||||
// written, so an emptied field is no price and the release stops.
|
||||
test('clearing a prefilled price blocks the release rather than reverting', () => {
|
||||
const products = [product(1, 395)];
|
||||
const typed = new Map([[1, { price: '', taxpercent: '5' }]]);
|
||||
assert.equal(unpricedIn(products, typed).length, 1);
|
||||
});
|
||||
|
||||
test('zero and a negative are refused as prices, not sent', () => {
|
||||
const products = [product(1, 0), product(2, 0)];
|
||||
const typed = new Map([
|
||||
[1, { price: '0', taxpercent: '0' }],
|
||||
[2, { price: '-5', taxpercent: '0' }],
|
||||
]);
|
||||
assert.equal(unpricedIn(products, typed).length, 2, 'a till cannot ring up ₹0 or less');
|
||||
});
|
||||
|
||||
test('a price that is not a number at all is refused', () => {
|
||||
const products = [product(1, 0)];
|
||||
assert.equal(unpricedIn(products, new Map([[1, { price: 'abc', taxpercent: '0' }]])).length, 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user