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 type { Product } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { effectivePrice } from './productState';
|
import { effectivePrice } from './productState';
|
||||||
|
import { needingPrice, planPublish } from './publishPlan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a price and release to the shops.
|
* 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);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose, running]);
|
}, [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);
|
const priceValue = Number(price);
|
||||||
// The backend refuses `price <= 0` with "set a price before publishing — a
|
// 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
|
// 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 =
|
const taxError =
|
||||||
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
|
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
|
||||||
const canSubmit =
|
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() {
|
async function run() {
|
||||||
setRunning(true);
|
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
|
// 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
|
// a single Go instance to save a few seconds risks a half-published
|
||||||
// catalogue, which is worse than a slow one.
|
// 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 {
|
try {
|
||||||
await productsApi.publish({
|
await productsApi.publish({
|
||||||
tenantid,
|
tenantid,
|
||||||
productid: product.productid,
|
productid: line.productid,
|
||||||
price: priceValue,
|
price: line.price,
|
||||||
taxpercent: taxValue,
|
taxpercent: line.taxpercent,
|
||||||
});
|
});
|
||||||
outcomes.push({ product, ok: true });
|
outcomes.push({ product, ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -140,30 +168,54 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
|
|||||||
{isBulk ? `Price ${products.length} products` : 'Price and release'}
|
{isBulk ? `Price ${products.length} products` : 'Price and release'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<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
|
{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 ?? '')}
|
: (first?.productname ?? '')}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
{results === null ? (
|
{results === null ? (
|
||||||
<>
|
<>
|
||||||
<TextInput
|
{/* Only asked when something in the selection actually lacks a
|
||||||
label="Selling price (₹)"
|
price. A sheet import carries one on every row, and asking
|
||||||
size="md"
|
again reads as though the price were missing when it is on
|
||||||
value={price}
|
screen two lines above. */}
|
||||||
onChange={setPrice}
|
{askForPrice ? (
|
||||||
placeholder="0.00"
|
<>
|
||||||
{...(priceError ? { error: priceError } : {})}
|
<TextInput
|
||||||
/>
|
label="Selling price (₹)"
|
||||||
<TextInput
|
size="md"
|
||||||
label="Tax %"
|
value={price}
|
||||||
size="md"
|
onChange={setPrice}
|
||||||
value={tax}
|
placeholder="0.00"
|
||||||
onChange={setTax}
|
{...(priceError ? { error: priceError } : {})}
|
||||||
placeholder="0"
|
/>
|
||||||
{...(taxError ? { error: taxError } : {})}
|
<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. */}
|
{/* The blast radius, stated before the button is pressed. */}
|
||||||
<HStack gap={1.5} align="start">
|
<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