test bugs fixed

This commit is contained in:
2026-08-28 18:18:50 +05:30
parent 670d193f36
commit 32c3bef3ad
6 changed files with 598 additions and 473 deletions

View File

@@ -296,8 +296,17 @@ export function CatalogueDetailDrawer({
options={categoryOptions}
value={categoryid}
onChange={(value) => onCategoryChange?.(value)}
placeholder="Leave uncategorised"
description="Your own category, not the catalogue's. Uncategorised beats wrongly categorised."
/* No "leave uncategorised" placeholder, and the advice that
used to sit here — "uncategorised beats wrongly categorised"
— is gone because it is false. A product filed under
category 0 is returned by NO query the customer app makes:
its endpoint rejects categoryid 0 outright. Seven products
across three tenants sat invisible that way, six outlets
showing shoppers an empty shop, while the console listed
their stock as normal. A wrongly filed product is at least
findable and fixable; an unfiled one was neither. */
placeholder="Choose a category"
description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all."
/>
) : null}
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>

View File

@@ -10,13 +10,14 @@ import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
import type { SheetProductRow } from '@/api/products';
import {
isStorageFailure,
pollJob,
previewSheet,
submitIngest,
ACCEPTED_EXTENSIONS,
isIncomplete,
isSettled,
pollBatch,
progressOf,
submitBatch,
summarise,
type IngestJob,
type IngestPreview,
type IngestBatch,
} from '@/api/ingest';
import { errorMessage } from '@/api/client';
import { SectionHeader } from '@/components/SectionHeader';
@@ -61,10 +62,8 @@ export function SheetImportPanel() {
* bar that invented a position would be lying, so it says what is happening
* and how many rows are in flight instead.
*/
/** The service's own dry run. Authoritative about what it will read. */
const [dryRun, setDryRun] = useState<IngestPreview | null>(null);
/** The job while it runs, and after it settles. */
const [job, setJob] = useState<IngestJob | null>(null);
/** The batch while it runs, and after it settles. */
const [batch, setBatch] = useState<IngestBatch | null>(null);
const [isWorking, setIsWorking] = useState(false);
@@ -73,8 +72,7 @@ export function SheetImportPanel() {
setFile(chosen);
setParsed(null);
setParseError(null);
setJob(null);
setDryRun(null);
setBatch(null);
if (!chosen) return;
try {
@@ -85,41 +83,30 @@ export function SheetImportPanel() {
}
/**
* Ask the service what it would read, without writing anything.
*
* `/preview` parses the sheet and reports the column mapping and the first
* rows. Worth doing every time: a header the service does not recognise is
* dropped SILENTLY, so a price column it never saw looks identical to a
* successful import until somebody opens the catalogue.
*/
async function handlePreview() {
if (!file) return;
setParseError(null);
setIsWorking(true);
try {
setDryRun(await previewSheet(file));
} catch (cause) {
setParseError(errorMessage(cause));
} finally {
setIsWorking(false);
}
}
/**
* Submit, then poll until it settles.
* Submit, then poll until the batch settles.
*
* The FILE goes up, not the parsed rows — the service does its own parsing
* and enrichment and can only do that from the original document. The local
* parse still runs, but only to fill the preview table.
* parse still runs, but only to fill the table above.
*
* There is no dry run any more. The batch endpoint has no `/preview` — a POST
* to it answers 405, because the path matches the GET that reads a batch by
* id. Only the older `/api/admin/store-catalog` route offers one, and it is
* gated on the `admin` role while the console's key is an `uploader`. So the
* local parse above is the only look before sending, and it reads the sheet
* with our rules rather than the service's.
*/
async function handleImport() {
if (!file) return;
setParseError(null);
setIsWorking(true);
try {
const submitted = await submitIngest({ file });
setJob(submitted);
setJob(await pollJob(submitted.job_id, setJob));
// Sent as a one-file batch. The endpoint takes up to twenty, and the
// client already supports that — the dropzone is what takes one at a
// time, and widening it is a separate change.
const submitted = await submitBatch({ files: [file] });
setBatch(submitted);
setBatch(await pollBatch(submitted.batch_id, setBatch));
} catch (cause) {
setParseError(errorMessage(cause));
} finally {
@@ -192,132 +179,142 @@ export function SheetImportPanel() {
/* ── Finished ─────────────────────────────────────────────────────────── */
if (job && (job.status === 'done' || job.status === 'failed')) {
const outcome = job.result;
const broken = isStorageFailure(job);
if (batch && isSettled(batch)) {
const { totals } = batch;
const broken = isIncomplete(batch);
/* Every file the service refused, with the reason it gave for each. A file
that could not be read stays in the batch rather than being dropped, so
this is where a sender learns what became of it. */
const refused = batch.files.filter((entry) => entry.status === 'failed');
return (
<Card padding={4} variant="transparent">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
{broken ? (
{batch.status === 'failed' ? (
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
) : (outcome?.error_count ?? 0) + (outcome?.rejected ?? 0) > 0 ? (
) : broken ? (
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
) : (
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
)}
<Text type="large" weight="semibold">
{summarise(job)}
{summarise(batch)}
</Text>
</HStack>
{outcome ? (
<>
<Text type="body" color="secondary">
{outcome.rows_total} sheet row{outcome.rows_total === 1 ? '' : 's'} became{' '}
{outcome.products_built} product{outcome.products_built === 1 ? '' : 's'}
{outcome.products_built > outcome.rows_total
? ' — a cell listing several pack sizes becomes one product each.'
: '.'}
{outcome.brands.length > 0 ? ` Brands touched: ${outcome.brands.join(', ')}.` : ''}
</Text>
<Text type="body" size="sm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
Batch {batch.batch_id} · {batch.status}
</Text>
{/* Headers the service did not recognise.
High on the panel because they are dropped silently. A price
column it never read looks exactly like a clean import. */}
{outcome.unrecognised_columns.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Ignored columns
</Text>
<Text type="body" size="sm" color="secondary">
{outcome.unrecognised_columns.join(', ')} — the service does not recognise these
headers, so nothing in them was read.
</Text>
</VStack>
) : null}
{outcome.errors.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{outcome.error_count} row{outcome.error_count === 1 ? '' : 's'} could not be
imported
</Text>
{outcome.errors.slice(0, 10).map((failure) => (
<HStack key={`e${failure.row}`} gap={1} align="center" wrap="wrap">
<Badge variant="error" label={`Row ${failure.row}`} />
<Text type="body" size="sm" color="secondary">
{failure.product_name ? `${failure.product_name} — ` : ''}
{failure.error}
</Text>
</HStack>
))}
{outcome.error_count > outcome.errors.length ? (
<Text type="body" size="xsm" color="secondary">
{outcome.error_count - outcome.errors.length} more not listed — the service
caps this list at 50.
</Text>
) : null}
</VStack>
) : null}
{outcome.rejections.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{outcome.rejected} built but rejected by validation
</Text>
{outcome.rejections.slice(0, 10).map((rejection, index) => (
<HStack key={`r${index}`} gap={1} align="center" wrap="wrap">
<Badge variant="error" label={rejection.size || '—'} />
<Text type="body" size="sm" color="secondary">
{rejection.product_name} — {rejection.reason}
</Text>
</HStack>
))}
</VStack>
) : null}
{outcome.warnings.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Corrections applied
</Text>
{outcome.warnings.slice(0, 10).map((warning, index) => (
<Text key={`w${index}`} type="body" size="sm" color="secondary">
{warning}
</Text>
))}
</VStack>
) : null}
{/* The catalogue is not the shelf. Said here because the screen
asks for a merchant and an outlet, which makes it look as
though the upload put something in their shop. It did not. */}
{!broken ? (
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
These are in the global catalogue. They are not yet on this outlet&rsquo;s shelf —
putting them there with a price and opening stock is a separate step, and it is
not wired up yet.
</Text>
) : null}
</>
) : (
{totals ? (
<Text type="body" color="secondary">
{job.detail ?? 'The service reported no detail.'}
{totals.rows_total} sheet row{totals.rows_total === 1 ? '' : 's'} became{' '}
{totals.products_built} product{totals.products_built === 1 ? '' : 's'}
{totals.products_built > totals.rows_total
? ' — a cell listing several pack sizes becomes one product each.'
: '.'}
</Text>
) : null}
{/* The way back into the catalogue. `brands` is the only identity the
batch returns — it reports counts, not product ids — so it is what
tells an operator where to go and look for what just arrived. */}
{batch.brands.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Brands touched
</Text>
<HStack gap={1} wrap="wrap">
{batch.brands.map((brand) => (
<Badge key={brand} variant="neutral" label={brand} />
))}
</HStack>
</VStack>
) : null}
{/* Named individually rather than counted. "1 of 2 files failed" does
not tell you which one to resend. */}
{refused.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{refused.length} file{refused.length === 1 ? '' : 's'} could not be read
</Text>
{refused.map((entry) => (
<HStack key={entry.index} gap={1} align="center" wrap="wrap">
<Badge variant="error" label={entry.filename} />
<Text type="body" size="sm" color="secondary">
{entry.detail ?? 'No reason given.'}
</Text>
</HStack>
))}
</VStack>
) : null}
{/* Headers the service did not recognise, per file.
High on the panel because they are dropped silently: a price
column it never read looks exactly like a clean import until
somebody opens the catalogue and finds everything unpriced. */}
{batch.files.map((entry) => {
const ignored = entry.result?.unrecognised_columns ?? [];
if (ignored.length === 0) return null;
return (
<VStack key={`u${entry.index}`} gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Ignored columns in {entry.filename}
</Text>
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{ignored.join(', ')} — nothing in these columns was read.
</Text>
</VStack>
);
})}
{/* Rows built but not stored. The counts populate either way, so
reading them without checking this reports an import that never
landed. */}
{batch.files.map((entry) =>
entry.result?.storage_error ? (
<Text key={`s${entry.index}`} type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{entry.filename}: rows were built but could not be stored — {entry.result.storage_error}
</Text>
) : null,
)}
<RawResponse payload={job} />
{(totals?.rejected ?? 0) > 0 ? (
<Text type="body" size="sm" color="secondary">
{totals.rejected} product{totals.rejected === 1 ? '' : 's'} were built and then
refused by the validation gate.
</Text>
) : null}
{batch.status === 'interrupted' ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
This batch does not restart on its own. Ask an admin on the ingest service to resume
it — re-uploading would run the files that already landed a second time.
</Text>
) : null}
{/* The catalogue is not the shelf. Said here because it is the single
most likely thing to be misread: the products exist now, and they
are still not on sale anywhere. */}
{batch.status !== 'failed' ? (
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
These are in the global catalogue. To put one on a shop&rsquo;s shelf, open the
catalogue, add it to that outlet with a category and a price, then receive stock
against it — a product with no stock is not offered in the customer app.
</Text>
) : null}
<RawResponse payload={batch} />
<HStack>
<Button
label="Send another file"
variant="secondary"
onClick={() => {
setJob(null);
setDryRun(null);
setBatch(null);
setParsed(null);
setFile(null);
}}
@@ -345,9 +342,14 @@ export function SheetImportPanel() {
}
/>
{/* The service reads .tsv as well, and the dropzone's own default
list did not offer it — a file the picker refuses never reaches
the code that would have accepted it. One list, exported by the
client, so the two cannot drift. */}
<SheetDropzone
file={file}
onFile={handleFile}
accept={ACCEPTED_EXTENSIONS.join(',')}
/>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
@@ -418,52 +420,49 @@ export function SheetImportPanel() {
/>
) : null}
{/* The service's dry run, once it has been asked for.
This is the authoritative answer to "what will it actually
read", and the ignored-columns line is the reason to run it: an
unrecognised header is dropped without a word. */}
{dryRun ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
What the service will read
</Text>
{Object.entries(dryRun.recognised_columns ?? {}).map(([field, header]) => (
<Text key={field} type="body" size="xsm" color="secondary">
{String(header)} → {field}
</Text>
))}
{(dryRun.unrecognised_columns ?? []).length > 0 ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
Ignored: {dryRun.unrecognised_columns.join(', ')} — nothing in these columns is
read.
</Text>
) : null}
</VStack>
) : null}
{/* Real progress, from the batch.
{/* Real progress, from the job.
`stage_index` of `total_stages` and `rows_done` of `rows_total`
both come off each poll, so this is a position rather than a
decoration. It falls back to indeterminate for the moment
between submitting and the first reading. */}
{job && job.status !== 'done' && job.status !== 'failed' ? (
Two positions come off each poll and they answer different
questions: files done of files total is how far the batch has
got, and the running file's stage of eleven is what it is doing
now. Shown together because a single number cannot say both. It
falls back to indeterminate for the moment between submitting
and the first reading. */}
{batch && !isSettled(batch) ? (
<VStack gap={0.5}>
{job.rows_total > 0 ? (
<ProgressBar
label={job.stage_name || 'Working'}
value={job.rows_done}
max={job.rows_total}
hasValueLabel
formatValueLabel={(value, max) => `${value} of ${max} rows`}
/>
) : (
<ProgressBar label={job.stage_name || 'Starting'} isIndeterminate />
)}
{(() => {
const { done, total } = progressOf(batch);
const running = batch.files.find((entry) => entry.status === 'running');
return total > 0 ? (
<ProgressBar
label={running?.stage_name || batch.current_file || 'Working'}
value={done}
max={total}
hasValueLabel
formatValueLabel={(value, max) => `${value} of ${max} files`}
/>
) : (
<ProgressBar label="Starting" isIndeterminate />
);
})()}
<Text type="body" size="xsm" color="secondary">
Stage {job.stage_index + 1} of {job.total_stages}
{job.detail ? ` · ${job.detail}` : ''} — keep this tab open.
{(() => {
const running = batch.files.find((entry) => entry.status === 'running');
if (!running) {
return batch.status === 'queued'
? 'Queued — the service runs one batch at a time. Keep this tab open.'
: 'Working — keep this tab open.';
}
const stage =
running.stage_index !== undefined && running.total_stages
? `Stage ${running.stage_index + 1} of ${running.total_stages}`
: 'Running';
const rows =
running.rows_total && running.rows_done !== undefined
? ` · ${running.rows_done} of ${running.rows_total} rows`
: '';
return `${running.filename} — ${stage}${rows}. Keep this tab open.`;
})()}
</Text>
</VStack>
) : null}
@@ -473,17 +472,10 @@ export function SheetImportPanel() {
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
<Text type="body" size="xsm" color="secondary">
Sent whole for parsing and enrichment. The table above is only what we could read
locally — check with a dry run first.
locally — the service applies its own column rules.
</Text>
</HStack>
<HStack gap={1} align="center">
<Button
label="Dry run"
variant="secondary"
size="lg"
isDisabled={isWorking || !file}
onClick={handlePreview}
/>
<Button
label={isWorking ? 'Working…' : 'Send to catalogue'}
variant="primary"