bulk release

This commit is contained in:
2026-09-08 12:59:17 +05:30
parent 12269deeda
commit 024fc4adfd
4 changed files with 552 additions and 323 deletions

View File

@@ -14,6 +14,7 @@ import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
import { SheetUploadDrawer } from './SheetUploadDrawer';
import { useSelection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
@@ -108,6 +109,16 @@ export function ProductsPanel({
});
}, [products.data, category]);
/**
* Every product listed can be ticked, not only the unpriced ones.
*
* Releasing is the obvious bulk job, but re-pricing a shelf is the other one
* — a supplier raises a rate and eleven products move together — and the same
* drawer does both. Restricting the ticks to "Not ready" would have made the
* second impossible for no gain.
*/
const selection = useSelection(rows.map((product) => product.productid));
const categoryOptions = useMemo(
() => [
{ value: '', label: 'All aisles' },
@@ -141,9 +152,49 @@ export function ProductsPanel({
}
/>
{rows.length > 0 ? (
<div className="bulkbar" data-active={selection.count > 0 ? 'yes' : 'no'}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<label className="bulkbar-all">
<input
type="checkbox"
checked={selection.allChosen}
ref={(el) => {
if (el) el.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
aria-label={
selection.allChosen ? 'Clear selection' : 'Select every product shown'
}
/>
<Text type="body" size="sm">
{selection.count > 0
? `${selection.count} selected`
: `Select all ${rows.length}`}
</Text>
</label>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button label="Clear" variant="ghost" size="sm" onClick={selection.clear} />
<Button
label={`Price and release ${selection.count}`}
variant="primary"
size="sm"
onClick={() =>
setPublishing(rows.filter((product) => selection.has(product.productid)))
}
/>
</HStack>
) : null}
</HStack>
</div>
) : null}
<ProductTable
rows={rows}
aisleNames={aisleNames}
selection={selection}
queryKey={category}
isLoading={products.isLoading}
onOpen={setOpen}
@@ -168,7 +219,13 @@ export function ProductsPanel({
products={publishing}
tenantid={tenantid}
branchCount={scoped.length}
onClose={() => setPublishing(null)}
onClose={() => {
setPublishing(null);
// The work is done, so the ticks go with it — leaving eleven rows
// selected after releasing them invites a second release of the
// same eleven.
selection.clear();
}}
/>
) : null}
</VStack>
@@ -180,6 +237,7 @@ export function ProductsPanel({
function ProductTable({
rows,
aisleNames,
selection,
isLoading,
onOpen,
onPrice,
@@ -188,6 +246,8 @@ function ProductTable({
rows: Product[];
/** Subcategory id to the aisle name the customer app shows it under. */
aisleNames: ReadonlyMap<number, string>;
/** The tick state, owned by the panel so its bar can act on the choice. */
selection: ReturnType<typeof useSelection>;
isLoading: boolean;
onOpen: (product: Product) => void;
onPrice: (product: Product) => void;
@@ -250,7 +310,8 @@ function ProductTable({
}}
>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '4%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '9.5%' }} />
<col style={{ width: '9.5%' }} />
@@ -260,6 +321,7 @@ function ProductTable({
</colgroup>
<thead>
<tr>
<Th />
<Th>Product</Th>
<Th>Category</Th>
<Th>Unit</Th>
@@ -275,6 +337,8 @@ function ProductTable({
key={product.productid}
product={product}
aisleNames={aisleNames}
isSelected={selection.has(product.productid)}
onSelect={() => selection.toggle(product.productid)}
onOpen={() => onOpen(product)}
onPrice={() => onPrice(product)}
/>
@@ -301,11 +365,15 @@ function ProductTable({
function Row({
product,
aisleNames,
isSelected,
onSelect,
onOpen,
onPrice,
}: {
product: Product;
aisleNames: ReadonlyMap<number, string>;
isSelected: boolean;
onSelect: () => void;
onOpen: () => void;
onPrice: () => void;
}) {
@@ -331,10 +399,26 @@ function Row({
}}
style={{
cursor: 'pointer',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
background: isSelected
? 'var(--color-brand-tint)'
: isHovered
? 'var(--color-surface-sunken)'
: 'transparent',
transition: 'background .15s',
}}
>
{/* The tick stops the click from reaching the row, or choosing a product
would also open its drawer over the list being worked through. */}
<Td>
<input
type="checkbox"
className="bulkbar-tick"
checked={isSelected}
onClick={(event) => event.stopPropagation()}
onChange={onSelect}
aria-label={`Select ${product.productname ?? 'product'}`}
/>
</Td>
<Td>
<HStack gap={1.5} align="center">
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />

View File

@@ -1,22 +1,25 @@
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check } from 'lucide-react';
import { AlertTriangle, Check, IndianRupee } from 'lucide-react';
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';
import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit';
import {
planPublishEach,
priceFieldFor,
taxFieldFor,
unpricedIn,
type TypedPrice,
} from './publishPlan';
/**
* Set a price and release to the shops.
*
* Two things about this are the backend's shape, not a choice, and both are
* stated on screen rather than hidden:
* Three things about this are the backend's shape, not a choice, and all three
* are stated on screen rather than hidden:
*
* 1. **It is all-or-nothing across branches.** `PublishProduct` reads the
* tenant's active outlets server-side precisely so a caller cannot narrow
@@ -27,6 +30,21 @@ import { needingPrice, planPublish } from './publishPlan';
* productid and runs its own transaction. Publishing 500 products is 500
* calls. So the loop is here, with real progress and a per-row result,
* rather than a spinner that pretends one call is happening.
*
* 3. **Nothing goes out without a price.** The backend refuses `price <= 0`
* ("a product with no price cannot be sold at a till"), and a product that
* slipped through at ₹0 could be ordered for nothing. So the release button
* stays disabled while any selected row is empty, and the rows that are
* empty say so — rather than releasing what it can and reporting the rest as
* failures afterwards, which leaves the merchant to work out which.
*
* ── Why a drawer and not a modal ────────────────────────────────────────────
*
* It was a centred modal, which is the wrong shape for a list: pricing eleven
* products means eleven fields beside the eleven names, and a box in the middle
* of the screen hides the table those names came from. The drawer is the
* console's one panel shell — sticky header, scrolling body, fixed action bar —
* so the button stays put however long the list runs.
*/
export interface PublishDialogProps {
products: Product[];
@@ -42,65 +60,55 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
const isBulk = products.length > 1;
const first = products[0];
const [price, setPrice] = useState(() => {
const existing = first ? effectivePrice(first) : 0;
return existing > 0 ? String(existing) : '';
});
const [tax, setTax] = useState(() => String(first?.taxpercent ?? 0));
/**
* One row per product, prefilled with that product's own figures.
*
* Seeded once from the selection: re-seeding on every render would throw away
* what is being typed, and re-seeding when the selection changes cannot
* happen because the drawer is opened with a selection and closed before the
* next one.
*/
const [rows, setRows] = useState<Map<number, TypedPrice>>(
() =>
new Map(
products.map((product) => [
product.productid,
{ price: priceFieldFor(product), taxpercent: taxFieldFor(product) },
]),
),
);
const [fill, setFill] = useState('');
const [done, setDone] = useState(0);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<Outcome[] | null>(null);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !running) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, running]);
function setRow(productid: number, patch: Partial<TypedPrice>) {
setRows((current) => {
const next = new Map(current);
const row = next.get(productid) ?? { price: '', taxpercent: '' };
next.set(productid, { ...row, ...patch });
return next;
});
}
/**
* 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);
/**
* Whether to show the price field.
*
* Always for ONE product, even when it already has a price — that is the
* "Change price" case, and a dialog that hides the field because the value
* exists is a dialog that cannot change it. That was the bug: the field was
* shown only when something LACKED a price, so a priced product opened a form
* with nothing to edit and re-published the figure it already had.
*
* For a batch, only when something lacks one: a spreadsheet import carries a
* price on every row, and asking again is asking a question already answered.
*/
const askForPrice = needPrice.length > 0 || !isBulk;
/** Fills only the EMPTY price fields — it never overwrites a real price. */
function applyToEmpty() {
if (fill.trim() === '') return;
setRows((current) => {
const next = new Map(current);
for (const product of products) {
const row = next.get(product.productid);
if (!row || row.price.trim() === '') {
next.set(product.productid, { price: fill, taxpercent: row?.taxpercent ?? '' });
}
}
return next;
});
setFill('');
}
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
// the merchant is told before the round trip, not after it.
const priceError =
price.trim() === ''
? null
: !Number.isFinite(priceValue) || priceValue <= 0
? 'A price above zero is required — a till cannot ring up a product at ₹0.'
: null;
const taxValue = Number(tax);
const taxError =
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
const canSubmit =
!running &&
// A price is required only when something in the selection lacks one.
(!askForPrice || price.trim() !== '') &&
priceError === null &&
taxError === null;
const blocked = useMemo(() => unpricedIn(products, rows), [products, rows]);
const canSubmit = !running && blocked.length === 0;
async function run() {
setRunning(true);
@@ -111,18 +119,9 @@ 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.
const plan = planPublish(products, { price: priceValue, taxpercent: taxValue });
const plan = planPublishEach(products, rows);
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({
@@ -149,163 +148,219 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
const failed = results?.filter((entry) => !entry.ok) ?? [];
const footer =
results === null ? (
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} isDisabled={running} />
<DrawerButton
label={
running
? `Releasing ${done} of ${products.length}…`
: isBulk
? `Set prices and release ${products.length}`
: 'Set price and release'
}
variant="primary"
icon={<IndianRupee size={15} />}
isDisabled={!canSubmit}
onClick={() => void run()}
/>
</>
) : (
<>
{failed.length > 0 ? (
<DrawerButton
label="Try the failures again"
variant="ghost"
onClick={() => {
setResults(null);
setDone(0);
}}
/>
) : null}
<DrawerButton label="Done" variant="primary" onClick={onClose} />
</>
);
return (
<>
<div
onClick={running ? undefined : onClose}
style={{ position: 'fixed', inset: 0, zIndex: 60, background: 'rgb(16 24 40 / .35)' }}
/>
<div
role="dialog"
aria-modal
aria-label={isBulk ? 'Price and release products' : 'Price and release product'}
style={{
position: 'fixed',
zIndex: 61,
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 'min(520px, calc(100vw - 32px))',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
borderRadius: 16,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
boxShadow: '0 24px 48px -12px rgb(16 24 40 / .25)',
}}
>
<VStack gap={2} padding={3}>
<VStack gap={0.5}>
<Text type="label" size="lg" weight="semibold">
{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
? 'Each keeps its own price. Only products without one use the figure below.'
: (first?.productname ?? '')}
</Text>
</VStack>
<Drawer
title={isBulk ? `Price and release ${products.length} products` : 'Price and release'}
{...(isBulk ? {} : { subtitle: first?.productname ?? '' })}
width={560}
onClose={running ? () => undefined : onClose}
isFooterSpread
footer={footer}
>
{results === null ? (
<>
{/* The blast radius, before the button rather than after it. */}
<Note icon={<AlertTriangle size={15} />}>
{branchCount === 1 ? (
<>
This sets the price at <strong>your branch</strong> and puts{' '}
{isBulk ? 'these products' : 'this product'} on sale there. Open a second outlet and
the same release reaches it too — publishing is business-wide in this system, never
per branch.
</>
) : (
<>
This sets the price at <strong>all {branchCount} branches</strong> and releases{' '}
{isBulk ? 'these products' : 'this product'} to every one of them. There is no way
to price or release a single branch — publishing is a business-wide decision in this
system.
</>
)}
</Note>
{results === null ? (
<>
{/* 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 } : {})}
{isBulk && blocked.length > 0 ? (
<Note>
{blocked.length} of {products.length} still {blocked.length === 1 ? 'has' : 'have'} no
price. Nothing is released until every one is priced — a product at ₹0 can be ordered
for nothing.
</Note>
) : null}
{/* Filling several empty rows without typing the same figure into
each. Deliberately only the EMPTY ones: a control that overwrote
real prices is the failure this whole screen was rebuilt around. */}
{isBulk && blocked.length > 1 ? (
<Section title="Fill the empty ones">
<DrawerCard>
<div style={{ display: 'flex', gap: 10, alignItems: 'end', padding: 14 }}>
<div style={{ flex: 1 }}>
<TextInput
label="Price for every row still empty (₹)"
size="sm"
value={fill}
onChange={setFill}
placeholder="0.00"
/>
</div>
<DrawerButton
label="Apply"
onClick={applyToEmpty}
isDisabled={fill.trim() === ''}
/>
<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>
)}
</div>
</DrawerCard>
</Section>
) : null}
{/* The blast radius, stated before the button is pressed. */}
<HStack gap={1.5} align="start">
<span style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 2 }}>
<AlertTriangle size={15} />
</span>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
This sets the price at <strong>all {branchCount} branches</strong> and releases
the product to every one of them. There is no way to price or release a single
branch — publishing is a business-wide decision in this system.
</Text>
</HStack>
{running ? (
<Text type="body" size="sm" color="secondary">
Publishing {done} of {products.length}…
</Text>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} isDisabled={running} />
<Button
label={running ? `Publishing… ${done}/${products.length}` : 'Set price and release'}
variant="primary"
size="sm"
onClick={() => void run()}
isDisabled={!canSubmit}
/>
</HStack>
</>
) : (
<>
<HStack gap={1} align="center">
<span style={{ color: 'var(--color-success, #10b981)' }}>
<Check size={16} />
</span>
<Text type="body" size="sm">
{results.length - failed.length} of {results.length} released to all{' '}
{branchCount} branches.
</Text>
</HStack>
{failed.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{failed.length} could not be published
</Text>
{failed.map((entry) => (
<Text
key={entry.product.productid}
type="body"
size="xsm"
color="secondary"
>
{entry.product.productname} — {entry.reason}
</Text>
))}
</VStack>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
{failed.length > 0 ? (
<Button
label="Retry the failures"
variant="ghost"
size="sm"
onClick={() => {
setResults(null);
setDone(0);
<Section title={isBulk ? `Prices — ${products.length} selected` : undefined}>
<DrawerCard>
{products.map((product) => {
const row = rows.get(product.productid) ?? { price: '', taxpercent: '' };
const isBlocked = blocked.some((p) => p.productid === product.productid);
return (
<div
key={product.productid}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 108px 84px',
gap: 10,
alignItems: 'end',
padding: '12px 14px',
borderTop: '1px solid var(--color-line)',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span
style={{
font: '500 13.5px/1.4 var(--font-sans)',
color: 'var(--color-ink-1)',
overflowWrap: 'anywhere',
}}
>
{product.productname}
</span>
<span
style={{
font: '400 11.5px/1.4 var(--font-mono)',
color: 'var(--color-ink-4)',
}}
>
{product.productsku || `#${product.productid}`}
</span>
{isBlocked ? (
<span
style={{
font: '500 11.5px/1.4 var(--font-sans)',
color: 'var(--color-warning, #b7860b)',
}}
>
Needs a price above zero
</span>
) : null}
</div>
<TextInput
label="Price (₹)"
size="sm"
value={row.price}
onChange={(value) => setRow(product.productid, { price: value })}
placeholder="0.00"
isDisabled={running}
/>
<TextInput
label="Tax %"
size="sm"
value={row.taxpercent}
onChange={(value) => setRow(product.productid, { taxpercent: value })}
placeholder="0"
isDisabled={running}
/>
</div>
);
})}
</DrawerCard>
</Section>
{running ? (
<Note>
Releasing {done} of {products.length}. One call per product — leave this open until it
finishes.
</Note>
) : null}
</>
) : (
<>
<Section>
<DrawerCard>
<Row
label={branchCount === 1 ? 'Released to your branch' : 'Released to all branches'}
value={
<Badge
label={`${results.length - failed.length} of ${results.length}`}
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'}
/>
) : null}
<Button label="Done" variant="primary" size="sm" onClick={onClose} />
</HStack>
</>
}
{...(failed.length === 0 ? { tone: 'success' as const } : {})}
/>
<Row label="Branches" value={String(branchCount)} />
</DrawerCard>
</Section>
{failed.length > 0 ? (
<Section title={`${failed.length} could not be released`}>
<DrawerCard>
{failed.map((entry) => (
<Row
key={entry.product.productid}
label={entry.product.productname ?? `#${entry.product.productid}`}
value={entry.reason ?? 'Failed'}
isStacked
/>
))}
</DrawerCard>
</Section>
) : (
<Note icon={<Check size={15} />} tone="brand">
{branchCount === 1
? 'Every one is on sale. Prices can still be changed from this list at any time.'
: `Every one is on sale at all ${branchCount} branches. Prices can still be changed from this list at any time.`}
</Note>
)}
</VStack>
</div>
</>
</>
)}
</Drawer>
);
}

View File

@@ -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);
});

View File

@@ -8,17 +8,25 @@ import { effectivePrice } from './productState';
* 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.
* nothing to restore them from. That is not hypothetical: R mart's import on
* 2026-08-31 was ₹395 rice, ₹30 crisps and ₹105 cleaner, and the dialog used to
* prefill one field from the first row.
*
* The rule for a BATCH: a product that has a price is released at its own, and
* the typed figure fills in only for one that has none — which after a
* spreadsheet import is usually nothing at all.
* ── One field per product ───────────────────────────────────────────────────
*
* The rule for ONE product is the opposite, and it has to be: "Change price"
* opens on a single product, and a form whose typed value is discarded because
* the field already had a value is not a form. That was the bug — a price could
* be set once and never corrected, because `own > 0` sent the old figure back
* every time.
* The shape that removes the hazard rather than guarding against it: every
* selected product has its OWN field, prefilled with its OWN price, and a figure
* typed into one row cannot reach another because there is nowhere for it to go.
* The earlier design — one shared field plus a rule about when it applied — was
* correct in the end but had to be got right; this one cannot be got wrong.
*
* ── The field is the value ──────────────────────────────────────────────────
*
* What a row shows is what will be written. An empty field is not "keep what it
* had", it is no price — and a product with no price is not released at all.
* That is the whole point of `unpricedIn`: the backend refuses `price <= 0`
* anyway ("a product with no price cannot be sold at a till"), and a product
* released at ₹0 could be ordered for nothing.
*/
export interface PublishLine {
productid: number;
@@ -26,26 +34,74 @@ export interface PublishLine {
taxpercent: number;
}
export function planPublish(
products: readonly Product[],
typed: { price: number; taxpercent: number },
): PublishLine[] {
// One product is an edit of that product: the merchant is looking at its
// price, has typed another, and means it.
const isSingle = products.length === 1;
/** One row's fields, exactly as typed — strings, because a field can be empty. */
export interface TypedPrice {
price: string;
taxpercent: string;
}
/** What a row's price field starts at: the product's own price, or empty. */
export function priceFieldFor(product: Product): string {
const own = effectivePrice(product);
return own > 0 ? String(own) : '';
}
/** What a row's tax field starts at. Zero is a real rate and is shown as one. */
export function taxFieldFor(product: Product): string {
const own = Number(product.taxpercent ?? NaN);
return Number.isFinite(own) && own >= 0 ? String(own) : '';
}
/** The number a field holds, or NaN when it holds nothing usable. */
function parse(field: string | undefined): number {
if (field === undefined || field.trim() === '') return NaN;
const value = Number(field);
return Number.isFinite(value) ? value : NaN;
}
/**
* The calls to make, one per product, from what each row holds.
*
* A row nobody touched still carries its prefill, so "release everything as it
* stands" needs no typing at all.
*/
export function planPublishEach(
products: readonly Product[],
typed: ReadonlyMap<number, TypedPrice>,
): PublishLine[] {
return products.map((product) => {
const own = effectivePrice(product);
const ownTax = Number(product.taxpercent ?? NaN);
const row = typed.get(product.productid);
const price = parse(row?.price ?? priceFieldFor(product));
const tax = parse(row?.taxpercent ?? taxFieldFor(product));
return {
productid: product.productid,
price: isSingle || own <= 0 ? typed.price : own,
taxpercent: isSingle || !Number.isFinite(ownTax) ? typed.taxpercent : ownTax,
price: Number.isFinite(price) ? price : 0,
// An empty tax field means zero-rated, which is a real rate for
// unpackaged staples — not a reason to refuse the release.
taxpercent: Number.isFinite(tax) && tax >= 0 ? tax : 0,
};
});
}
/** The products that still need a price typed in — usually none. */
/**
* The selected products that cannot be released yet, and why they cannot.
*
* Anything this returns blocks the whole release rather than being skipped:
* releasing eight of ten and saying so afterwards leaves the merchant to work
* out which two, when the two are visible on screen right now.
*/
export function unpricedIn(
products: readonly Product[],
typed: ReadonlyMap<number, TypedPrice>,
): Product[] {
return products.filter((product) => {
const row = typed.get(product.productid);
const price = parse(row?.price ?? priceFieldFor(product));
return !Number.isFinite(price) || price <= 0;
});
}
/** The products that arrive with no price of their own — the ones to type in. */
export function needingPrice(products: readonly Product[]): Product[] {
return products.filter((product) => effectivePrice(product) <= 0);
}