store user login
This commit is contained in:
464
src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
Normal file
464
src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { Download, Upload } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { offlineSalesApi, type OfflineSalesUploadResponse } from '@/api/offlineSales';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { count, money } from '../format';
|
||||
import { Drawer } from '../Drawer';
|
||||
import {
|
||||
buildTemplateWorkbook,
|
||||
parseSalesWorkbook,
|
||||
summarise,
|
||||
templateFilename,
|
||||
toBills,
|
||||
type ParsedSheet,
|
||||
} from './salesSheet';
|
||||
|
||||
/**
|
||||
* Counter sales, from a spreadsheet.
|
||||
*
|
||||
* Three steps, in the order the person does them: download the workbook, fill
|
||||
* in what sold, upload it back. The middle step happens in Excel, so the drawer
|
||||
* has to survive being left open for an hour — which is why nothing here is
|
||||
* timed and the template refetches on open rather than being cached.
|
||||
*
|
||||
* The preview between upload and import is the point of the whole screen: an
|
||||
* import moves stock, and a row that would move the wrong stock has to be
|
||||
* visible before it does, not reported afterwards.
|
||||
*/
|
||||
export function CounterSalesDrawer({
|
||||
tenantid,
|
||||
/** Set for a Store user — pins the upload to their branch. */
|
||||
locationid,
|
||||
branchIds,
|
||||
onClose,
|
||||
}: {
|
||||
tenantid: number;
|
||||
locationid?: number;
|
||||
branchIds: number[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const client = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [readError, setReadError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
|
||||
|
||||
/**
|
||||
* `staleTime: 0` on purpose. A template is only useful if its stock figures
|
||||
* and product list match the shops right now — an hour-old one sends someone
|
||||
* to fill in a row for a product that has since been withdrawn.
|
||||
*/
|
||||
const template = useQuery({
|
||||
queryKey: ['saleTemplate', tenantid, locationid ?? 0],
|
||||
queryFn: () => offlineSalesApi.template(tenantid, locationid),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const rows = parsed?.rows ?? [];
|
||||
const totals = useMemo(() => summarise(rows), [rows]);
|
||||
const isBlocked = (parsed?.fatal.length ?? 0) > 0 || totals.errors > 0;
|
||||
|
||||
async function onFile(next: File | null) {
|
||||
setFile(next);
|
||||
setParsed(null);
|
||||
setReadError(null);
|
||||
if (!next) return;
|
||||
try {
|
||||
const buffer = await next.arrayBuffer();
|
||||
setParsed(
|
||||
parseSalesWorkbook(buffer, {
|
||||
tenantid,
|
||||
...(locationid ? { locationid } : {}),
|
||||
allowedLocationIds: branchIds,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
setReadError('That file could not be read. Upload the .xlsx template you downloaded.');
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!template.data) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
XLSX.writeFile(buildTemplateWorkbook(template.data), templateFilename(template.data, today));
|
||||
}
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: () =>
|
||||
offlineSalesApi.upload({
|
||||
tenantid,
|
||||
// Sent even when it is 0. At 0 each bill goes to the branch its own row
|
||||
// names; set, it refuses every bill naming another branch — the one
|
||||
// server-side guard that holds a shop to its own store.
|
||||
locationid: locationid ?? 0,
|
||||
userid: user?.userid ?? 0,
|
||||
bills: toBills(rows),
|
||||
}),
|
||||
onSuccess: async (response) => {
|
||||
setResult(response);
|
||||
// An import moves stock and creates orders, so everything counting either
|
||||
// is stale. Cheaper to refetch broadly than to reason about which page
|
||||
// the operator opens next.
|
||||
await client.invalidateQueries();
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Drawer title="Import finished" width={620} onClose={onClose}>
|
||||
<div className="form-grid">
|
||||
<Figure label="Imported" value={count(result.imported)} isStrong />
|
||||
<Figure label="Already done" value={count(result.duplicate)} />
|
||||
<Figure label="Failed" value={count(result.failed)} />
|
||||
<Figure label="Recorded" value={money(result.totalamount)} />
|
||||
</div>
|
||||
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{result.imported > 0
|
||||
? 'Stock has come off the shelf and the sales are in Orders, marked offline.'
|
||||
: 'Nothing was imported, so no stock moved.'}
|
||||
{result.duplicate > 0
|
||||
? ' Bills marked “already done” were uploaded before — their stock was not deducted twice.'
|
||||
: ''}
|
||||
</Text>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Store</Th>
|
||||
<Th>Bill</Th>
|
||||
<Th>Result</Th>
|
||||
<Th>Items</Th>
|
||||
<Th>Amount</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.results.map((entry, index) => (
|
||||
<tr key={`${entry.locationid}-${entry.billno}-${index}`}>
|
||||
<Td>{entry.locationname || `Branch ${entry.locationid}`}</Td>
|
||||
<Td>{entry.billno || '—'}</Td>
|
||||
<Td>
|
||||
<ResultChip status={entry.status} message={entry.message} />
|
||||
</Td>
|
||||
<Td>{count(entry.itemcount ?? 0)}</Td>
|
||||
<Td>{money(entry.amount ?? 0)}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<HStack gap={1} justify="end">
|
||||
<Button
|
||||
label="Upload another file"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
/>
|
||||
<Button label="Done" variant="primary" size="sm" onClick={onClose} />
|
||||
</HStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Upload counter sales"
|
||||
subtitle={
|
||||
locationid
|
||||
? 'Sales rung up at your counter, so the stock comes off the shelf.'
|
||||
: 'One file covers every branch — each row says which store it belongs to.'
|
||||
}
|
||||
width={720}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* ── 1. The workbook ────────────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Step number={1} title="Download the workbook" />
|
||||
<HStack gap={1.5} align="center" wrap="wrap">
|
||||
<Button
|
||||
label={template.isLoading ? 'Preparing…' : 'Download template'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download size={13} />}
|
||||
isDisabled={!template.data || template.isLoading}
|
||||
onClick={download}
|
||||
/>
|
||||
{template.data ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{count(template.data.products.length)} products ·{' '}
|
||||
{count(template.data.locations.length)} store
|
||||
{template.data.locations.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
{template.isError ? (
|
||||
<Problem message={errorMessage(template.error)} />
|
||||
) : (
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
Fill in the <strong>qtysold</strong> column and upload the file back. It has to be this
|
||||
workbook — the product ids are already in it, and nothing else identifies a product.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* ── 2. The filled-in file ──────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Step number={2} title="Upload it back" />
|
||||
<FileInput
|
||||
label="Filled-in workbook"
|
||||
isLabelHidden
|
||||
value={file}
|
||||
onChange={(next) => void onFile(Array.isArray(next) ? (next[0] ?? null) : next)}
|
||||
accept=".xlsx,.xls"
|
||||
mode="dropzone"
|
||||
placeholder="Drop the .xlsx file here, or click to choose"
|
||||
/>
|
||||
{readError ? <Problem message={readError} /> : null}
|
||||
{parsed?.fatal.map((message) => <Problem key={message} message={message} />)}
|
||||
</VStack>
|
||||
|
||||
{/* ── 3. What is in it ───────────────────────────────────────────── */}
|
||||
{parsed && parsed.fatal.length === 0 ? (
|
||||
<VStack gap={1.5}>
|
||||
<Step number={3} title="Check and import" />
|
||||
|
||||
<div className="form-grid">
|
||||
<Figure label="Bills" value={count(totals.bills)} isStrong />
|
||||
<Figure label="Lines" value={count(totals.lines)} />
|
||||
<Figure label="Units" value={count(totals.units)} />
|
||||
<Figure label="Amount" value={money(totals.amount)} isStrong />
|
||||
</div>
|
||||
|
||||
{totals.errors > 0 ? (
|
||||
<Problem
|
||||
message={`${count(totals.errors)} row${totals.errors === 1 ? '' : 's'} must be fixed in the file before this can be uploaded.`}
|
||||
/>
|
||||
) : null}
|
||||
{parsed.skipped > 0 ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{count(parsed.skipped)} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were
|
||||
ignored.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Row</Th>
|
||||
{locationid ? null : <Th>Store</Th>}
|
||||
<Th>Product</Th>
|
||||
<Th>Qty</Th>
|
||||
<Th>Price</Th>
|
||||
<Th>Bill</Th>
|
||||
<Th>Status</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.excelRow}>
|
||||
<Td isMuted>{row.excelRow}</Td>
|
||||
{locationid ? null : (
|
||||
<Td isMuted>{row.locationname || row.locationid}</Td>
|
||||
)}
|
||||
<Td>{row.productname || `#${row.productid}`}</Td>
|
||||
<Td>{row.qtysold}</Td>
|
||||
<Td>{row.unitprice ? money(row.unitprice) : '—'}</Td>
|
||||
<Td isMuted>{row.billno || '—'}</Td>
|
||||
<Td>
|
||||
<RowStatus errors={row.errors} warnings={row.warnings} />
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{upload.isError ? <Problem message={errorMessage(upload.error)} /> : null}
|
||||
|
||||
<HStack gap={1} justify="end" wrap="wrap">
|
||||
<Button
|
||||
label="Clear"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label={upload.isPending ? 'Importing…' : `Import ${count(totals.bills)} bills`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Upload size={13} />}
|
||||
isDisabled={isBlocked || upload.isPending || rows.length === 0}
|
||||
onClick={() => upload.mutate()}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Step({ number, title }: { number: number; title: string }) {
|
||||
return (
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value, isStrong }: { label: string; value: string; isStrong?: boolean }) {
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size={isStrong ? 'lg' : 'sm'}
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function RowStatus({ errors, warnings }: { errors: string[]; warnings: string[] }) {
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<span style={{ color: 'var(--color-error, #d64545)', fontSize: 12 }}>{errors.join(' · ')}</span>
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
return (
|
||||
<span style={{ color: 'var(--color-warning, #b7860b)', fontSize: 12 }}>
|
||||
{warnings.join(' · ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span style={{ color: 'var(--color-success, #10b981)', fontSize: 12 }}>ready</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A duplicate is neutral on purpose.
|
||||
*
|
||||
* A re-upload being refused is the safeguard working. Calling it an error would
|
||||
* push someone towards "fixing" it, and the fix they would reach for is
|
||||
* changing the bill number — which is exactly how stock gets deducted twice.
|
||||
*/
|
||||
function ResultChip({ status, message }: { status: string; message?: string }) {
|
||||
const value = status.toLowerCase();
|
||||
const color =
|
||||
value === 'imported'
|
||||
? 'var(--color-success, #10b981)'
|
||||
: value === 'duplicate'
|
||||
? 'var(--color-ink-3)'
|
||||
: 'var(--color-error, #d64545)';
|
||||
const label = value === 'duplicate' ? 'already done' : value;
|
||||
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<span style={{ color, fontSize: 12, fontWeight: 600 }}>{label}</span>
|
||||
{message ? (
|
||||
<span style={{ color: 'var(--color-ink-4)', fontSize: 11.5 }}>{message}</span>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ message }: { message: string }) {
|
||||
return (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)', lineHeight: 1.6 }}>
|
||||
{message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
isMuted,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isMuted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
163
src/features/store-admin/offlineSales/salesSheet.test.ts
Normal file
163
src/features/store-admin/offlineSales/salesSheet.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { SaleTemplate } from '@/api/offlineSales';
|
||||
import {
|
||||
buildTemplateWorkbook,
|
||||
parseSalesWorkbook,
|
||||
summarise,
|
||||
templateFilename,
|
||||
toBills,
|
||||
} from './salesSheet';
|
||||
|
||||
const TEMPLATE: SaleTemplate = {
|
||||
tenantid: 1135,
|
||||
locationid: 0,
|
||||
locations: [
|
||||
{ locationid: 1, locationname: 'R mart — RS Puram', productcount: 2 },
|
||||
{ locationid: 2, locationname: 'R mart — Peelamedu', productcount: 1 },
|
||||
],
|
||||
products: [
|
||||
{ tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', productid: 9001, productname: 'Aachi Chilli Powder 100gm', currentstock: 24, price: 35, taxpercent: 5 },
|
||||
{ tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', productid: 9004, productname: 'Amul Butter 500g', currentstock: 2, price: 265, taxpercent: 12 },
|
||||
{ tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', productid: 9007, productname: 'Nestle Milkmaid 400g', currentstock: 40, price: 160, taxpercent: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Round-trip helper: build the workbook, fill in cells, hand back a buffer. */
|
||||
function filled(edits: Record<number, Record<string, string | number>>): ArrayBuffer {
|
||||
const workbook = buildTemplateWorkbook(TEMPLATE);
|
||||
const sheet = workbook.Sheets['Sales'] as XLSX.WorkSheet;
|
||||
const header = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1 })[0] as string[];
|
||||
const column = (name: string) =>
|
||||
header.findIndex((cell) => String(cell).toLowerCase().startsWith(name));
|
||||
|
||||
for (const [row, values] of Object.entries(edits)) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const address = XLSX.utils.encode_cell({ r: Number(row) - 1, c: column(name) });
|
||||
sheet[address] = typeof value === 'number' ? { t: 'n', v: value } : { t: 's', v: value };
|
||||
}
|
||||
}
|
||||
const out = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
|
||||
return out;
|
||||
}
|
||||
|
||||
test('the workbook carries every product, ids filled in', () => {
|
||||
const workbook = buildTemplateWorkbook(TEMPLATE);
|
||||
assert.deepEqual(workbook.SheetNames, ['Sales', 'Store Info', 'Instructions']);
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['Sales'] as XLSX.WorkSheet, {
|
||||
header: 1,
|
||||
});
|
||||
assert.equal(rows.length, 4, 'header plus three products');
|
||||
assert.equal((rows[1] as unknown[])[3], 9001, 'productid is pre-filled');
|
||||
assert.equal((rows[1] as unknown[])[6], '', 'qtysold is left empty, never defaulted to 0');
|
||||
});
|
||||
|
||||
test('the filename says what the workbook spans', () => {
|
||||
assert.equal(templateFilename(TEMPLATE, '2026-08-25'), 'counter-sales-all-stores-2026-08-25.xlsx');
|
||||
assert.equal(
|
||||
templateFilename({ ...TEMPLATE, locations: [TEMPLATE.locations[0] as never] }, '2026-08-25'),
|
||||
'counter-sales-r-mart-rs-puram-2026-08-25.xlsx',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows with no quantity are skipped, not reported as problems', () => {
|
||||
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 3 } }), { tenantid: 1135 });
|
||||
assert.deepEqual(parsed.fatal, []);
|
||||
assert.equal(parsed.rows.length, 1);
|
||||
assert.equal(parsed.skipped, 2);
|
||||
assert.equal(parsed.rows[0]?.productid, 9001);
|
||||
});
|
||||
|
||||
test('a file from another account is refused whole', () => {
|
||||
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1 } }), { tenantid: 9999 });
|
||||
assert.ok(parsed.fatal[0]?.includes('different account'));
|
||||
});
|
||||
|
||||
test('selling more than the shelf holds is an error on that row', () => {
|
||||
const parsed = parseSalesWorkbook(filled({ 3: { qtysold: 5 } }), { tenantid: 1135 });
|
||||
assert.equal(parsed.rows.length, 1);
|
||||
assert.ok(parsed.rows[0]?.errors.some((problem) => problem.includes('only 2 in stock')));
|
||||
});
|
||||
|
||||
test('a pinned upload refuses rows belonging to another branch', () => {
|
||||
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1 }, 4: { qtysold: 1 } }), {
|
||||
tenantid: 1135,
|
||||
locationid: 1,
|
||||
});
|
||||
const foreign = parsed.rows.find((row) => row.locationid === 2);
|
||||
assert.ok(foreign, 'the row is still parsed, so the operator can see it');
|
||||
assert.ok(foreign?.errors.some((problem) => problem.includes('another store')));
|
||||
const own = parsed.rows.find((row) => row.locationid === 1);
|
||||
assert.deepEqual(own?.errors, []);
|
||||
});
|
||||
|
||||
test('an unpriced line is a warning, not a blocker', () => {
|
||||
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1, unitprice: 0 } }), {
|
||||
tenantid: 1135,
|
||||
});
|
||||
assert.deepEqual(parsed.rows[0]?.errors, []);
|
||||
assert.ok(parsed.rows[0]?.warnings.some((note) => note.includes('no price')));
|
||||
});
|
||||
|
||||
test('an unknown payment mode is caught before upload', () => {
|
||||
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1, paymentmode: 'cheque' } }), {
|
||||
tenantid: 1135,
|
||||
});
|
||||
assert.ok(parsed.rows[0]?.errors.some((problem) => problem.includes('not Cash, Card or UPI')));
|
||||
});
|
||||
|
||||
test('the same bill number at two branches stays two bills', () => {
|
||||
const parsed = parseSalesWorkbook(
|
||||
filled({ 2: { qtysold: 1, billno: '1' }, 4: { qtysold: 1, billno: '1' } }),
|
||||
{ tenantid: 1135 },
|
||||
);
|
||||
const bills = toBills(parsed.rows);
|
||||
assert.equal(bills.length, 2);
|
||||
assert.deepEqual(
|
||||
bills.map((bill) => bill.locationid).sort(),
|
||||
[1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test('rows sharing a bill number become one bill with its lines', () => {
|
||||
const parsed = parseSalesWorkbook(
|
||||
filled({ 2: { qtysold: 2, billno: 'A-7' }, 3: { qtysold: 1, billno: 'A-7' } }),
|
||||
{ tenantid: 1135 },
|
||||
);
|
||||
const bills = toBills(parsed.rows);
|
||||
assert.equal(bills.length, 1);
|
||||
assert.equal(bills[0]?.items.length, 2);
|
||||
assert.equal(bills[0]?.billno, 'A-7');
|
||||
});
|
||||
|
||||
test('bill-level fields are taken from whichever row carries them', () => {
|
||||
const parsed = parseSalesWorkbook(
|
||||
filled({
|
||||
2: { qtysold: 1, billno: 'B-2' },
|
||||
3: { qtysold: 1, billno: 'B-2', paymentmode: 'UPI', customername: 'Kavitha' },
|
||||
}),
|
||||
{ tenantid: 1135 },
|
||||
);
|
||||
const bill = toBills(parsed.rows)[0];
|
||||
assert.equal(bill?.paymentmode, 'UPI');
|
||||
assert.equal(bill?.customername, 'Kavitha');
|
||||
});
|
||||
|
||||
test('the preview total is unit price by quantity, less discount', () => {
|
||||
const parsed = parseSalesWorkbook(
|
||||
filled({ 2: { qtysold: 4, discountamount: 20 }, 4: { qtysold: 2 } }),
|
||||
{ tenantid: 1135 },
|
||||
);
|
||||
const totals = summarise(parsed.rows);
|
||||
assert.equal(totals.lines, 2);
|
||||
assert.equal(totals.units, 6);
|
||||
assert.equal(totals.amount, 35 * 4 - 20 + 160 * 2);
|
||||
assert.equal(totals.stores, 2);
|
||||
assert.equal(totals.bills, 2, 'no bill number means one bill per branch');
|
||||
});
|
||||
|
||||
test('a file with nothing filled in says so rather than uploading nothing', () => {
|
||||
const parsed = parseSalesWorkbook(filled({}), { tenantid: 1135 });
|
||||
assert.ok(parsed.fatal[0]?.includes('No sold quantities'));
|
||||
});
|
||||
555
src/features/store-admin/offlineSales/salesSheet.ts
Normal file
555
src/features/store-admin/offlineSales/salesSheet.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type {
|
||||
OfflineSaleBillInput,
|
||||
SaleTemplate,
|
||||
SaleTemplateRow,
|
||||
} from '@/api/offlineSales';
|
||||
|
||||
/**
|
||||
* The spreadsheet half of counter-sales import: turning a merchant's catalogue
|
||||
* into a workbook the shops fill in, and turning that workbook back into bills
|
||||
* the API will take.
|
||||
*
|
||||
* **One workbook covers every branch.** Each row carries its own `tenantid` and
|
||||
* `locationid`, and that row's `locationid` decides which branch the sale comes
|
||||
* out of. A merchant running six outlets downloads one file; rows for all six
|
||||
* can be filled in and uploaded together. Nobody picks a store in the UI,
|
||||
* because the sheet already says which store each line is for.
|
||||
*
|
||||
* **The template must be downloaded, not typed.** `productid` is the only field
|
||||
* that identifies a product: across the live catalogue 6,245 products share 93
|
||||
* distinct SKU values — one tenant has 463 products all carrying sku "1" — and
|
||||
* names are not unique either. So the sheet is generated with the ids already
|
||||
* filled in, and the parser matches on nothing else.
|
||||
*
|
||||
* Parsing happens in the browser so the operator sees every problem laid out
|
||||
* against their own rows and can fix the file before anything is written. The
|
||||
* backend re-validates all of it; this is for the person, not for safety.
|
||||
*/
|
||||
|
||||
/** Parsing looks for this sheet by name, then falls back to the first one, so a
|
||||
* file re-saved by Excel under a translated name still works. */
|
||||
export const SALES_SHEET = 'Sales';
|
||||
export const INFO_SHEET = 'Store Info';
|
||||
const HELP_SHEET = 'Instructions';
|
||||
|
||||
/** Bumped only when the column set changes in a way an old file would break on. */
|
||||
export const TEMPLATE_VERSION = 2;
|
||||
|
||||
/**
|
||||
* The columns, in order. The first six are locked reference data —
|
||||
* `locationid` among them, since it routes the sale — and `qtysold` is the one
|
||||
* the operator is expected to type in.
|
||||
*/
|
||||
const COLUMNS = [
|
||||
'tenantid',
|
||||
'locationid',
|
||||
'locationname',
|
||||
'productid',
|
||||
'productname',
|
||||
'currentstock',
|
||||
'qtysold',
|
||||
'unitprice',
|
||||
'discountamount',
|
||||
'taxpercent',
|
||||
'billno',
|
||||
'saledate',
|
||||
'paymentmode',
|
||||
'customername',
|
||||
'customermobile',
|
||||
'remarks',
|
||||
] as const;
|
||||
|
||||
const COLUMN_WIDTHS = [10, 11, 22, 11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24];
|
||||
|
||||
const HEADER_LABELS: Record<string, string> = {
|
||||
tenantid: 'tenantid (do not edit)',
|
||||
locationid: 'locationid (do not edit)',
|
||||
locationname: 'store (do not edit)',
|
||||
productid: 'productid (do not edit)',
|
||||
productname: 'productname (do not edit)',
|
||||
currentstock: 'currentstock (info)',
|
||||
qtysold: 'qtysold *',
|
||||
unitprice: 'unitprice',
|
||||
discountamount: 'discountamount',
|
||||
taxpercent: 'taxpercent',
|
||||
billno: 'billno',
|
||||
saledate: 'saledate',
|
||||
paymentmode: 'paymentmode',
|
||||
customername: 'customername',
|
||||
customermobile: 'customermobile',
|
||||
remarks: 'remarks',
|
||||
};
|
||||
|
||||
const INSTRUCTIONS: (string | number)[][] = [
|
||||
['How to record counter sales'],
|
||||
[''],
|
||||
['This ONE file covers every one of your stores.'],
|
||||
['Each row already says which store it belongs to, in the locationid and store columns.'],
|
||||
[''],
|
||||
['1.', 'Fill in the "qtysold" column for whatever was sold at the counter.'],
|
||||
['', 'Leave the row blank or 0 if it did not sell — blank rows are ignored.'],
|
||||
['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'],
|
||||
['', 'They identify the store and the product, and must match.'],
|
||||
['3.', 'unitprice defaults to that store price. Change it if you sold at a different price.'],
|
||||
['4.', 'billno groups rows into one bill. Rows sharing a billno become one order.'],
|
||||
['', 'Bill numbers only need to be unique within a store.'],
|
||||
['5.', 'saledate accepts YYYY-MM-DD. Blank means today.'],
|
||||
['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'],
|
||||
['7.', 'taxpercent is treated as already included in unitprice, so the amount collected'],
|
||||
['', 'stays exactly unitprice x qtysold minus any discount.'],
|
||||
[''],
|
||||
['Uploading the same file twice is safe — a repeated bill is reported as already'],
|
||||
['imported and its stock is NOT deducted a second time.'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the workbook.
|
||||
*
|
||||
* Every stocked product gets a row, even at zero stock: this is a worksheet to
|
||||
* fill in, and hiding a row would mean an operator could not record a sale they
|
||||
* actually made.
|
||||
*/
|
||||
export function buildTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
|
||||
const header = COLUMNS.map((column) => HEADER_LABELS[column] ?? column);
|
||||
|
||||
const body = template.products.map((product: SaleTemplateRow) => [
|
||||
product.tenantid,
|
||||
product.locationid,
|
||||
product.locationname,
|
||||
product.productid,
|
||||
product.productname,
|
||||
product.currentstock,
|
||||
// qtysold onward are the operator's columns, left empty: pre-filling
|
||||
// qtysold with 0 invites a file of accidental zero-quantity rows.
|
||||
'',
|
||||
product.price > 0 ? product.price : '',
|
||||
'',
|
||||
product.taxpercent > 0 ? product.taxpercent : '',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
]);
|
||||
|
||||
const sales = XLSX.utils.aoa_to_sheet([header, ...body]);
|
||||
sales['!cols'] = COLUMN_WIDTHS.map((width) => ({ wch: width }));
|
||||
sales['!autofilter'] = {
|
||||
ref: XLSX.utils.encode_range({
|
||||
s: { r: 0, c: 0 },
|
||||
e: { r: body.length, c: COLUMNS.length - 1 },
|
||||
}),
|
||||
};
|
||||
|
||||
const info = XLSX.utils.aoa_to_sheet([
|
||||
['Field', 'Value'],
|
||||
['tenantid', template.tenantid],
|
||||
['templateversion', TEMPLATE_VERSION],
|
||||
['stores in this file', template.locations.length],
|
||||
[''],
|
||||
['Stores covered', 'Products'],
|
||||
...template.locations.map((location) => [
|
||||
`${location.locationid} — ${location.locationname}`,
|
||||
location.productcount,
|
||||
]),
|
||||
[''],
|
||||
['Do not edit this sheet.'],
|
||||
]);
|
||||
info['!cols'] = [{ wch: 34 }, { wch: 42 }];
|
||||
|
||||
const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS);
|
||||
help['!cols'] = [{ wch: 4 }, { wch: 92 }];
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sales, SALES_SHEET);
|
||||
XLSX.utils.book_append_sheet(workbook, info, INFO_SHEET);
|
||||
XLSX.utils.book_append_sheet(workbook, help, HELP_SHEET);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
/** One store by name, or "all-stores", plus the date so a folder stays sortable. */
|
||||
export function templateFilename(template: SaleTemplate, today: string): string {
|
||||
const single = template.locations.length === 1 ? (template.locations[0]?.locationname ?? '') : '';
|
||||
const slug =
|
||||
(single || 'all-stores')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'all-stores';
|
||||
return `counter-sales-${slug}-${today}.xlsx`;
|
||||
}
|
||||
|
||||
/* ── Parsing ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface ParsedSaleRow {
|
||||
/** 1-based row number as Excel shows it, so a problem can be pointed at. */
|
||||
excelRow: number;
|
||||
tenantid: number | null;
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
productid: number;
|
||||
productname: string;
|
||||
currentstock: number | null;
|
||||
qtysold: number;
|
||||
unitprice: number | null;
|
||||
discountamount: number;
|
||||
taxpercent: number | null;
|
||||
billno: string;
|
||||
saledate: string;
|
||||
paymentmode: string;
|
||||
customername: string;
|
||||
customermobile: string;
|
||||
remarks: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedSheet {
|
||||
tenantid: number | null;
|
||||
/** Rows with a quantity. Blank ones are dropped, not reported as problems. */
|
||||
rows: ParsedSaleRow[];
|
||||
/** Rows skipped for having no quantity — counted so "the column was empty"
|
||||
* can be told apart from "the file had two sales in it". */
|
||||
skipped: number;
|
||||
/** Problems with the file as a whole rather than with a row. */
|
||||
fatal: string[];
|
||||
}
|
||||
|
||||
export interface ParseScope {
|
||||
tenantid: number;
|
||||
/** Set for a Store user: rows for any other branch are refused. */
|
||||
locationid?: number;
|
||||
/** Branches the uploader may write to, for naming a stray id usefully. */
|
||||
allowedLocationIds?: number[];
|
||||
}
|
||||
|
||||
const PAYMENT_MODES = new Set(['cash', 'card', 'upi']);
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
const parsed = Number(String(value).trim().replace(/,/g, ''));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toText(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A date cell arrives as a real Date, a serial number, or text depending on how
|
||||
* the operator's Excel was configured. All three become YYYY-MM-DD so the
|
||||
* backend sees one format.
|
||||
*/
|
||||
function toDate(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === 'number') {
|
||||
const parsed = XLSX.SSF?.parse_date_code?.(value);
|
||||
if (parsed && parsed.y) {
|
||||
return `${parsed.y}-${String(parsed.m).padStart(2, '0')}-${String(parsed.d).padStart(2, '0')}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/** Match a header cell back to a column, tolerating "(do not edit)" and "*". */
|
||||
function normaliseHeader(raw: unknown): string {
|
||||
return toText(raw)
|
||||
.toLowerCase()
|
||||
.replace(/\(.*?\)/g, '')
|
||||
.replace(/[^a-z]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook.
|
||||
*
|
||||
* Never throws for a row-level problem — those are attached to the row so the
|
||||
* whole sheet can be shown at once, which is the point of parsing here. Only a
|
||||
* file that cannot be read, or has no recognisable columns, is fatal.
|
||||
*/
|
||||
export function parseSalesWorkbook(data: ArrayBuffer, scope: ParseScope): ParsedSheet {
|
||||
const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] };
|
||||
|
||||
let workbook: XLSX.WorkBook;
|
||||
try {
|
||||
workbook = XLSX.read(data, { cellDates: true });
|
||||
} catch {
|
||||
out.fatal.push('That file could not be read as a spreadsheet. Upload the .xlsx template.');
|
||||
return out;
|
||||
}
|
||||
|
||||
const infoSheet = workbook.Sheets[INFO_SHEET];
|
||||
if (infoSheet) {
|
||||
const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, {
|
||||
header: 1,
|
||||
blankrows: false,
|
||||
});
|
||||
for (const row of infoRows) {
|
||||
if (toText(row?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNumber(row?.[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (out.tenantid !== null && out.tenantid !== scope.tenantid) {
|
||||
out.fatal.push(
|
||||
`This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
|
||||
);
|
||||
}
|
||||
|
||||
const firstName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[SALES_SHEET] ?? (firstName ? workbook.Sheets[firstName] : undefined);
|
||||
if (!sheet) {
|
||||
out.fatal.push('The workbook has no sheets.');
|
||||
return out;
|
||||
}
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
||||
header: 1,
|
||||
blankrows: false,
|
||||
defval: '',
|
||||
});
|
||||
if (grid.length < 2) {
|
||||
out.fatal.push('The Sales sheet has no rows to import.');
|
||||
return out;
|
||||
}
|
||||
|
||||
const index: Record<string, number> = {};
|
||||
(grid[0] ?? []).forEach((cellValue, position) => {
|
||||
const key = normaliseHeader(cellValue);
|
||||
if (key && index[key] === undefined) index[key] = position;
|
||||
});
|
||||
|
||||
if (index['productid'] === undefined || index['qtysold'] === undefined) {
|
||||
out.fatal.push(
|
||||
'The Sales sheet is missing the "productid" or "qtysold" column. Upload the downloaded template without renaming its columns.',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
if (index['locationid'] === undefined && !scope.locationid) {
|
||||
// Without it there is nothing to route a sale by, and guessing a branch
|
||||
// would silently move the wrong store's stock.
|
||||
out.fatal.push(
|
||||
'The Sales sheet is missing the "locationid" column, so there is no way to tell which store each sale belongs to. Download a fresh template.',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
const allowed = scope.allowedLocationIds?.length ? new Set(scope.allowedLocationIds) : null;
|
||||
const cell = (row: unknown[], key: string): unknown => {
|
||||
const position = index[key];
|
||||
return position === undefined ? '' : row[position];
|
||||
};
|
||||
|
||||
for (let i = 1; i < grid.length; i += 1) {
|
||||
const raw = grid[i] ?? [];
|
||||
const excelRow = i + 1;
|
||||
|
||||
const qty = toNumber(cell(raw, 'qtysold'));
|
||||
// Nothing sold on this line. Not an error — the template lists an entire
|
||||
// catalogue and most rows are expected to be empty.
|
||||
if (qty === null || qty === 0) {
|
||||
out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const productid = toNumber(cell(raw, 'productid'));
|
||||
const rowLocation = toNumber(cell(raw, 'locationid'));
|
||||
const unitprice = toNumber(cell(raw, 'unitprice'));
|
||||
const taxpercent = toNumber(cell(raw, 'taxpercent'));
|
||||
const discount = toNumber(cell(raw, 'discountamount')) ?? 0;
|
||||
const stock = toNumber(cell(raw, 'currentstock'));
|
||||
const paymentmode = toText(cell(raw, 'paymentmode'));
|
||||
const locationid = rowLocation ?? scope.locationid ?? 0;
|
||||
|
||||
const row: ParsedSaleRow = {
|
||||
excelRow,
|
||||
tenantid: toNumber(cell(raw, 'tenantid')),
|
||||
locationid,
|
||||
locationname: toText(cell(raw, 'locationname')),
|
||||
productid: productid ?? 0,
|
||||
productname: toText(cell(raw, 'productname')),
|
||||
currentstock: stock,
|
||||
qtysold: qty,
|
||||
unitprice,
|
||||
discountamount: discount,
|
||||
taxpercent,
|
||||
billno: toText(cell(raw, 'billno')),
|
||||
saledate: toDate(cell(raw, 'saledate')),
|
||||
paymentmode,
|
||||
customername: toText(cell(raw, 'customername')),
|
||||
customermobile: toText(cell(raw, 'customermobile')),
|
||||
remarks: toText(cell(raw, 'remarks')),
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
if (!productid || productid <= 0) {
|
||||
row.errors.push('productid is missing — do not delete that column');
|
||||
}
|
||||
if (!locationid || locationid <= 0) {
|
||||
row.errors.push('locationid is missing — this row does not say which store it belongs to');
|
||||
} else if (row.tenantid !== null && row.tenantid !== scope.tenantid) {
|
||||
row.errors.push(`tenantid ${row.tenantid} is not your account`);
|
||||
} else if (scope.locationid && locationid !== scope.locationid) {
|
||||
// The Store user guard. The backend enforces it too; saying it here means
|
||||
// they see it before uploading rather than as a rejected bill.
|
||||
row.errors.push('this row is for another store, which you cannot upload for');
|
||||
} else if (allowed && !allowed.has(locationid)) {
|
||||
row.errors.push(`locationid ${locationid} is not one of your stores`);
|
||||
}
|
||||
if (qty < 0) row.errors.push('qtysold cannot be negative');
|
||||
if (stock !== null && qty > stock) {
|
||||
row.errors.push(`only ${stock} in stock, ${qty} sold`);
|
||||
}
|
||||
if (discount < 0) row.errors.push('discountamount cannot be negative');
|
||||
if (unitprice !== null && unitprice > 0 && discount > unitprice * qty) {
|
||||
row.errors.push('discount is larger than the line total');
|
||||
}
|
||||
if (paymentmode && !PAYMENT_MODES.has(paymentmode.toLowerCase())) {
|
||||
row.errors.push(`paymentmode "${paymentmode}" is not Cash, Card or UPI`);
|
||||
}
|
||||
|
||||
// Warnings do not block. A zero price is legitimate — a free sample — but
|
||||
// is nearly always a forgotten cell, so it is worth saying.
|
||||
if (unitprice === null || unitprice <= 0) {
|
||||
row.warnings.push('no price — this sale will record no revenue');
|
||||
}
|
||||
if (!Number.isInteger(qty)) row.warnings.push('fractional quantity');
|
||||
|
||||
out.rows.push(row);
|
||||
}
|
||||
|
||||
if (out.rows.length === 0 && out.fatal.length === 0) {
|
||||
out.fatal.push('No sold quantities found. Fill in the "qtysold" column for at least one row.');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group rows into bills.
|
||||
*
|
||||
* Keyed on BRANCH first and bill number second, so the same bill number at two
|
||||
* stores stays two sales rather than colliding — counter books at different
|
||||
* outlets routinely restart numbering from 1.
|
||||
*
|
||||
* Rows with no bill number collapse into one unnumbered bill per branch rather
|
||||
* than one bill per row: a sheet where the operator ignored the column is one
|
||||
* shopping trip far more often than fifty, and one bill per row would also mean
|
||||
* one order per row cluttering the order list.
|
||||
*/
|
||||
export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] {
|
||||
const groups = new Map<string, ParsedSaleRow[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = billKey(row);
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) bucket.push(row);
|
||||
else groups.set(key, [row]);
|
||||
}
|
||||
|
||||
return [...groups.values()].map((group) => {
|
||||
const head = group[0] as ParsedSaleRow;
|
||||
// Bill-level fields belong to the bill, not the line: taking the first
|
||||
// non-empty value means the operator fills them in on the first row only,
|
||||
// which is how people actually fill these in.
|
||||
const first = (pick: (row: ParsedSaleRow) => string): string =>
|
||||
group.map(pick).find((value) => value !== '') ?? '';
|
||||
|
||||
return {
|
||||
locationid: head.locationid,
|
||||
billno: head.billno.trim(),
|
||||
saledate: first((row) => row.saledate),
|
||||
paymentmode: first((row) => row.paymentmode),
|
||||
customername: first((row) => row.customername),
|
||||
customermobile: first((row) => row.customermobile),
|
||||
remarks: first((row) => row.remarks),
|
||||
items: group.map((row) => ({
|
||||
productid: row.productid,
|
||||
productname: row.productname || undefined,
|
||||
qtysold: row.qtysold,
|
||||
unitprice: row.unitprice ?? undefined,
|
||||
discountamount: row.discountamount || undefined,
|
||||
taxpercent: row.taxpercent ?? undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function billKey(row: ParsedSaleRow): string {
|
||||
return `${row.locationid}::${row.billno.trim().toUpperCase() || '__nobill__'}`;
|
||||
}
|
||||
|
||||
export interface SaleSummary {
|
||||
lines: number;
|
||||
units: number;
|
||||
amount: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
bills: number;
|
||||
stores: number;
|
||||
byStore: {
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
lines: number;
|
||||
units: number;
|
||||
amount: number;
|
||||
errors: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals for the preview.
|
||||
*
|
||||
* The arithmetic mirrors the backend's — tax inclusive — so the figure shown
|
||||
* before the upload is the figure that gets recorded.
|
||||
*/
|
||||
export function summarise(rows: ParsedSaleRow[]): SaleSummary {
|
||||
let units = 0;
|
||||
let amount = 0;
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
const bills = new Set<string>();
|
||||
const stores = new Map<number, SaleSummary['byStore'][number]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const lineAmount = Math.max(0, (row.unitprice ?? 0) * row.qtysold - row.discountamount);
|
||||
units += row.qtysold;
|
||||
amount += lineAmount;
|
||||
if (row.errors.length) errors += 1;
|
||||
if (row.warnings.length) warnings += 1;
|
||||
bills.add(billKey(row));
|
||||
|
||||
let store = stores.get(row.locationid);
|
||||
if (!store) {
|
||||
store = {
|
||||
locationid: row.locationid,
|
||||
locationname: row.locationname,
|
||||
lines: 0,
|
||||
units: 0,
|
||||
amount: 0,
|
||||
errors: 0,
|
||||
};
|
||||
stores.set(row.locationid, store);
|
||||
}
|
||||
store.lines += 1;
|
||||
store.units += row.qtysold;
|
||||
store.amount += lineAmount;
|
||||
if (row.errors.length) store.errors += 1;
|
||||
if (!store.locationname && row.locationname) store.locationname = row.locationname;
|
||||
}
|
||||
|
||||
return {
|
||||
lines: rows.length,
|
||||
units,
|
||||
amount,
|
||||
errors,
|
||||
warnings,
|
||||
bills: bills.size,
|
||||
stores: stores.size,
|
||||
byStore: [...stores.values()].sort((a, b) => b.amount - a.amount),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user