updated on shelfon store catalogue
This commit is contained in:
@@ -10,6 +10,7 @@ import { productsApi } from '@/api/products';
|
||||
import type { Product } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { effectivePrice } from './productState';
|
||||
import { needingPrice, planPublish } from './publishPlan';
|
||||
|
||||
/**
|
||||
* Set a price and release to the shops.
|
||||
@@ -58,6 +59,17 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, running]);
|
||||
|
||||
/**
|
||||
* The products that still need a price typed in.
|
||||
*
|
||||
* Usually none. A spreadsheet import carries a price on every row, so asking
|
||||
* for one again is asking a question that has already been answered — and it
|
||||
* was doing more than annoy: the typed figure was applied to every product in
|
||||
* the selection.
|
||||
*/
|
||||
const needPrice = needingPrice(products);
|
||||
const askForPrice = needPrice.length > 0;
|
||||
|
||||
const priceValue = Number(price);
|
||||
// The backend refuses `price <= 0` with "set a price before publishing — a
|
||||
// product with no price cannot be sold at a till". Refusing here too means
|
||||
@@ -72,7 +84,11 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
||||
const taxError =
|
||||
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
|
||||
const canSubmit =
|
||||
!running && price.trim() !== '' && priceError === null && taxError === null;
|
||||
!running &&
|
||||
// A price is required only when something in the selection lacks one.
|
||||
(!askForPrice || price.trim() !== '') &&
|
||||
priceError === null &&
|
||||
taxError === null;
|
||||
|
||||
async function run() {
|
||||
setRunning(true);
|
||||
@@ -83,13 +99,25 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
||||
// its own transaction with a per-outlet loop inside it; firing hundreds at
|
||||
// a single Go instance to save a few seconds risks a half-published
|
||||
// catalogue, which is worse than a slow one.
|
||||
for (const product of products) {
|
||||
const plan = planPublish(products, { price: priceValue, taxpercent: taxValue });
|
||||
|
||||
for (const [index, product] of products.entries()) {
|
||||
// Each product is released AT ITS OWN PRICE. The field above only fills
|
||||
// in for one that has none.
|
||||
//
|
||||
// Sending the typed figure for every row was a quiet way to destroy a
|
||||
// catalogue: `publishproduct` writes `products.retailprice` as well as
|
||||
// the per-outlet price, so releasing seventeen imported products together
|
||||
// — ₹395 rice, ₹30 crisps, ₹105 cleaner — would have set all seventeen to
|
||||
// whichever price happened to prefill from the first one, at every
|
||||
// branch, with no way back to the originals.
|
||||
const line = plan[index]!;
|
||||
try {
|
||||
await productsApi.publish({
|
||||
tenantid,
|
||||
productid: product.productid,
|
||||
price: priceValue,
|
||||
taxpercent: taxValue,
|
||||
productid: line.productid,
|
||||
price: line.price,
|
||||
taxpercent: line.taxpercent,
|
||||
});
|
||||
outcomes.push({ product, ok: true });
|
||||
} catch (error) {
|
||||
@@ -140,30 +168,54 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
||||
{isBulk ? `Price ${products.length} products` : 'Price and release'}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{/* This line used to read "The same price is set on every product
|
||||
selected" — which was true, and was the bug. It is now each
|
||||
product's own, and saying so is what stops someone reaching
|
||||
for the field to "correct" a price that is already right. */}
|
||||
{isBulk
|
||||
? 'The same price is set on every product selected.'
|
||||
? 'Each keeps its own price. Only products without one use the figure below.'
|
||||
: (first?.productname ?? '')}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{results === null ? (
|
||||
<>
|
||||
<TextInput
|
||||
label="Selling price (₹)"
|
||||
size="md"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="0.00"
|
||||
{...(priceError ? { error: priceError } : {})}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tax %"
|
||||
size="md"
|
||||
value={tax}
|
||||
onChange={setTax}
|
||||
placeholder="0"
|
||||
{...(taxError ? { error: taxError } : {})}
|
||||
/>
|
||||
{/* Only asked when something in the selection actually lacks a
|
||||
price. A sheet import carries one on every row, and asking
|
||||
again reads as though the price were missing when it is on
|
||||
screen two lines above. */}
|
||||
{askForPrice ? (
|
||||
<>
|
||||
<TextInput
|
||||
label="Selling price (₹)"
|
||||
size="md"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="0.00"
|
||||
{...(priceError ? { error: priceError } : {})}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tax %"
|
||||
size="md"
|
||||
value={tax}
|
||||
onChange={setTax}
|
||||
placeholder="0"
|
||||
{...(taxError ? { error: taxError } : {})}
|
||||
/>
|
||||
{isBulk ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{needPrice.length} of {products.length} have no price yet and will use this
|
||||
one. The rest keep their own.
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{isBulk
|
||||
? `All ${products.length} already have a price — each is released at its own. Nothing is overwritten.`
|
||||
: `Released at its own price of ₹${effectivePrice(first as Product)}.`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* The blast radius, stated before the button is pressed. */}
|
||||
<HStack gap={1.5} align="start">
|
||||
|
||||
62
src/features/store-admin/publishPlan.test.ts
Normal file
62
src/features/store-admin/publishPlan.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Releasing several products at once must not give them all one price.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { needingPrice, planPublish } 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;
|
||||
|
||||
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 },
|
||||
);
|
||||
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 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', () => {
|
||||
const plan = planPublish([product(1, 0, 175)], { 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', () => {
|
||||
const plan = planPublish([product(1, 100, 0, 0)], { 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);
|
||||
});
|
||||
41
src/features/store-admin/publishPlan.ts
Normal file
41
src/features/store-admin/publishPlan.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Product } from '@/api/types';
|
||||
import { effectivePrice } from './productState';
|
||||
|
||||
/**
|
||||
* What price and tax each product is released at.
|
||||
*
|
||||
* Its own function because getting it wrong destroys data rather than merely
|
||||
* failing. `publishproduct` writes `products.retailprice` as well as the
|
||||
* per-outlet price, at every branch — so releasing seventeen imported products
|
||||
* with one typed figure sets all seventeen to it and loses the originals, with
|
||||
* nothing to restore them from.
|
||||
*
|
||||
* The rule: a product that HAS a price is released at its own. The typed figure
|
||||
* fills in only for one that has none, which after a spreadsheet import is
|
||||
* usually nothing at all.
|
||||
*/
|
||||
export interface PublishLine {
|
||||
productid: number;
|
||||
price: number;
|
||||
taxpercent: number;
|
||||
}
|
||||
|
||||
export function planPublish(
|
||||
products: readonly Product[],
|
||||
fallback: { price: number; taxpercent: number },
|
||||
): PublishLine[] {
|
||||
return products.map((product) => {
|
||||
const own = effectivePrice(product);
|
||||
const ownTax = Number(product.taxpercent ?? NaN);
|
||||
return {
|
||||
productid: product.productid,
|
||||
price: own > 0 ? own : fallback.price,
|
||||
taxpercent: Number.isFinite(ownTax) ? ownTax : fallback.taxpercent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The products that still need a price typed in — usually none. */
|
||||
export function needingPrice(products: readonly Product[]): Product[] {
|
||||
return products.filter((product) => effectivePrice(product) <= 0);
|
||||
}
|
||||
Reference in New Issue
Block a user