test bugs fixed
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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’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’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"
|
||||
|
||||
Reference in New Issue
Block a user