@murumets-ee/imports 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyze/index.d.mts +265 -0
- package/dist/analyze/index.d.mts.map +1 -0
- package/dist/analyze/index.mjs +2 -0
- package/dist/analyze/index.mjs.map +1 -0
- package/dist/carmaker-feed-B-QYr3PI.mjs +2 -0
- package/dist/{carmaker-feed-C_MnDaOC.mjs.map → carmaker-feed-B-QYr3PI.mjs.map} +1 -1
- package/dist/{carmaker-feed-DlD2xPs0.d.mts → carmaker-feed-_QVQFLUl.d.mts} +3 -2
- package/dist/carmaker-feed-_QVQFLUl.d.mts.map +1 -0
- package/dist/{cleanup-DdILFoAP.mjs → cleanup-DDljBe0Z.mjs} +2 -2
- package/dist/{cleanup-DdILFoAP.mjs.map → cleanup-DDljBe0Z.mjs.map} +1 -1
- package/dist/engine-B0ExE4jL.mjs +2 -0
- package/dist/engine-B0ExE4jL.mjs.map +1 -0
- package/dist/{parts-schema-u5kr5zFg.d.mts → engine-DiM4r2Vj.d.mts} +2 -58
- package/dist/engine-DiM4r2Vj.d.mts.map +1 -0
- package/dist/index-Bm69N5DW.d.mts +97 -0
- package/dist/index-Bm69N5DW.d.mts.map +1 -0
- package/dist/index.d.mts +26 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/mapping/index.d.mts +2 -1
- package/dist/mapping/index.d.mts.map +1 -1
- package/dist/mapping/index.mjs +1 -1
- package/dist/mapping-transform-CO4MNhMe.mjs +2 -0
- package/dist/{mapping-transform-AMFEg1rS.mjs.map → mapping-transform-CO4MNhMe.mjs.map} +1 -1
- package/dist/matching/index.d.mts +2 -0
- package/dist/matching/index.mjs +1 -0
- package/dist/matching-Sr0z4HvN.mjs +2 -0
- package/dist/matching-Sr0z4HvN.mjs.map +1 -0
- package/dist/parts-schema-DOWhBJYh.d.mts +61 -0
- package/dist/parts-schema-DOWhBJYh.d.mts.map +1 -0
- package/dist/parts-schema-QqLci_Pl.mjs +2 -0
- package/dist/parts-schema-QqLci_Pl.mjs.map +1 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/read-feed-DGjqejw3.mjs +2 -0
- package/dist/read-feed-DGjqejw3.mjs.map +1 -0
- package/dist/runner-C9GIe5Dr.mjs +2 -0
- package/dist/runner-C9GIe5Dr.mjs.map +1 -0
- package/dist/{schema-registry-B_7AweTa.mjs → schema-registry-DB5aIZMN.mjs} +2 -2
- package/dist/{schema-registry-B_7AweTa.mjs.map → schema-registry-DB5aIZMN.mjs.map} +1 -1
- package/dist/storage-resolver.d.mts +1 -1
- package/dist/transform-Cu5YEoOl.mjs +2 -0
- package/dist/transform-Cu5YEoOl.mjs.map +1 -0
- package/dist/transforms.d.mts +1 -1
- package/dist/transforms.mjs +1 -1
- package/dist/{worker-DH3SN-91.d.mts → worker-_MlRH5hd.d.mts} +21 -5
- package/dist/worker-_MlRH5hd.d.mts.map +1 -0
- package/dist/worker.d.mts +1 -1
- package/dist/worker.mjs +1 -1
- package/dist/worker.mjs.map +1 -1
- package/dist/xlsx-reader-_ioPAqm5.mjs +2 -0
- package/dist/{xlsx-reader-Dsq_ERiB.mjs.map → xlsx-reader-_ioPAqm5.mjs.map} +1 -1
- package/package.json +16 -8
- package/dist/carmaker-feed-C_MnDaOC.mjs +0 -2
- package/dist/carmaker-feed-DlD2xPs0.d.mts.map +0 -1
- package/dist/mapping-transform-AMFEg1rS.mjs +0 -2
- package/dist/parts-schema-BBUINSdN.mjs +0 -2
- package/dist/parts-schema-BBUINSdN.mjs.map +0 -1
- package/dist/parts-schema-u5kr5zFg.d.mts.map +0 -1
- package/dist/runner-wPnCZlB4.mjs +0 -2
- package/dist/runner-wPnCZlB4.mjs.map +0 -1
- package/dist/transform-DwAQ4b-j.mjs +0 -2
- package/dist/transform-DwAQ4b-j.mjs.map +0 -1
- package/dist/worker-DH3SN-91.d.mts.map +0 -1
- package/dist/xlsx-reader-Dsq_ERiB.mjs +0 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/mapping/discount-table-schema.ts","../../src/mapping/mapping-transform.ts","../../src/mapping/schema-registry.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/mapping/discount-table-schema.ts","../../src/mapping/mapping-transform.ts","../../src/mapping/schema-registry.ts"],"mappings":";;;;;;UAqBiB,gBAAA;EACf,aAAA;EACA,WAAW;AAAA;AAAA,cAQA,mBAAA,EAAqB,YAAY,CAAC,gBAAA;;;;cCClC,sBAAA;;;;ACPb;;cDmCa,gBAAA,EAAkB,YAAY;;;;iBCnC3B,eAAA,CAAgB,IAAA,WAAe,YAAY;AFM3D;AAAA,iBEDgB,iBAAA,CAAA;;UAKC,mBAAA;EACf,IAAA;EACA,MAAA,WAAiB,WAAW;AAAA;;ADL9B;;;;AAAmC;iBCcnB,qBAAA,CAAA,GAAyB,mBAAmB"}
|
package/dist/mapping/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c}from"../parts-schema-QqLci_Pl.mjs";import{a as l,c as u,i as d,n as f,o as p,r as m,s as h,t as g}from"../engine-B0ExE4jL.mjs";import{i as _,n as v,r as y,t as b}from"../schema-registry-DB5aIZMN.mjs";import{n as x,t as S}from"../mapping-transform-CO4MNhMe.mjs";export{p as COLUMN_TRANSFORM_OPS,S as MAPPING_TRANSFORM_NAME,h as applyColumnTransforms,g as buildMappingTransform,u as columnTransformSchema,b as describeTargetSchemas,_ as discountTableSchema,v as getTargetSchema,y as listTargetSchemas,f as mappingColumnSchema,m as mappingConfigSchema,x as mappingTransform,c as normalizeCode,n as numberCell,d as numberFormatSchema,s as parseEuropeanNumber,t as parseNumber,i as partsParamsSchema,o as partsSchema,l as resolveMapping,e as rowError,a as textOrNull,r as toMinorUnits};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{r as e,t}from"./engine-B0ExE4jL.mjs";import{n,r}from"./schema-registry-DB5aIZMN.mjs";import{z as i}from"zod";const a=`commerce:mapping`,o=i.object({mapping:e}),s=new WeakMap;function c(e){let i=s.get(e);if(i)return i;let{mapping:a}=o.parse(e),c=n(a.targetSchema);if(!c)throw Error(`Unknown target schema "${a.targetSchema}" (registered: ${r().join(`, `)})`);let l=t(a,c);return s.set(e,l),l}const l=async(e,t)=>c(t.params)(e,t);export{l as n,a as t};
|
|
2
|
+
//# sourceMappingURL=mapping-transform-CO4MNhMe.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mapping-transform-
|
|
1
|
+
{"version":3,"file":"mapping-transform-CO4MNhMe.mjs","names":[],"sources":["../src/mapping/mapping-transform.ts"],"sourcesContent":["/**\n * `commerce:mapping` — the registered transform for runs driven by a saved\n * `import_mapping` (commerce-demo phase 02; the PR3 wizard enqueues these).\n *\n * The run's `params.mapping` carries a SNAPSHOT of the mapping config, so the run\n * is reproducible (it records the exact mapping used) and the transform stays a\n * pure function of `(row, ctx)` with no per-row DB I/O. This transform compiles\n * the snapshot once per `ctx.params` reference (memoized via a `WeakMap`, the same\n * shape as the carmaker path) and applies it.\n *\n * Output type is `unknown` because the target schema (parts vs discount_table)\n * isn't known until the snapshot is read at runtime. The parts schema additionally\n * reads brand/supplier identity from the SAME `params` object (alongside `mapping`).\n *\n * **Sink caveat.** The `runImport` runner bulk-writes whatever doc this produces\n * into the ES `parts` index. That's correct for a `parts` mapping but WRONG for a\n * `discount_table` mapping — its rows belong in Postgres (`supplier_discount_group`),\n * not ES. PR2 ships the discount_table SCHEMA (so the engine parses it) but no sink;\n * a `discount_table` run must NOT be enqueued through the parts-ES `runImport` path.\n * PR4 wires the Postgres sink + the route that enqueues discount runs.\n *\n * PR2 shipped + tested this transform; the wizard that creates the mappings +\n * sets `params.feed` for the reader landed in PR3. Registration is plugin-owned:\n * the `imports()` plugin registers this transform at its own init.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { buildMappingTransform, mappingConfigSchema } from './engine.js'\nimport { getTargetSchema, listTargetSchemas } from './schema-registry.js'\n\n/** Stable name under which the mapping-driven transform is registered. */\nexport const MAPPING_TRANSFORM_NAME = 'commerce:mapping'\n\n/** The mapping snapshot lives under `params.mapping`; other params flow to the schema. */\nconst mappingParamsSchema = z.object({ mapping: mappingConfigSchema })\n\n/** Compiled-transform cache, keyed on the `ctx.params` object identity (per run). */\nconst compiledCache = new WeakMap<object, RowTransform<unknown>>()\n\nfunction compile(params: Record<string, unknown>): RowTransform<unknown> {\n const cached = compiledCache.get(params)\n if (cached) return cached\n const { mapping } = mappingParamsSchema.parse(params)\n const schema = getTargetSchema(mapping.targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${mapping.targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n const transform = buildMappingTransform(mapping, schema)\n compiledCache.set(params, transform)\n return transform\n}\n\n/**\n * The registered transform. Resolves + compiles the mapping snapshot from\n * `ctx.params` (memoized), then delegates each row to it. A malformed snapshot or\n * unknown schema throws — surfaced by the runner as a row-level `transform_threw`.\n */\nexport const mappingTransform: RowTransform<unknown> = async (row, ctx) => {\n const transform = compile(ctx.params)\n return transform(row, ctx)\n}\n"],"mappings":"oHAgCA,MAAa,EAAyB,mBAGhC,EAAsB,EAAE,OAAO,CAAE,QAAS,CAAoB,CAAC,EAG/D,EAAgB,IAAI,QAE1B,SAAS,EAAQ,EAAwD,CACvE,IAAM,EAAS,EAAc,IAAI,CAAM,EACvC,GAAI,EAAQ,OAAO,EACnB,GAAM,CAAE,WAAY,EAAoB,MAAM,CAAM,EAC9C,EAAS,EAAgB,EAAQ,YAAY,EACnD,GAAI,CAAC,EACH,MAAU,MACR,0BAA0B,EAAQ,aAAa,iBAAiB,EAAkB,CAAC,CAAC,KAAK,IAAI,EAAE,EACjG,EAEF,IAAM,EAAY,EAAsB,EAAS,CAAM,EAEvD,OADA,EAAc,IAAI,EAAQ,CAAS,EAC5B,CACT,CAOA,MAAa,EAA0C,MAAO,EAAK,IAC/C,EAAQ,EAAI,MACf,CAAC,CAAC,EAAK,CAAG"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as SNAPSHOT_MAX_ROWS, c as AutoAssignField, i as SNAPSHOT_MAX_COLUMN_CHARS, l as suggestTargets, n as SNAPSHOT_MAX_CELL_CHARS, o as SNAPSHOT_MAX_SERIALIZED_CHARS, r as SNAPSHOT_MAX_COLUMNS, s as toHeaderSnapshot, t as HeaderSample, u as tokenizeHeader } from "../index-Bm69N5DW.mjs";
|
|
2
|
+
export { type AutoAssignField, type HeaderSample, SNAPSHOT_MAX_CELL_CHARS, SNAPSHOT_MAX_COLUMNS, SNAPSHOT_MAX_COLUMN_CHARS, SNAPSHOT_MAX_ROWS, SNAPSHOT_MAX_SERIALIZED_CHARS, suggestTargets, toHeaderSnapshot, tokenizeHeader };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as e,c as t,i as n,n as r,o as i,r as a,s as o,t as s}from"../matching-Sr0z4HvN.mjs";export{s as SNAPSHOT_MAX_CELL_CHARS,r as SNAPSHOT_MAX_COLUMNS,a as SNAPSHOT_MAX_COLUMN_CHARS,n as SNAPSHOT_MAX_ROWS,e as SNAPSHOT_MAX_SERIALIZED_CHARS,o as suggestTargets,i as toHeaderSnapshot,t as tokenizeHeader};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function e(e){return e.toLowerCase().replace(/[^a-z0-9]/g,``)}function t(e){return e.replace(/([a-z0-9])([A-Z])/g,`$1 $2`).toLowerCase().split(/[^a-z0-9]+/).filter(e=>e!==``).slice(0,12)}function n(e){let t=[];for(let n=0;n<e.length;n++){let r=``;for(let i=n;i<e.length;i++)r+=e[i]??``,t.push(r)}return t}function r(r,i){let a=i.map(t=>{let n=new Set([e(t.key),e(t.label)]);for(let r of t.aliases??[])n.add(e(r));return n.delete(``),n}),o=[];r.forEach((r,i)=>{let s=e(r);if(s===``)return;let c=new Set(n(t(r)));a.forEach((e,t)=>{let n=0;for(let t of e)s===t?n=Math.max(n,1e3+t.length):c.has(t)&&(n=Math.max(n,t.length));n>0&&o.push({columnIndex:i,fieldIndex:t,score:n})})}),o.sort((e,t)=>t.score-e.score||e.fieldIndex-t.fieldIndex||e.columnIndex-t.columnIndex);let s=new Map,c=new Set,l=new Set;for(let e of o){if(c.has(e.columnIndex)||l.has(e.fieldIndex))continue;let t=r[e.columnIndex],n=i[e.fieldIndex];t===void 0||n===void 0||(c.add(e.columnIndex),l.add(e.fieldIndex),s.set(t,n.key))}return s}const i=256,a=256,o=5,s=500,c=1e5;function l(e){let t=e.columns.filter(e=>e.length>0);if(t.length===0||t.length>256||t.some(e=>e.length>256))return null;let n={columns:t,rows:e.rows.slice(0,5).map(e=>{let t={};for(let[n,r]of Object.entries(e))n.length!==0&&(t[n]=u(r));return t})};return JSON.stringify(n).length>1e5?null:n}function u(e){if(e.length<=500)return e;let t=e.slice(0,500),n=t.charCodeAt(t.length-1);return n>=55296&&n<=56319?t.slice(0,-1):t}export{c as a,t as c,o as i,i as n,l as o,a as r,r as s,s as t};
|
|
2
|
+
//# sourceMappingURL=matching-Sr0z4HvN.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matching-Sr0z4HvN.mjs","names":[],"sources":["../src/matching/auto-assign.ts","../src/matching/header-snapshot.ts"],"sourcesContent":["/**\n * Auto-assignment of a previewed feed's columns to target fields — the wizard's first guess\n * when an operator uploads a sample file.\n *\n * Kept pure (no React, no server imports) so the matching rules are unit-testable against real\n * supplier headers instead of only being visible by driving the UI.\n *\n * ## Why this is not a substring match\n *\n * The previous rule accepted a match when either string contained the other, which is wrong in\n * both directions at once. It fired where it should not — `Barcode` contains `code`, so the\n * barcode column was offered as the REQUIRED part-code field while the actual code column\n * (`ArticleName`, `partno`) matched nothing — and it stayed silent where it mattered, because\n * no real feed names a column after our internal key. Measured against the three ground-truth\n * supplier samples, it assigned two columns correctly, one wrongly, and missed the rest,\n * including every supersession column. A missed price column is loud; a missed supersession\n * column is silent (prices import, the run succeeds, the replacement tables stay empty), which\n * is how the supersession feature shipped switched-off and stayed that way.\n *\n * ## The rules\n *\n * 1. A field's candidate spellings are its `key`, its `label`, and its declared `aliases`, each\n * reduced to lowercase alphanumerics.\n * 2. A column matches a candidate only on a TOKEN BOUNDARY — the header is split on\n * punctuation, whitespace and camelCase humps, and a candidate must equal a contiguous run\n * of those tokens. So `Net Price EUR` and `NetPrice/Discount` still reach `net_price`, while\n * `Barcode` can no longer reach `code`, because `code` is not one of its tokens.\n * 3. Every (column, field) match competes globally and the strongest wins: a whole-header hit\n * beats a partial one, and a longer candidate beats a shorter one. Assignment is greedy over\n * that order, so `Description1` claims `description1` even when a plain `Description` column\n * sits earlier in the file.\n * 4. One target is used at most once. Two columns claiming the same field would be rejected by\n * the mapping validator at save time, so the weaker claimant is left unassigned for the\n * operator to resolve rather than silently producing an unsaveable mapping.\n */\n\n/** The subset of a target field this matcher reads. */\nexport interface AutoAssignField {\n key: string\n label: string\n aliases?: readonly string[] | undefined\n}\n\n/** Lowercase alphanumerics — the comparison form for both headers and candidates. */\nfunction normalize(s: string): string {\n return s.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\n/**\n * Tokens considered per header. Matching enumerates every contiguous run of tokens, which is\n * quadratic in their count AND in their total length, so this cap is load-bearing rather than\n * tidy: a mis-detected delimiter collapses an entire line — sometimes an entire file — into ONE\n * \"column\", and this runs in the operator's browser the moment a preview returns. Measured\n * uncapped: 500 tokens → 207 MB of run strings and 0.6s; 1,000 → 1.7 GB and 5.4s; 2,000 → 14 GB\n * and 40s, all retained in a Set. The wizard has a dedicated banner for exactly that mistake\n * (`delimiterSuggestion`), but auto-assign runs before it can paint. No real column header is\n * a dozen words long, so capping costs nothing and bounds the work at ~78 runs per column.\n */\nconst MAX_HEADER_TOKENS = 12\n\n/**\n * Split a header into comparison tokens: on punctuation/whitespace, and on camelCase humps so\n * `ArticleName` → `article` + `name` and `CurrencyCd` → `currency` + `cd`. Truncated to\n * {@link MAX_HEADER_TOKENS}.\n *\n * **Acronym boundaries are deliberately NOT split.** Adding `([A-Z]+)([A-Z][a-z])` looks like a\n * free improvement — it makes `EANCode` read as `ean` + `code` — and it re-opens this module's\n * founding bug: the standalone `code` token then outscores `barcode`'s `ean` alias (4 vs 3), so\n * the BARCODE column gets offered as the required part-code field, silently, exactly as the old\n * substring matcher did. It generalises to any `<Acronym>Code` header, which no alias list can\n * enumerate. Compound spellings that matter are handled as whole-header aliases instead, which\n * beat any partial run.\n */\nexport function tokenizeHeader(header: string): string[] {\n return header\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter((t) => t !== '')\n .slice(0, MAX_HEADER_TOKENS)\n}\n\n/** Every contiguous run of tokens, joined — the strings a candidate may equal. */\nfunction tokenRuns(tokens: readonly string[]): string[] {\n const runs: string[] = []\n for (let start = 0; start < tokens.length; start++) {\n let joined = ''\n for (let end = start; end < tokens.length; end++) {\n joined += tokens[end] ?? ''\n runs.push(joined)\n }\n }\n return runs\n}\n\ninterface Candidate {\n columnIndex: number\n fieldIndex: number\n /** Whole-header hits outrank partial ones; longer candidates outrank shorter ones. */\n score: number\n}\n\n/**\n * Suggest a target field for each column. Returns `column → target key`, omitting any column\n * with no confident match — an absent entry means \"the operator decides\", never \"no mapping\n * needed\".\n */\nexport function suggestTargets(\n columns: readonly string[],\n fields: readonly AutoAssignField[],\n): Map<string, string> {\n const candidatesByField = fields.map((f) => {\n const spellings = new Set<string>([normalize(f.key), normalize(f.label)])\n for (const alias of f.aliases ?? []) spellings.add(normalize(alias))\n spellings.delete('')\n return spellings\n })\n\n const matches: Candidate[] = []\n columns.forEach((column, columnIndex) => {\n const full = normalize(column)\n if (full === '') return\n const runs = new Set(tokenRuns(tokenizeHeader(column)))\n candidatesByField.forEach((spellings, fieldIndex) => {\n let best = 0\n for (const spelling of spellings) {\n // A whole-header hit is worth more than a run hit of the same length, so a column\n // named exactly `code` beats one that merely contains the token.\n if (full === spelling) best = Math.max(best, 1000 + spelling.length)\n else if (runs.has(spelling)) best = Math.max(best, spelling.length)\n }\n if (best > 0) matches.push({ columnIndex, fieldIndex, score: best })\n })\n })\n\n // Strongest first; ties resolved by declaration order then file order so the result is\n // independent of Set/array iteration incidentals.\n matches.sort(\n (a, b) => b.score - a.score || a.fieldIndex - b.fieldIndex || a.columnIndex - b.columnIndex,\n )\n\n const assigned = new Map<string, string>()\n const takenColumns = new Set<number>()\n const takenFields = new Set<number>()\n for (const m of matches) {\n if (takenColumns.has(m.columnIndex) || takenFields.has(m.fieldIndex)) continue\n const column = columns[m.columnIndex]\n const field = fields[m.fieldIndex]\n if (column === undefined || field === undefined) continue\n takenColumns.add(m.columnIndex)\n takenFields.add(m.fieldIndex)\n assigned.set(column, field.key)\n }\n return assigned\n}\n","/**\n * The parsed-sample shape this builder consumes and emits — structurally identical to\n * `FeedPreview` from `preview-feed.ts` and to the wizard's `FeedPreviewResponse`, and\n * declared HERE rather than imported from either so this module keeps zero dependencies.\n * That is what lets one implementation serve the browser wizard and the `lumi` CLI: an\n * import of `preview-feed.js` would pull `csv-parse` and the xlsx reader toward a client\n * bundle, and an import of the wizard's types would point the dependency the wrong way.\n */\nexport interface HeaderSample {\n /** Source column names, in first-seen order. */\n columns: string[]\n /** Sample data rows, each `{ column -> cell }`. */\n rows: Array<Record<string, string>>\n}\n\n// Snapshot caps — mirror `../entities/import-mapping-validation.ts`'s\n// `headerSnapshotSchema` + `HEADER_SNAPSHOT_MAX_CHARS` ceiling, so a snapshot this\n// builder emits always passes the server's save validation.\n//\n// Still deliberately duplicated rather than imported, and the reason has changed with\n// the module's address: it is no longer \"don't drag server code into the browser\" (this\n// file now lives in the server package), it is that `import-mapping-validation.ts` pulls\n// zod and the schema registry, which would end this subpath's zero-dependency property —\n// the one thing that lets the wizard and the CLI share ONE matcher. The contract is\n// pinned the way it always was, by the round-trip test in `header-snapshot.test.ts`\n// feeding this builder's output through the real `assertValidMapping`.\nexport const SNAPSHOT_MAX_COLUMNS = 256\nexport const SNAPSHOT_MAX_COLUMN_CHARS = 256\nexport const SNAPSHOT_MAX_ROWS = 5\nexport const SNAPSHOT_MAX_CELL_CHARS = 500\nexport const SNAPSHOT_MAX_SERIALIZED_CHARS = 100_000\n\n/**\n * Build the file-less-edit `headerSnapshot` (#667) from the current preview.\n * Rows are capped + cells truncated (they're display samples, not data); a\n * preview that can't fit the caps at all (pathological column count / column\n * names) returns `null` — the mapping saves fine, it just won't support\n * file-less editing.\n *\n * Pure module (no React, no 'use client') so the caps mirror is unit-testable\n * without page infrastructure — same shape as `find-saved-mapping.ts`.\n */\nexport function toHeaderSnapshot(preview: HeaderSample): HeaderSample | null {\n // Drop blank-named columns rather than rejecting the snapshot: csv-parse\n // treats '' as a valid column name, so a header with a trailing delimiter or\n // blank middle cell (`code,price,`) reaches here from perfectly ordinary\n // feeds — while the server schema (rightly) refuses empty names. A blank\n // column is unmappable anyway (columnMap sources require min-1 names).\n const columns = preview.columns.filter((c) => c.length > 0)\n if (\n columns.length === 0 ||\n columns.length > SNAPSHOT_MAX_COLUMNS ||\n columns.some((c) => c.length > SNAPSHOT_MAX_COLUMN_CHARS)\n ) {\n return null\n }\n const snapshot: HeaderSample = {\n columns,\n rows: preview.rows.slice(0, SNAPSHOT_MAX_ROWS).map((row) => {\n const out: Record<string, string> = {}\n for (const [col, cell] of Object.entries(row)) {\n if (col.length === 0) continue // cell of a dropped blank column\n out[col] = truncateCell(cell)\n }\n return out\n }),\n }\n // Mirror the server's serialized ceiling too — without this, a wide feed\n // with long sample cells (~40+ columns × 500-char cells) passes the per-axis\n // caps here but hard-fails the SAVE server-side with an error the operator\n // can't act on. Over the ceiling → no file-less support, save proceeds.\n if (JSON.stringify(snapshot).length > SNAPSHOT_MAX_SERIALIZED_CHARS) return null\n return snapshot\n}\n\n/**\n * Truncate a sample cell to the cap WITHOUT splitting a surrogate pair: a\n * `.slice()` cut through an astral character (emoji in a product name) leaves\n * a trailing lone high surrogate, which zod accepts but Postgres rejects as\n * jsonb input — the save would die at the DB layer instead of degrading.\n */\nfunction truncateCell(cell: string): string {\n if (cell.length <= SNAPSHOT_MAX_CELL_CHARS) return cell\n const cut = cell.slice(0, SNAPSHOT_MAX_CELL_CHARS)\n const last = cut.charCodeAt(cut.length - 1)\n const endsWithLoneHighSurrogate = last >= 0xd800 && last <= 0xdbff\n return endsWithLoneHighSurrogate ? cut.slice(0, -1) : cut\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { p as TargetSchema } from "./engine-DiM4r2Vj.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { PartsDocument } from "@murumets-ee/search-elasticsearch";
|
|
4
|
+
|
|
5
|
+
//#region src/mapping/parts-schema.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Params the `parts` schema reads from the run — the per-(supplier, brand)
|
|
8
|
+
* identity resolved at enqueue time. Identical to the carmaker transform's params
|
|
9
|
+
* (the carmaker path re-exports this), so brand/supplier injection + the optional
|
|
10
|
+
* supplier code-prefix strip are validated the same way everywhere.
|
|
11
|
+
*/
|
|
12
|
+
declare const partsParamsSchema: z.ZodObject<{
|
|
13
|
+
brandId: z.ZodString;
|
|
14
|
+
brandSlug: z.ZodString;
|
|
15
|
+
supplierId: z.ZodString;
|
|
16
|
+
supplierDisplayName: z.ZodString;
|
|
17
|
+
importBatchId: z.ZodString; /** Supplier code prefix to strip before normalization (e.g. `'ME-'`). Optional. */
|
|
18
|
+
supplierCodePrefix: z.ZodOptional<z.ZodString>;
|
|
19
|
+
/**
|
|
20
|
+
* Purchase-discount map for this supplier (commerce-demo PR4, phase 03 §2):
|
|
21
|
+
* `{ groupCode → discountPct }`, resolved from `supplier_discount_group` at
|
|
22
|
+
* enqueue time and snapshotted onto the run so `buildRow` stays a pure function
|
|
23
|
+
* (no per-row DB I/O; `@murumets-ee/imports` stays a leaf). Absent for direct-net
|
|
24
|
+
* suppliers + the carmaker path. Percentages are 0–100 (the entity constrains them).
|
|
25
|
+
*/
|
|
26
|
+
discountGroups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
supplierId: string;
|
|
29
|
+
brandId: string;
|
|
30
|
+
brandSlug: string;
|
|
31
|
+
supplierDisplayName: string;
|
|
32
|
+
importBatchId: string;
|
|
33
|
+
supplierCodePrefix?: string | undefined;
|
|
34
|
+
discountGroups?: Record<string, number> | undefined;
|
|
35
|
+
}, {
|
|
36
|
+
supplierId: string;
|
|
37
|
+
brandId: string;
|
|
38
|
+
brandSlug: string;
|
|
39
|
+
supplierDisplayName: string;
|
|
40
|
+
importBatchId: string;
|
|
41
|
+
supplierCodePrefix?: string | undefined;
|
|
42
|
+
discountGroups?: Record<string, number> | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
type PartsParams = z.infer<typeof partsParamsSchema>;
|
|
45
|
+
/**
|
|
46
|
+
* Uppercase + dash/whitespace-stripped (D2). `*`/other chars are preserved (e.g. `*S`
|
|
47
|
+
* markers). EXPORTED so downstream consumers that key off `code_normalized` — the
|
|
48
|
+
* replacement editor (commerce-demo PR6), which normalizes admin-typed codes before
|
|
49
|
+
* matching members — use the IDENTICAL rule (R05 Q7: normalization must match across
|
|
50
|
+
* every code source or merge-on-collision silently fails to collide).
|
|
51
|
+
*/
|
|
52
|
+
declare function normalizeCode(s: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* The `parts` schema. `buildRow` THROWS on invalid run params (so a misconfigured
|
|
55
|
+
* run surfaces as a transform error, matching the carmaker contract) and returns a
|
|
56
|
+
* typed `RowResult` per row otherwise.
|
|
57
|
+
*/
|
|
58
|
+
declare const partsSchema: TargetSchema<PartsDocument>;
|
|
59
|
+
//#endregion
|
|
60
|
+
export { partsSchema as i, normalizeCode as n, partsParamsSchema as r, PartsParams as t };
|
|
61
|
+
//# sourceMappingURL=parts-schema-DOWhBJYh.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parts-schema-DOWhBJYh.d.mts","names":[],"sources":["../src/mapping/parts-schema.ts"],"mappings":";;;;;;;;;;;cAmDa,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAqBlB,WAAA,GAAc,CAAA,CAAE,KAAK,QAAQ,iBAAA;;;;;AAAzC;;;iBAsMgB,aAAA,CAAc,CAAS;;;;;AAtMmB;cA6R7C,WAAA,EAAa,YAAY,CAAC,aAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{z as e}from"zod";const t=/^-?\d+(\.\d+)?$/;function n(e,n){let r=e.trim();if(r===``||r.toUpperCase()===`NA`)return null;let{decimal:i,thousands:a}=n,o=r;if(a!==``&&(o=o.split(a).join(``)),i===`,`){if(o.includes(`.`))return NaN;o=o.split(`,`).join(`.`)}else if(o.includes(`,`))return NaN;return t.test(o)?Number.parseFloat(o):NaN}function r(e){return Math.round(e*100)}function i(e){let n=e.trim();if(n===``||n.toUpperCase()===`NA`)return null;let r=n.includes(`,`),i=n.includes(`.`),a;if(r&&i)a=n.replace(/\./g,``).replace(`,`,`.`);else if(r)a=n.replace(`,`,`.`);else if(i)return NaN;else a=n;return t.test(a)?Number.parseFloat(a):NaN}function a(e){if(typeof e!=`string`)return null;let t=e.trim();return t===``?null:t}function o(e){return e==null?{kind:`absent`}:typeof e==`number`?Number.isNaN(e)?{kind:`invalid`}:{kind:`value`,value:e}:e.trim()===``?{kind:`absent`}:{kind:`invalid`}}function s(e,t,n){return{kind:`error`,error:{errorType:e,message:t,field:n}}}const c=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),importBatchId:e.string().uuid(),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),discountGroups:e.record(e.string(),e.number().min(0).max(100)).optional()}),l=new WeakMap;function u(e){let t=l.get(e);if(t)return t;let n=c.parse(e);return l.set(e,n),n}const d=[`de`,`en`,`es`,`fr`,`it`,`nl`,`pt`,`ro`],f=e=>`name_${e}`,p=[{key:`code`,label:`Code`,required:!0,type:`string`,aliases:[`part no`,`part number`,`article name`,`article no`,`article number`,`sku`,`varuosa kood`,`kood`,`artikelnummer`,`artikel nr`]},{key:`net_price`,label:`Net price`,required:!1,type:`number`,aliases:[`price`,`net price discount`,`nettopreis`,`netto`]},{key:`gross_price`,label:`Gross price`,required:!1,type:`number`,aliases:[`bruttopreis`,`brutto`]},{key:`list_price`,label:`List price`,required:!1,type:`number`,aliases:[`retail price`,`rrp`,`jaehind`,`jaehind km ta`,`listenpreis`]},{key:`currency`,label:`Currency`,required:!1,type:`string`,aliases:[`currency cd`,`currency code`,`valuuta`]},{key:`barcode`,label:`Barcode`,required:!1,type:`string`,aliases:[`ean`,`ean code`,`gtin`,`gtin code`,`triipkood`]},{key:`description1`,label:`Description 1`,required:!1,type:`string`,aliases:[`description`,`kirjeldus`,`beschreibung`]},{key:`description2`,label:`Description 2`,required:!1,type:`string`},{key:`discount_group`,label:`Discount group`,required:!1,type:`string`,aliases:[`product group`,`tootegrupp`,`rabattgruppe`]},{key:`superseding_code`,label:`Replaced by (newer code)`,required:!1,type:`string`,aliases:[`replaced by`,`successor`,`successor code`,`new code`,`ersetzt durch`,`wird ersetzt durch`,`nachfolgenummer`,`vervangen door`]},{key:`replaces_code`,label:`Replaces (older code)`,required:!1,type:`string`,aliases:[`replaces`,`supersedes`,`old code`,`previous code`,`predecessor`,`vorgaengernummer`]},{key:`core_charge`,label:`Core charge`,required:!1,type:`number`,aliases:[`surcharge`,`deposit`,`core deposit`,`korpusetasu`,`pfand`]},...d.map(e=>({key:f(e),label:`Name (${e})`,required:!1,type:`string`,aliases:[`description ${e}`,`name ${e}`]}))],m=/[\s-]/g;function h(e){return e.toUpperCase().replace(m,``)}function g(e,t){let n=a(e);if(n===null)return null;let r=h(t&&n.startsWith(t)?n.slice(t.length):n);return r===``?null:r}function _(e,t){return Math.round(e*(1-t/100))}function v(e){return e===null?{core_charge_minor:null,core_charge_currency:null,core_charge_refundable:null,core_charge_tax_class:null,core_charge_tax_inclusive:null}:{core_charge_minor:e,core_charge_currency:`EUR`,core_charge_refundable:!0,core_charge_tax_class:null,core_charge_tax_inclusive:!1}}const y={name:`parts`,fields:p,buildRow(e,t){let n=u(t.params),i=typeof e.code==`string`?e.code.trim():``;if(!i)return s(`missing_code`,`code is empty`,`code`);let c=h(n.supplierCodePrefix&&i.startsWith(n.supplierCodePrefix)?i.slice(n.supplierCodePrefix.length):i);if(!c)return s(`empty_normalized_code`,`Code "${i}" is empty after normalization`,`code`);let l=a(e.currency)??`EUR`;if(l!==`EUR`)return s(`unsupported_currency`,`Currency "${l}" not supported (EUR-only)`,`currency`);if(c.endsWith(`*S`)){let t=c.slice(0,-2);if(!t)return s(`core_charge_no_parent`,`Core-fee row "${i}" has no part code before the "*S" marker`,`code`);let a=o(e.net_price);if(a.kind===`invalid`)return s(`invalid_core_charge`,`core-fee (*S) row net_price is not a number`,`net_price`);if(a.kind===`absent`)return s(`missing_core_charge`,`core-fee (*S) row has no net_price to use as the deposit`,`net_price`);let l=r(a.value);return l<=0?{kind:`skip`,reason:`core_fee_zero`}:{kind:`skip`,reason:`core_charge_marker`,patches:[{id:`${t}__${n.supplierId}`,doc:v(l),orphanWarning:{warningType:`orphan_core_charge`,message:`core-fee (*S) row references a part not present in this import`,field:`code`}}]}}let d=o(e.net_price);if(d.kind===`invalid`)return s(`invalid_net_price`,`net_price is not a number`,`net_price`);let f=o(e.gross_price);if(f.kind===`invalid`)return s(`invalid_gross_price`,`gross_price is not a number`,`gross_price`);let p=o(e.list_price);if(p.kind===`invalid`)return s(`invalid_list_price`,`list_price is not a number`,`list_price`);let m=d.kind===`value`?d.value:null,y=p.kind===`value`?p.value:null,b=m??y;if(b===null)return s(`missing_price`,`no net_price or list_price provided`,`price`);let x=a(e.discount_group),S=y===null?null:r(y),C=null,w=[];if(m!==null)C=r(m);else if(S!==null){let e=x===null?void 0:n.discountGroups?.[x];e===void 0?w.push({warningType:`no_purchase_price`,message:x===null?`list price present but no discount group on the row — cost pending`:`no discount group "${x}" configured for this supplier — cost pending`,field:`discount_group`}):C=_(S,e)}let T=[],E=g(e.superseding_code,n.supplierCodePrefix);E!==null&&E!==c&&T.push({from:c,to:E});let D=g(e.replaces_code,n.supplierCodePrefix);D!==null&&D!==c&&T.push({from:D,to:c});let O=o(e.core_charge);if(O.kind===`invalid`)return s(`invalid_core_charge`,`core_charge is not a number`,`core_charge`);let k=O.kind===`value`?r(O.value):null,A=k!==null&&k>0?k:null,j=`${c}__${n.supplierId}`;return{kind:`success`,id:j,doc:{doc_id:j,code:i,code_normalized:c,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:b,gross_price_eur:f.kind===`value`?f.value:null,currency:`EUR`,barcode:a(e.barcode),name_de:a(e.name_de),name_en:a(e.name_en),name_es:a(e.name_es),name_fr:a(e.name_fr),name_it:a(e.name_it),name_nl:a(e.name_nl),name_pt:a(e.name_pt),name_ro:a(e.name_ro),description1:a(e.description1),description2:a(e.description2),list_price_minor:S,discount_group:x,superseding_code:a(e.superseding_code),purchase_price_minor:C,...v(A),import_batch_id:n.importBatchId,imported_at:new Date().toISOString()},...w.length>0&&{warnings:w},...T.length>0&&{pairs:T},...C!==null&&{priceSample:C}}}};export{s as a,n as c,o as i,r as l,c as n,a as o,y as r,i as s,h as t};
|
|
2
|
+
//# sourceMappingURL=parts-schema-QqLci_Pl.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parts-schema-QqLci_Pl.mjs","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/target-schema.ts","../src/mapping/parts-schema.ts"],"sourcesContent":["/**\n * Number parsing for the configurable mapping engine (commerce-demo phase 02).\n *\n * Real supplier feeds (R03) disagree on decimal AND thousands separators:\n * `55.02` (dot decimal), `326,88` (comma decimal), `1.234,56` (EU\n * thousands+decimal), `1,234.56` (US), `1 234,56` (space-grouped). The mapping\n * stores an explicit `{ decimal, thousands }` per profile so there is no guessing\n * — `parseNumber` trusts the configured format.\n *\n * Two parsers live here:\n * - `parseNumber(raw, format)` — the format-driven parser used by the generic\n * `number` per-column transform.\n * - `parseEuropeanNumber(raw)` — the carmaker-feed legacy parser (comma decimal,\n * dot thousands, **bare-dot rejected as ambiguous**). Carries the carmaker feed's\n * documented format with two intentional broadenings vs the old inline parser:\n * it trims surrounding whitespace, and returns `null` (not `NaN`) for an empty /\n * `'NA'` cell so both number ops share absent-vs-malformed semantics. The\n * carmaker doc-shape suite (27 tests) is the regression net.\n */\n\n/** Separator config for {@link parseNumber}. Stored per `import_mapping`. */\nexport interface NumberFormat {\n /** Decimal separator. */\n decimal: '.' | ','\n /** Thousands grouping separator. `''` = none. */\n thousands: '' | '.' | ',' | ' '\n}\n\n/** Post-cleanup numeric form: optional sign, digits, optional single decimal. */\nconst STRICT_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\n\n/**\n * Parse a feed cell into a number using an EXPLICIT `{ decimal, thousands }`\n * format. No ambiguity: thousands separators are stripped, the decimal separator\n * is normalised to `.`, and the result is strict-validated.\n *\n * Returns:\n * - `null` for an empty / `'NA'` cell (a legitimately-absent value — the caller\n * decides whether that's allowed for a given target field).\n * - `NaN` for a non-empty but unparseable cell (malformed — the caller surfaces\n * a row-level error rather than coercing to 0).\n * - the parsed number otherwise.\n *\n * A character that is neither a digit, the configured decimal, the configured\n * thousands separator, nor a leading `-` makes the cell unparseable (`NaN`) —\n * e.g. a literal `.` when the format's decimal is `,` and thousands is not `.`.\n */\nexport function parseNumber(raw: string, format: NumberFormat): number | null {\n const trimmed = raw.trim()\n if (trimmed === '' || trimmed.toUpperCase() === 'NA') return null\n\n const { decimal, thousands } = format\n let s = trimmed\n // 1. Remove every thousands separator (when one is configured).\n if (thousands !== '') s = s.split(thousands).join('')\n\n // 2. Normalise the decimal separator to `.`. After thousands removal, the\n // \"other\" separator must not appear — if it does, the cell doesn't match\n // the declared format and is unparseable.\n if (decimal === ',') {\n if (s.includes('.')) return Number.NaN\n s = s.split(',').join('.')\n } else {\n // decimal === '.'\n if (s.includes(',')) return Number.NaN\n }\n\n if (!STRICT_NUMBER_RE.test(s)) return Number.NaN\n return Number.parseFloat(s)\n}\n\n/**\n * Convert a major-unit money value (e.g. `55.02` EUR) to integer MINOR units\n * (`5502`). Rounds to the nearest cent to absorb float drift. The commerce-demo\n * PartsDocument money fields (`list_price_minor`, `purchase_price_minor`,\n * `core_charge_minor`) are integer minor units so derived prices (PR4) don't\n * accrue rounding error — see F003 in progress.md.\n */\nexport function toMinorUnits(value: number): number {\n return Math.round(value * 100)\n}\n\n/**\n * Parse a European-formatted decimal: `,` is the decimal separator, `.` is the\n * (optional) thousands separator. Returns `null` for an empty / `'NA'` cell and\n * `NaN` on unparseable (malformed) input.\n *\n * This is the carmaker-feed legacy parser, preserved from the hardcoded transform.\n * Crucially it **rejects bare-dot** input (`1.98`, `1.234`) as ambiguous rather\n * than guessing — the carmaker feeds are documented as comma-decimal, and a `.`\n * without a `,` could mean a US-format decimal OR a thousands marker. The\n * format-driven {@link parseNumber} doesn't need this guard (its separators are\n * explicit); the carmaker path keeps it.\n *\n * The `null` (absent) vs `NaN` (malformed) split mirrors {@link parseNumber} so\n * the mapping engine treats both number ops uniformly: a target field's `buildRow`\n * reads `null` as \"not provided\" and `NaN` as \"invalid\". This is what lets the\n * reimplemented carmaker transform keep its `GrossPrice 'NA' → null` behaviour\n * (optional field) while still erroring on a malformed `NetPrice` (required).\n *\n * Cases: `'1,98'`→`1.98`, `'1.234,56'`→`1234.56`, `'5'`→`5`, `'-5,00'`→`-5`,\n * empty / `'NA'` → `null`. Rejected (`NaN`): bare-dot (`'1.98'`, `'1.234'`),\n * trailing junk (`'1,98EUR'`), multiple commas (`'1,2,3'`), other non-numeric.\n */\nexport function parseEuropeanNumber(s: string): number | null {\n const trimmed = s.trim()\n if (trimmed === '' || trimmed.toUpperCase() === 'NA') return null\n const hasComma = trimmed.includes(',')\n const hasDot = trimmed.includes('.')\n let cleaned: string\n if (hasComma && hasDot) {\n // European thousands+decimal: strip every `.`, then swap the (sole) `,` for\n // `.`. The strict regex below catches multi-comma input.\n cleaned = trimmed.replace(/\\./g, '').replace(',', '.')\n } else if (hasComma) {\n cleaned = trimmed.replace(',', '.')\n } else if (hasDot) {\n return Number.NaN\n } else {\n cleaned = trimmed\n }\n if (!STRICT_NUMBER_RE.test(cleaned)) return Number.NaN\n return Number.parseFloat(cleaned)\n}\n","/**\n * TargetSchema — the contract a mapping writes INTO (commerce-demo phase 02, D005).\n *\n * A mapping's `column_map` points source columns at a schema's whitelisted target\n * fields; the engine applies the per-column transforms to build a `MappedRow`, then\n * hands it to `schema.buildRow` to produce the typed output document (an ES\n * `PartsDocument` for `parts`, a discount row for `discount_table`).\n *\n * The `fields` list is the SECOND injection-boundary whitelist (the first is the\n * registered schema name): a `column_map` entry whose `target` is not a field key\n * here is rejected at validation time (R02). Two schemas ship in PR2 — `parts` and\n * `discount_table`.\n */\n\nimport type { RowResult } from '../transform.js'\nimport type { CellValue } from './column-transforms.js'\n\n/** One whitelisted target field on a schema. */\nexport interface TargetField {\n /** Stable target key referenced by `column_map[].target`. */\n key: string\n /** Human label for the wizard dropdown (PR3). */\n label: string\n /** Whether a mapping for this schema MUST include this target. */\n required: boolean\n /** Coarse type — drives the wizard's transform suggestions; `buildRow` does the real coercion. */\n type: 'string' | 'number'\n /**\n * Header spellings this field should be SUGGESTED for when the wizard auto-assigns a freshly\n * previewed file. Purely advisory: the operator can always override, and nothing here widens\n * the injection-boundary whitelist — `key` remains the only accepted `column_map[].target`.\n *\n * Real feeds do not name their columns after our keys (a supplier ships `partno`, `VARUOSA\n * KOOD`, `ArticleName`, never `code`), so without aliases the wizard suggests nothing for the\n * columns that matter and the operator has to know the schema by heart. `describeTargetSchemas`\n * ships this to the wizard verbatim, so a field's synonyms live next to the field itself\n * rather than in a lookup table on the client.\n *\n * Only add a spelling whose meaning is UNAMBIGUOUS. An alias is a silent decision applied to\n * somebody's catalogue — see the supersession fields below, where two spellings that both read\n * as \"replacement\" denote opposite directions and are therefore deliberately absent.\n */\n aliases?: readonly string[]\n}\n\n/** The per-row product of the engine: target field key → cleaned value. */\nexport type MappedRow = Record<string, CellValue>\n\n/** Per-row context handed to `buildRow`. `params` is the opaque per-run params object. */\nexport interface BuildRowContext {\n importRunId: string\n rowNumber: number\n /**\n * Opaque per-run params (the parts schema reads brand/supplier identity from\n * it; `discount_table` ignores it). The schema validates the shape it needs.\n */\n params: Record<string, unknown>\n}\n\n/** A registered target schema: a whitelist + a row builder. */\nexport interface TargetSchema<TOut> {\n /** Registered name — also the `import_mapping.target_schema` value. */\n name: string\n /** The target-field whitelist (injection boundary). */\n fields: readonly TargetField[]\n /** Build a typed output (success) / skip / error from a mapped row. */\n buildRow(mapped: MappedRow, ctx: BuildRowContext): RowResult<TOut>\n}\n\n// ---------------------------------------------------------------------------\n// Shared `buildRow` helpers (used by every concrete schema).\n// ---------------------------------------------------------------------------\n\n/**\n * A nullable text cell → trimmed string, or `null` for empty / non-string. Accepts\n * `undefined` (an UNMAPPED target — indexed access under `noUncheckedIndexedAccess`)\n * and treats it as absent.\n */\nexport function textOrNull(v: CellValue | undefined): string | null {\n if (typeof v !== 'string') return null\n const t = v.trim()\n return t === '' ? null : t\n}\n\n/** The three outcomes a `number` / `europeanNumber` op produces for a cell. */\nexport type NumberOutcome =\n | { kind: 'value'; value: number }\n | { kind: 'absent' }\n | { kind: 'invalid' }\n\n/**\n * Classify a cell that should hold a number. `undefined` (unmapped) / `null` →\n * absent; a real number → value (`NaN` → invalid); a non-empty raw string (no\n * number op declared) → invalid; a blank raw string → absent.\n */\nexport function numberCell(v: CellValue | undefined): NumberOutcome {\n if (v === undefined || v === null) return { kind: 'absent' }\n if (typeof v === 'number') {\n return Number.isNaN(v) ? { kind: 'invalid' } : { kind: 'value', value: v }\n }\n return v.trim() === '' ? { kind: 'absent' } : { kind: 'invalid' }\n}\n\n/** Build a `RowResult` error variant (assignable to any `TargetSchema<TOut>`). */\nexport function rowError(errorType: string, message: string, field: string) {\n return { kind: 'error' as const, error: { errorType, message, field } }\n}\n","/**\n * `parts` target schema — maps a cleaned feed row into the ES `PartsDocument`\n * (commerce-demo phase 02, D005). This is the SHARED assembly + validation logic\n * the reimplemented carmaker transform and every operator-built `parts` mapping\n * run through, so the row taxonomy (code normalization, currency gate, price\n * validation) is uniform across all supplier feeds.\n *\n * **Field coverage.** This schema populates the direct passthroughs (the existing\n * `PartsDocument` columns plus `list_price_minor`, `discount_group`,\n * `superseding_code`) AND the DERIVED `purchase_price_minor` (PR4 — direct net, or\n * two-file `list × (1 − groupDiscount)` resolved from `params.discountGroups`). Core\n * charge (Korpusetasu, PR7, D004) has TWO encodings → one `core_charge_minor`:\n * Encoding A is a `surcharge`/`core_charge` COLUMN on the saleable row (written here);\n * Encoding B is a separate `<code>*S` core-fee row that skips the saleable index and\n * emits a doc-patch attaching its deposit to the PARENT part in the runner's phase-2\n * pass. Replacement-pair SIDE EFFECTS (separate entity) are PR6 — emitted from\n * `superseding_code` and/or `replaces_code`, which name the same relationship from\n * opposite ends and are pairs-only (`replaces_code` has no `PartsDocument` column).\n *\n * **Money.** Every money field is integer minor units via `toMinorUnits` (F003), and\n * the two-file derivation stays in minor units end-to-end (no major↔minor\n * round-trip). The legacy `net_price_eur` stays a major-unit float for back-compat:\n * it holds the direct net price, or — for a two-file LIST feed with no direct net —\n * the list price as a display placeholder (the true cost is `purchase_price_minor`).\n *\n * **Cost-pending warning.** A list-only row whose `discount_group` doesn't resolve\n * against `params.discountGroups` is still indexed (list set, `purchase_price_minor`\n * null) and carries a `no_purchase_price` WARNING — not a row error (phase 03 §5).\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { z } from 'zod'\nimport type { DirectionalPair, DocPatch, RowWarning } from '../transform.js'\nimport type { CellValue } from './column-transforms.js'\nimport { toMinorUnits } from './number-format.js'\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n textOrNull,\n} from './target-schema.js'\n\n/**\n * Params the `parts` schema reads from the run — the per-(supplier, brand)\n * identity resolved at enqueue time. Identical to the carmaker transform's params\n * (the carmaker path re-exports this), so brand/supplier injection + the optional\n * supplier code-prefix strip are validated the same way everywhere.\n */\nexport const partsParamsSchema = z.object({\n brandId: z.string().uuid(),\n brandSlug: z.string().min(1).max(64),\n supplierId: z.string().uuid(),\n supplierDisplayName: z.string().min(1).max(255),\n importBatchId: z.string().uuid(),\n /** Supplier code prefix to strip before normalization (e.g. `'ME-'`). Optional. */\n supplierCodePrefix: z\n .string()\n .max(8)\n .regex(/^[A-Z0-9-]+$/, 'must be uppercase alphanumeric or \"-\"')\n .optional(),\n /**\n * Purchase-discount map for this supplier (commerce-demo PR4, phase 03 §2):\n * `{ groupCode → discountPct }`, resolved from `supplier_discount_group` at\n * enqueue time and snapshotted onto the run so `buildRow` stays a pure function\n * (no per-row DB I/O; `@murumets-ee/imports` stays a leaf). Absent for direct-net\n * suppliers + the carmaker path. Percentages are 0–100 (the entity constrains them).\n */\n discountGroups: z.record(z.string(), z.number().min(0).max(100)).optional(),\n})\nexport type PartsParams = z.infer<typeof partsParamsSchema>\n\n/** Per-run memoization of the parsed params (keyed on the `ctx.params` reference). */\nconst paramsCache = new WeakMap<object, PartsParams>()\n\nfunction resolveParams(raw: Record<string, unknown>): PartsParams {\n const cached = paramsCache.get(raw)\n if (cached) return cached\n const parsed = partsParamsSchema.parse(raw)\n paramsCache.set(raw, parsed)\n return parsed\n}\n\n/** Per-locale display-name target fields (8 locales). */\nconst NAME_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'nl', 'pt', 'ro'] as const\ntype NameLocale = (typeof NAME_LOCALES)[number]\nconst nameKey = (l: NameLocale): `name_${NameLocale}` => `name_${l}`\n\n/**\n * The `parts` target-field whitelist.\n *\n * **On the two supersession fields.** They exist as a PAIR because the direction of a\n * \"replacement\" column cannot be read off its name. TecDoc's data format defines `SupersNo` /\n * `ErsatzNr` as *\"Article ArtNo supersedes Article SupersNo\"* — the column holds the OLD code\n * and the row is current. German `ersetzt durch` and English `replaced by` mean the exact\n * opposite: the column holds the NEW code and the row is obsolete. Both are \"replacement\"\n * words; only the grammatical voice distinguishes them, and a bare noun like `Ersatznummer`\n * carries no voice at all. TecDoc's own SOAP API resolved this by shipping `replacesArticles`\n * and `replacedByArticles` as separate elements rather than one ambiguous field, and that is\n * what these two keys are.\n *\n * Getting it backwards is silent — an inverted feed builds well-formed chains with valid heads\n * and no warnings, and the union-find merge is append-only, so re-importing the right way round\n * does NOT repair it. Hence: no alias in this file is ambiguous about direction, and the\n * genuinely ambiguous spellings (`superseded no`, `Ersatznummer`, `Ersatz-Nr`) are deliberately\n * aliased to NEITHER field, so an operator meeting one has to state the direction rather than\n * inherit a guess.\n */\nconst PARTS_FIELDS: readonly TargetField[] = [\n {\n key: 'code',\n label: 'Code',\n required: true,\n type: 'string',\n // `part no` already covers a `partno` header: aliases are compared as bare alphanumerics,\n // and a header matches on a run of its own tokens, so both spellings reduce to `partno`.\n aliases: [\n 'part no',\n 'part number',\n 'article name',\n 'article no',\n 'article number',\n 'sku',\n 'varuosa kood',\n 'kood',\n 'artikelnummer',\n 'artikel nr',\n ],\n },\n {\n key: 'net_price',\n label: 'Net price',\n required: false,\n type: 'number',\n aliases: ['price', 'net price discount', 'nettopreis', 'netto'],\n },\n {\n key: 'gross_price',\n label: 'Gross price',\n required: false,\n type: 'number',\n aliases: ['bruttopreis', 'brutto'],\n },\n {\n key: 'list_price',\n label: 'List price',\n required: false,\n type: 'number',\n // `JAEHIND KM-TA` is Estonian for \"retail price, VAT-excluded\" (R03 sample A).\n aliases: ['retail price', 'rrp', 'jaehind', 'jaehind km ta', 'listenpreis'],\n },\n {\n key: 'currency',\n label: 'Currency',\n required: false,\n type: 'string',\n aliases: ['currency cd', 'currency code', 'valuuta'],\n },\n {\n key: 'barcode',\n label: 'Barcode',\n required: false,\n type: 'string',\n // `ean code` / `gtin code` are whole-header aliases on purpose: without them a header\n // spelled `EAN Code` matches the bare `code` token and claims the REQUIRED part-code\n // field, because `code` (4 chars) outscores the `ean` alias (3). A whole-header hit\n // outranks any partial run, so naming the compound form settles it.\n aliases: ['ean', 'ean code', 'gtin', 'gtin code', 'triipkood'],\n },\n {\n key: 'description1',\n label: 'Description 1',\n required: false,\n type: 'string',\n aliases: ['description', 'kirjeldus', 'beschreibung'],\n },\n { key: 'description2', label: 'Description 2', required: false, type: 'string' },\n {\n key: 'discount_group',\n label: 'Discount group',\n required: false,\n type: 'string',\n // `TOOTEGRUPP` is Estonian for \"product group\" (R03 sample A), which IS the discount key.\n // A bare `group` is deliberately NOT here: on a parts feed it is as likely an assortment\n // or category group, and a wrong-but-colliding value resolves against\n // `params.discountGroups` and derives a wrong `purchase_price_minor` with no warning at all\n // (only a NON-colliding code raises `no_purchase_price`). A silent money error is worth\n // more than a saved click.\n aliases: ['product group', 'tootegrupp', 'rabattgruppe'],\n },\n {\n /**\n * The NEWER code that supersedes THIS row — the row's own code is the obsolete one.\n * Emits `(this row → the value)`.\n */\n key: 'superseding_code',\n label: 'Replaced by (newer code)',\n required: false,\n type: 'string',\n // Every entry states direction by grammar: a passive (\"replaced by\", \"ersetzt durch\",\n // \"vervangen door\") or a word that means \"the one after\" (\"successor\", \"new code\",\n // \"Nachfolgenummer\"). A bare noun phrase like `replacement code` does NOT — it reads just\n // as easily as \"the code this replaces\" — so it is excluded for the same reason\n // `Ersatznummer` is.\n aliases: [\n 'replaced by',\n 'successor',\n 'successor code',\n 'new code',\n 'ersetzt durch',\n 'wird ersetzt durch',\n 'nachfolgenummer',\n 'vervangen door',\n ],\n },\n {\n /**\n * The OLDER code THIS row supersedes — the row's own code is the current one. Emits the\n * INVERSE pair `(the value → this row)`. This is TecDoc table 204's shape.\n */\n key: 'replaces_code',\n label: 'Replaces (older code)',\n required: false,\n type: 'string',\n // Same rule from the other side: active verbs (\"replaces\", \"supersedes\") or words meaning\n // \"the one before\" (\"predecessor\", \"old code\", \"Vorgängernummer\"). `superseded article` is\n // NOT here — TecDoc reads that phrasing as the old code, but it is the same bare\n // past-participle family as `superseded no`, which this file refuses to auto-assign;\n // excluding one and accepting the other would be incoherent.\n aliases: [\n 'replaces',\n 'supersedes',\n 'old code',\n 'previous code',\n 'predecessor',\n 'vorgaengernummer',\n ],\n },\n // Core charge (Korpusetasu, commerce-demo PR7, D004). Encoding A: a supplier's\n // `surcharge`/`core_charge` COLUMN maps here → this row's deposit. (Encoding B —\n // a separate `<code>*S` row — needs no whitelist field; it's detected on the code.)\n {\n key: 'core_charge',\n label: 'Core charge',\n required: false,\n type: 'number',\n aliases: ['surcharge', 'deposit', 'core deposit', 'korpusetasu', 'pfand'],\n },\n ...NAME_LOCALES.map(\n (l): TargetField => ({\n key: nameKey(l),\n label: `Name (${l})`,\n required: false,\n type: 'string',\n // The carmaker feed ships these as `Description_<LOCALE>` (R03 sample D).\n aliases: [`description ${l}`, `name ${l}`],\n }),\n ),\n] as const\n\nconst NORMALIZE_CODE_RE = /[\\s-]/g\n/**\n * Uppercase + dash/whitespace-stripped (D2). `*`/other chars are preserved (e.g. `*S`\n * markers). EXPORTED so downstream consumers that key off `code_normalized` — the\n * replacement editor (commerce-demo PR6), which normalizes admin-typed codes before\n * matching members — use the IDENTICAL rule (R05 Q7: normalization must match across\n * every code source or merge-on-collision silently fails to collide).\n */\nexport function normalizeCode(s: string): string {\n return s.toUpperCase().replace(NORMALIZE_CODE_RE, '')\n}\n\n/**\n * A supersession cell → one normalized code, or `null` when there is no usable edge end:\n * unmapped, blank, or punctuation-only (a `'---'` placeholder normalizes to the empty string).\n *\n * **The supplier prefix must be stripped here too.** `code_normalized` removes\n * `params.supplierCodePrefix` before normalizing, so a feed that writes `FO-1120698` in its own\n * code column also writes `FO-1091888` in its supersession column. Normalizing one with the\n * prefix and the other without produces an edge between a code that exists in the catalogue and\n * one that never will — the chain builds cleanly and matches nothing, which is indistinguishable\n * from having no supersession data at all.\n */\nfunction normalizeCodeCell(\n cell: CellValue | undefined,\n supplierCodePrefix: string | undefined,\n): string | null {\n const raw = textOrNull(cell)\n if (raw === null) return null\n const stripped =\n supplierCodePrefix && raw.startsWith(supplierCodePrefix)\n ? raw.slice(supplierCodePrefix.length)\n : raw\n const normalized = normalizeCode(stripped)\n return normalized === '' ? null : normalized\n}\n\n/**\n * Two-file purchase price (phase 03 §2): `list × (1 − discountPct/100)`, computed\n * ENTIRELY in integer minor units so no major↔minor round-trip can introduce drift\n * (F003). `listMinor` is already minor units; the result is rounded to the nearest\n * minor unit. Worked (R03 A+B): `5502 × 0.74 = 4071.48 → 4071` (€40.71).\n */\nfunction deriveTwoFilePurchaseMinor(listMinor: number, discountPct: number): number {\n return Math.round(listMinor * (1 - discountPct / 100))\n}\n\n/**\n * The core-charge marker suffix on a NORMALIZED code (D004, SD002). `normalizeCode`\n * uppercases + strips dashes/spaces but PRESERVES `*`, so a `<code>*S` core-fee row\n * arrives here intact — e.g. `1120698*S`. A code ending in `*S` is a core-fee line,\n * not a saleable part (Encoding B).\n */\nconst CORE_FEE_SUFFIX = '*S'\n\n/** The five core-charge doc fields the two encodings write. */\ntype CoreChargeFields = Pick<\n PartsDocument,\n | 'core_charge_minor'\n | 'core_charge_currency'\n | 'core_charge_refundable'\n | 'core_charge_tax_class'\n | 'core_charge_tax_inclusive'\n>\n\n/**\n * The tax-NEUTRAL core-charge block (D004 / D007) for a deposit amount in minor\n * units, or all-null when there's no core charge. EUR (D9) + refundable by default;\n * the imported EU feed amounts are net so `tax_inclusive = false`; `tax_class` stays\n * null — no rate is baked in here, the future pluggable EU/US tax engine maps it (D007).\n */\nfunction coreChargeFields(minor: number | null): CoreChargeFields {\n if (minor === null) {\n return {\n core_charge_minor: null,\n core_charge_currency: null,\n core_charge_refundable: null,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: null,\n }\n }\n return {\n core_charge_minor: minor,\n core_charge_currency: 'EUR',\n core_charge_refundable: true,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: false,\n }\n}\n\n/**\n * The `parts` schema. `buildRow` THROWS on invalid run params (so a misconfigured\n * run surfaces as a transform error, matching the carmaker contract) and returns a\n * typed `RowResult` per row otherwise.\n */\nexport const partsSchema: TargetSchema<PartsDocument> = {\n name: 'parts',\n fields: PARTS_FIELDS,\n buildRow(mapped: MappedRow, ctx: BuildRowContext) {\n const params = resolveParams(ctx.params)\n\n // 1. Code (required) → raw `code` + normalized `code_normalized` + doc id.\n const codeRaw = typeof mapped.code === 'string' ? mapped.code.trim() : ''\n if (!codeRaw) return rowError('missing_code', 'code is empty', 'code')\n const stripped =\n params.supplierCodePrefix && codeRaw.startsWith(params.supplierCodePrefix)\n ? codeRaw.slice(params.supplierCodePrefix.length)\n : codeRaw\n const codeNormalized = normalizeCode(stripped)\n if (!codeNormalized) {\n return rowError(\n 'empty_normalized_code',\n `Code \"${codeRaw}\" is empty after normalization`,\n 'code',\n )\n }\n\n // 2. Currency gate (D9 EUR-only). Unmapped/blank defaults to EUR. Runs BEFORE the\n // `*S` detection so a non-EUR core-fee row is rejected too — Encoding A and B keep\n // the SAME currency contract (a `*S` deposit is never silently coerced to EUR).\n const currencyText = textOrNull(mapped.currency)\n const currency = currencyText ?? 'EUR'\n if (currency !== 'EUR') {\n return rowError(\n 'unsupported_currency',\n `Currency \"${currency}\" not supported (EUR-only)`,\n 'currency',\n )\n }\n\n // 2b. Core-fee line — Encoding B (D004, SD002). A `<code>*S` normalized code is a\n // core-fee MARKER row, not a saleable part: strip the `*S`, take THIS row's net\n // price as the deposit, and emit a doc-patch that attaches it onto the PARENT\n // part's core-charge fields. The row itself SKIPS the saleable index (never a\n // `*S` SKU — D004). The runner applies the patch in a phase-2 pass; a parent\n // that isn't in the import becomes an `orphan_core_charge` warning (never a\n // phantom doc, never a silent drop). A `*S` with no parent code, or no/invalid\n // amount, is a row error.\n if (codeNormalized.endsWith(CORE_FEE_SUFFIX)) {\n const parentNormalized = codeNormalized.slice(0, -CORE_FEE_SUFFIX.length)\n if (!parentNormalized) {\n return rowError(\n 'core_charge_no_parent',\n `Core-fee row \"${codeRaw}\" has no part code before the \"*S\" marker`,\n 'code',\n )\n }\n const deposit = numberCell(mapped.net_price)\n if (deposit.kind === 'invalid') {\n return rowError(\n 'invalid_core_charge',\n 'core-fee (*S) row net_price is not a number',\n 'net_price',\n )\n }\n if (deposit.kind === 'absent') {\n return rowError(\n 'missing_core_charge',\n 'core-fee (*S) row has no net_price to use as the deposit',\n 'net_price',\n )\n }\n // Gate on the MINOR-unit result (a sub-cent value like 0.004 → round to 0):\n // 0 / sub-cent / negative deposit → nothing to attach; drop it without a patch\n // rather than zeroing a parent's core charge.\n const depositMinor = toMinorUnits(deposit.value)\n if (depositMinor <= 0) {\n return { kind: 'skip', reason: 'core_fee_zero' }\n }\n const patch: DocPatch<PartsDocument> = {\n id: `${parentNormalized}__${params.supplierId}`,\n doc: coreChargeFields(depositMinor),\n orphanWarning: {\n warningType: 'orphan_core_charge',\n message: 'core-fee (*S) row references a part not present in this import',\n field: 'code',\n },\n }\n return { kind: 'skip', reason: 'core_charge_marker', patches: [patch] }\n }\n\n // 3. Prices. net/gross/list each: absent → null, invalid → error, value → number.\n const net = numberCell(mapped.net_price)\n if (net.kind === 'invalid')\n return rowError('invalid_net_price', 'net_price is not a number', 'net_price')\n const gross = numberCell(mapped.gross_price)\n if (gross.kind === 'invalid') {\n return rowError('invalid_gross_price', 'gross_price is not a number', 'gross_price')\n }\n const list = numberCell(mapped.list_price)\n if (list.kind === 'invalid')\n return rowError('invalid_list_price', 'list_price is not a number', 'list_price')\n\n const netValue = net.kind === 'value' ? net.value : null\n const listValue = list.kind === 'value' ? list.value : null\n // At least one price must resolve. `net_price_eur` is non-nullable on the doc,\n // so it takes the direct net or — for a list-only two-file feed — the list\n // price as a display placeholder (the true cost is `purchase_price_minor`).\n const displayPrice = netValue ?? listValue\n if (displayPrice === null) {\n return rowError('missing_price', 'no net_price or list_price provided', 'price')\n }\n\n // 4. Purchase-price derivation (phase 03 §2, PR4). All money stays in integer\n // minor units (F003 — never round-trip back to major).\n // - direct net column (R03 C/D) → purchase = net\n // - two-file list × (1 − groupDiscount) → purchase = round(list × (1 − d/100))\n // - neither resolvable → cost pending: index list-only + WARN\n // (a `no_purchase_price` warning, NOT a row error — the part is still indexed).\n const discountGroup = textOrNull(mapped.discount_group)\n const listMinor = listValue === null ? null : toMinorUnits(listValue)\n let purchasePriceMinor: number | null = null\n const warnings: RowWarning[] = []\n if (netValue !== null) {\n purchasePriceMinor = toMinorUnits(netValue)\n } else if (listMinor !== null) {\n // List-only row: resolve the supplier's discount group → derive cost.\n const discountPct =\n discountGroup !== null ? params.discountGroups?.[discountGroup] : undefined\n if (discountPct !== undefined) {\n purchasePriceMinor = deriveTwoFilePurchaseMinor(listMinor, discountPct)\n } else {\n warnings.push({\n warningType: 'no_purchase_price',\n message:\n discountGroup === null\n ? 'list price present but no discount group on the row — cost pending'\n : `no discount group \"${discountGroup}\" configured for this supplier — cost pending`,\n field: 'discount_group',\n })\n }\n }\n\n // 5. Replacement-pair side-output (commerce-demo PR6, SD007). Both supersession columns\n // are SPARSE on a full manufacturer catalogue, and both produce the same\n // `(from = older → to = newer)` edge the chain engine consumes — they differ only in\n // which end of the edge the row itself is:\n // `superseding_code` → the value is NEWER, so the edge runs (this row → value);\n // `replaces_code` → the value is OLDER, so the edge runs (value → this row).\n // Every code is normalized the SAME way as `code_normalized` so the downstream merge\n // collides codes across feeds (R05 Q7). A value that is empty, normalizes to empty\n // (e.g. `'---'`), or self-pairs yields no edge. A row MAY carry both columns — that is\n // a legitimate three-code chain (older → this → newer), not a conflict.\n const pairs: DirectionalPair[] = []\n const supersedingNormalized = normalizeCodeCell(\n mapped.superseding_code,\n params.supplierCodePrefix,\n )\n if (supersedingNormalized !== null && supersedingNormalized !== codeNormalized) {\n pairs.push({ from: codeNormalized, to: supersedingNormalized })\n }\n const replacesNormalized = normalizeCodeCell(mapped.replaces_code, params.supplierCodePrefix)\n if (replacesNormalized !== null && replacesNormalized !== codeNormalized) {\n pairs.push({ from: replacesNormalized, to: codeNormalized })\n }\n\n // 6. Core charge — Encoding A (D004). A `surcharge`/`core_charge` COLUMN on this\n // saleable row is the deposit for THIS part. 0 / empty / absent / sub-cent → no\n // core charge. (Encoding B — a separate `<code>*S` row — is handled at step 1b\n // and attaches to the parent in phase-2, so it never reaches here. In the unusual\n // case a part has BOTH a `core_charge` column AND a sibling `*S` row — suppliers\n // use one encoding — the `*S` phase-2 patch runs after indexing and wins.)\n const coreChargeCell = numberCell(mapped.core_charge)\n if (coreChargeCell.kind === 'invalid') {\n return rowError('invalid_core_charge', 'core_charge is not a number', 'core_charge')\n }\n // Gate on the MINOR-unit result so a sub-cent value (rounds to 0) yields no charge.\n const coreChargeMinorRaw =\n coreChargeCell.kind === 'value' ? toMinorUnits(coreChargeCell.value) : null\n const coreChargeMinor =\n coreChargeMinorRaw !== null && coreChargeMinorRaw > 0 ? coreChargeMinorRaw : null\n\n const docId = `${codeNormalized}__${params.supplierId}`\n const doc: PartsDocument = {\n doc_id: docId,\n code: codeRaw,\n code_normalized: codeNormalized,\n brand_id: params.brandId,\n brand_slug: params.brandSlug,\n supplier_id: params.supplierId,\n supplier_display_name: params.supplierDisplayName,\n net_price_eur: displayPrice,\n gross_price_eur: gross.kind === 'value' ? gross.value : null,\n currency: 'EUR',\n barcode: textOrNull(mapped.barcode),\n name_de: textOrNull(mapped.name_de),\n name_en: textOrNull(mapped.name_en),\n name_es: textOrNull(mapped.name_es),\n name_fr: textOrNull(mapped.name_fr),\n name_it: textOrNull(mapped.name_it),\n name_nl: textOrNull(mapped.name_nl),\n name_pt: textOrNull(mapped.name_pt),\n name_ro: textOrNull(mapped.name_ro),\n description1: textOrNull(mapped.description1),\n description2: textOrNull(mapped.description2),\n // commerce-demo: list price → minor units; group + code passthroughs.\n list_price_minor: listMinor,\n discount_group: discountGroup,\n superseding_code: textOrNull(mapped.superseding_code),\n // Derived purchase cost (PR4 — direct net or two-file list × (1 − d)); null = cost pending.\n purchase_price_minor: purchasePriceMinor,\n // Core charge (PR7, Encoding A column) — or all-null when this row has none.\n ...coreChargeFields(coreChargeMinor),\n import_batch_id: params.importBatchId,\n imported_at: new Date().toISOString(),\n }\n\n return {\n kind: 'success',\n id: docId,\n doc,\n ...(warnings.length > 0 && { warnings }),\n ...(pairs.length > 0 && { pairs }),\n // Value-drift sampling (F010 / #602) — the DERIVED cost, not the raw\n // net/list price, so a decimal-format flip on either the direct-net\n // or two-file source column shows up here regardless of which pricing\n // path this supplier's feed uses. `null` (cost-pending) is omitted,\n // same as `no_purchase_price` warnings already do — the collector\n // counts it toward `nullRate` instead.\n ...(purchasePriceMinor !== null && { priceSample: purchasePriceMinor }),\n }\n },\n}\n"],"mappings":"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"}
|
package/dist/plugin.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as EsClientResolver, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver } from "./worker-
|
|
1
|
+
import { n as EsClientResolver, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver } from "./worker-_MlRH5hd.mjs";
|
|
2
2
|
import { ImportsStorageFactory } from "./storage.mjs";
|
|
3
3
|
import { IndexConfig } from "@murumets-ee/search-elasticsearch";
|
|
4
4
|
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e,o as t,u as n}from"./transform-
|
|
1
|
+
import{n as e,o as t,u as n}from"./transform-Cu5YEoOl.mjs";import{registerImportsStorageFactory as r}from"./storage.mjs";import{i,t as a}from"./carmaker-feed-B-QYr3PI.mjs";import{n as o,t as s}from"./mapping-transform-CO4MNhMe.mjs";import{createImportsRunDeadListener as c,createRunImportHandler as l,importsRunJob as u}from"./worker.mjs";import{definePlugin as d}from"@murumets-ee/core";import{z as f}from"zod";import{inArray as p}from"drizzle-orm";import{createIndex as m,dropIndex as h,readAliasTargets as g,reindex as _}from"@murumets-ee/search-elasticsearch";import{defineJob as v}from"@murumets-ee/queue/client";const y=f.object({toIndex:f.string().min(1).optional(),allowExisting:f.boolean().optional(),dropOld:f.boolean().optional(),batchSize:f.number().int().min(1).max(1e4).optional()}),b=v({name:`imports:reindex`,description:`Copy the aliased parts index into a new physical index built with the current mapping, then atomically swap the alias onto it.`,schema:y,defaultRetries:0}),x=[`pending`,`running`];async function S(e){let t=e.getTable();return e.count({where:p(t.status,[...x])})}function C(e){let t=/^(.*)_v(\d+)$/.exec(e);if(!t)return null;let[,n,r]=t;if(n===void 0||r===void 0)return null;let i=Number.parseInt(r,10)+1;return Number.isSafeInteger(i)?`${n}_v${i}`:null}function w(e){let{esClient:t,esIndex:n,indexConfig:r,importRuns:i,logger:a}=e;return async e=>{let o=a?.child({jobId:e.id,type:`imports:reindex`});if(r===void 0)throw Error(`imports:reindex requires imports({ indexConfig }) — the destination index must be created with the settings + mappings this app intends, otherwise Elasticsearch auto-creates it with inferred text mappings and the reindex silently discards the mapping change it exists to ship. Pass your app's index config (for the parts catalog: partsIndexConfig from @murumets-ee/search-elasticsearch).`);let s=y.parse(e.payload);if(i!==void 0){let e=await S(i);if(e>0)throw Error(`imports:reindex: ${e} feed import(s) are pending or running. An import writes through the alias "${n}", which points at the SOURCE index for the whole copy, so anything it writes now would be lost at the swap. Wait for them to finish (or cancel them) and re-run.`)}let c=await t(),l=Object.keys(await g(c,n)).sort();if(l.length>1)throw Error(`imports:reindex: alias "${n}" points at ${l.length} indices (${l.join(`, `)}). Reindexing would copy only one of them and then swap, leaving the alias on a mix of stale and fresh data. Resolve the alias to a single index first, then re-run. An interrupted reindex cannot cause this — swapAlias sends its remove with must_exist, so a stale swap fails instead of adding a second target. Look for a manual alias edit, another tool writing to this cluster, or a swap issued before that guard existed.`);let u=l[0];if(u===void 0)throw Error(`imports:reindex: alias "${n}" does not exist, so there is nothing to reindex. Run an import first — imports({ ensureIndex }) bootstraps the alias on the import path.`);let d=s.toIndex??C(u);if(d===null)throw Error(`imports:reindex: alias "${n}" points at "${u}", which does not follow the "<name>_v<N>" convention, so the next version cannot be derived. Pass an explicit destination (\`lumi imports:reindex --to <index>\`).`);let f=d;if(f===u)throw Error(`imports:reindex: destination "${f}" is the index the alias already points at. Reindexing an index onto itself would copy every document over itself and swap the alias onto where it already is. Pass a different --to.`);let p=await c.indices.exists({index:f});if(p&&s.allowExisting!==!0)throw Error(`imports:reindex: destination "${f}" already exists. If it is debris from a failed run, re-run with --resume to continue into it (bulkUpsert is idempotent by _id, so already-copied documents are simply overwritten); if it was created by hand with a different mapping, delete it or pass --to <another-index> instead. Refusing to reuse it silently, because doing so would apply ITS mapping rather than the one you changed.`);e.updateProgress({phase:p?`resuming`:`creating`,fromIndex:u,toIndex:f,processed:0,total:0}),p?o?.warn({fromIndex:u,toIndex:f},`imports:reindex: resuming into an existing destination index (--resume); its EXISTING mapping applies, not indexConfig`):(o?.info({fromIndex:u,toIndex:f},`imports:reindex: creating destination index`),await m(c,{name:f,config:r})),o?.info({fromIndex:u,toIndex:f},`imports:reindex: copying documents`);let v=await _({client:c,alias:n,fromIndex:u,toIndex:f,refreshOnCompletion:!0,...s.batchSize!==void 0&&{batchSize:s.batchSize},...i!==void 0&&{beforeSwap:async()=>{let e=await S(i);if(e>0)throw Error(`imports:reindex: ${e} feed import(s) started while the copy was running. Their documents went to the source index through the alias and are NOT in "${f}", so swapping now would drop them. The alias is untouched and "${f}" is left in place — wait for the import(s), then re-run with --resume to finish.`)}},reportProgress:async({processed:t,total:n})=>{e.updateProgress({phase:`copying`,fromIndex:u,toIndex:f,processed:t,total:n})}});e.updateProgress({phase:`swapped`,fromIndex:u,toIndex:f,processed:v.processed,total:v.processed+v.skipped,skipped:v.skipped}),o?.info({fromIndex:u,toIndex:f,processed:v.processed,skipped:v.skipped},`imports:reindex: alias swapped onto destination`),s.dropOld===!0&&(await h(c,u),o?.info({fromIndex:u},`imports:reindex: dropped source index (--drop-old)`))}}const T=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function E(f){let p=f.esIndex,m=f.storageBucket===null?null:{bucket:f.storageBucket??`lumi-imports`,orphanTtlHours:f.orphanUploadTtlHours??2,processedRetentionDays:f.processedRetentionDays??7};return f.storage&&r(f.storage),d({name:`@murumets-ee/imports`,server:{entities:[n,t],init:async t=>{let r=e();if(r.has(`commerce:mapping`)||r.register(s,o),r.has(`commerce:carmaker-feed`)||r.register(a,i),!t.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){t.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let d=await import(`@murumets-ee/queue/client`),h=t.getClient(n);d.registerJob(u,l({importRuns:h,transforms:r,esClient:f.esClient,esIndex:p,...f.ensureIndex!==void 0&&{ensureIndex:f.ensureIndex},...f.resolveFilePath!==void 0&&{resolveFilePath:f.resolveFilePath},...f.pairSink!==void 0&&{pairSink:f.pairSink},logger:t.logger.child({pkg:`imports`})})),d.registerJob(b,w({esClient:f.esClient,esIndex:p,importRuns:h,...f.indexConfig!==void 0&&{indexConfig:f.indexConfig},logger:t.logger.child({pkg:`imports`})}));let g=globalThis[T];g&&g();let _=d.addQueueTerminalListener(c({importRuns:h,logger:t.logger.child({pkg:`imports`})}));if(globalThis[T]=_,m!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:n}=await import(`./cleanup-DDljBe0Z.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=t.logger.child({pkg:`imports`,job:`cleanup`});d.registerJob(d.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await r(()=>e({app:t,bucket:m.bucket,ttlHours:m.orphanTtlHours,logger:i}))}),d.registerJob(d.defineJob({name:`imports:cleanup-processed-uploads`,description:`Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.`,schedule:`@every 1h`}),async()=>{await r(()=>n({app:t,bucket:m.bucket,retentionDays:m.processedRetentionDays,logger:i}))})}t.logger.info({esIndex:p,cleanup:m===null?{enabled:!1}:{enabled:!0,...m}},`Imports plugin initialized`)}}})}export{E as imports};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{z as e}from"zod";import{createReadStream as t}from"node:fs";import{parse as n}from"csv-parse";function r(e){let t=new Set;for(let n of e)if(n!==``){if(t.has(n))throw Error(`Duplicate header "${n}" after trimming — column names must be unique`);t.add(n)}}async function i(e,n={}){let{hasHeader:r=!0}=n,i=t(e),a=0,o=0;for await(let e of i){for(let t=0;t<e.length;t+=1)e[t]===10&&(a+=1);e.length>0&&(o=e[e.length-1]??0)}return o!==0&&o!==10&&(a+=1),r?Math.max(0,a-1):a}async function*a(e){let{filePath:i,delimiter:a=` `,hasHeader:o=!0,columns:s,relaxColumnCount:c=!1,quote:l=!1}=e,u=s?Array.from(s):o?e=>{let t=e.map(e=>e.trim());return r(t),t}:!1,d=t(i),f=d.pipe(n({delimiter:a,columns:u,bom:!0,skip_empty_lines:!0,relax_column_count:c,quote:l})),p=0;try{for await(let e of f){p+=1;let t={};if(Array.isArray(e)){let n=e;for(let e=0;e<n.length;e+=1)t[String(e)]=n[e]??``}else for(let[n,r]of Object.entries(e))t[n]=r??``;yield{rowNumber:p,row:t}}}finally{d.destroy()}}const o=e.object({format:e.enum([`csv`,`tsv`,`xlsx`]).optional(),delimiter:e.string().min(1).max(4).optional(),hasHeader:e.boolean().optional(),headerRowIndex:e.number().int().min(0).optional(),sheet:e.string().min(1).max(128).optional(),quote:e.union([e.string().length(1),e.literal(!1)]).optional()}).strict();function s(e){let t=e&&typeof e==`object`&&!Array.isArray(e)?e:{},n=t.feed===void 0||t.feed===null?{}:t.feed;return o.safeParse(n)}function c(e){let t=e.delimiter??(e.format===`csv`?`,`:` `);return{filePath:e.filePath,delimiter:t,...e.hasHeader!==void 0&&{hasHeader:e.hasHeader},...e.columns!==void 0&&{columns:e.columns},...e.relaxColumnCount!==void 0&&{relaxColumnCount:e.relaxColumnCount},...e.quote!==void 0&&{quote:e.quote}}}async function*l(e){if(e.format===`xlsx`){let{streamXlsx:t}=await import(`./xlsx-reader-_ioPAqm5.mjs`);yield*t({filePath:e.filePath,...e.hasHeader!==void 0&&{hasHeader:e.hasHeader},...e.headerRowIndex!==void 0&&{headerRowIndex:e.headerRowIndex},...e.sheet!==void 0&&{sheet:e.sheet}});return}yield*a(c(e))}export{i as a,r as i,l as n,a as o,s as r,o as t};
|
|
2
|
+
//# sourceMappingURL=read-feed-DGjqejw3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-feed-DGjqejw3.mjs","names":[],"sources":["../src/streaming.ts","../src/read-feed.ts"],"sourcesContent":["/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Throw if two (non-empty) header names collide. Headers are the keys the mapping\n * engine matches a `source` against; after trimming, two columns differing only in\n * whitespace would collapse to one key and silently overwrite each other's data.\n * Fail fast instead — the operator fixes the feed or the mapping. Shared by the\n * csv/tsv reader (below) and the xlsx reader. Blank columns are ignored (unmapped).\n */\nexport function assertUniqueHeaders(header: readonly string[]): void {\n const seen = new Set<string>()\n for (const h of header) {\n if (h === '') continue\n if (seen.has(h)) {\n throw new Error(`Duplicate header \"${h}\" after trimming — column names must be unique`)\n }\n seen.add(h)\n }\n}\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That was unconditionally fine while `quote: false` was the only\n * behaviour available; a mapping can now declare RFC 4180 (`quote`),\n * and an embedded newline in a quoted field then makes this an UPPER\n * BOUND rather than a count. It is used only as the progress\n * denominator (`totalRows`), so the visible consequence is a bar that\n * stops short of 100%, never a wrong import. The parsed row count from\n * `streamFeed` is authoritative and is what the run records.\n *\n * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with\n * `\\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap\n * matters — pre-pass needs to be a small fraction of the actual import\n * or the operator perceives the run as \"stuck on pre-pass\" with no UI.\n *\n * Returns the number of DATA rows (header excluded when present).\n * Empty trailing lines (no `\\n` after the final row, or a single\n * trailing `\\n`) are handled identically to how csv-parse treats them\n * with `skip_empty_lines: true`.\n */\nexport async function countDataRows(\n filePath: string,\n options: { hasHeader?: boolean } = {},\n): Promise<number> {\n const { hasHeader = true } = options\n const stream = createReadStream(filePath)\n let count = 0\n let lastByte = 0\n for await (const chunk of stream as AsyncIterable<Buffer>) {\n for (let i = 0; i < chunk.length; i += 1) {\n if (chunk[i] === 0x0a /* \\n */) count += 1\n }\n if (chunk.length > 0) lastByte = chunk[chunk.length - 1] ?? 0\n }\n // If the file doesn't end with a newline, the final row didn't get\n // counted by the `\\n` tally — add one. Skipping when the file is\n // entirely empty (count=0 and lastByte=0).\n if (lastByte !== 0 && lastByte !== 0x0a) count += 1\n return hasHeader ? Math.max(0, count - 1) : count\n}\n\nexport interface StreamFeedOptions {\n /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */\n filePath: string\n /**\n * Single-character field delimiter. Default `\\t` (the carmaker feed\n * format). Pass `,` for CSV, `;` for some European dialects.\n */\n delimiter?: string\n /**\n * `true` (default): the first row is the header and column names come\n * from it. `false`: rows are emitted as positional `{ \"0\": ..., \"1\": ... }`\n * and the transform reads by index — useful for headerless feeds that\n * commit to a documented column order.\n */\n hasHeader?: boolean\n /**\n * Optional explicit column-name list. When provided, takes precedence\n * over `hasHeader` (header row, if present, is skipped but its values\n * are ignored). Useful when the upstream header is unstable but the\n * positional shape isn't.\n */\n columns?: ReadonlyArray<string>\n /**\n * Forward to `csv-parse` `relax_column_count`. Default `false` —\n * a row whose column count doesn't match the header surfaces as a\n * parser error so the transform isn't silently fed truncated data.\n */\n relaxColumnCount?: boolean\n /**\n * Forward to `csv-parse` `quote`. The character (or `false`) that\n * encloses fields containing the delimiter. Default `false` — most\n * supplier feeds in this package's target use case are tab-delimited\n * exports that contain literal `\"` characters in description fields\n * (e.g. `LIVRE \"INVERSION D'IMAGE\"` from the Mercedes carmaker feed)\n * with NO quote-as-field-wrapper convention. csv-parse's library\n * default `'\"'` then misreads the literal `\"` as an opening quote\n * and rejects the row with `Invalid Opening Quote`.\n *\n * Pass `'\"'` (or any character) to opt back into RFC-4180-style\n * CSV parsing for genuine quoted-CSV inputs. Pass `'\\''` for\n * single-quoted dialects.\n */\n quote?: string | false\n}\n\nexport interface StreamFeedRow {\n /** 1-based row number. Header (when present) is row 0; first data row is row 1. */\n rowNumber: number\n /**\n * Cell values keyed by column name (or string-position when\n * `hasHeader: false` AND no `columns`).\n *\n * **Cell-value invariants:**\n * - Empty cells (`A\\t\\tC`) → `''` (empty string).\n * - Missing TRAILING cells in `relaxColumnCount: true` mode → the\n * key is **absent** from the object, not present-with-`''`. csv-parse\n * does not emit keys for short rows. Transforms reading those\n * columns get `undefined` from `row['col']` and must handle it\n * (`row['col'] ?? ''` is the canonical idiom).\n * - With the default `relaxColumnCount: false`, short rows reject at\n * the parser, so this case never reaches the transform.\n */\n row: Record<string, string>\n}\n\n/**\n * Async-iterable over the parsed rows of a delimited file. Use with\n * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.\n *\n * The iterator owns its file descriptor — the `for await` loop closes\n * the underlying stream when it returns or breaks. Aborting mid-stream\n * (`break`, `throw`, signal) is safe; csv-parse propagates the close.\n */\nexport async function* streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow> {\n const {\n filePath,\n delimiter = '\\t',\n hasHeader = true,\n columns,\n relaxColumnCount = false,\n quote = false,\n } = options\n\n // When the caller provides explicit `columns`, prefer them. When the file\n // has a header but no explicit `columns`, derive the column names from the\n // first row via a function so we can TRIM them — real supplier feeds ship\n // leading/trailing-space headers (R03 Samples A/C: `\" JAEHIND KM-TA\"`), and\n // an untrimmed key wouldn't match a mapping's `source`. The xlsx reader trims\n // headers the same way. When neither is true, rows get string-position keys.\n //\n // csv-parse's `columns` accepts `ColumnOption[] | boolean | ((record) =>\n // ColumnOption[])`; `string[]` satisfies `ColumnOption[]`.\n const columnConfig: string[] | boolean | ((header: string[]) => string[]) = columns\n ? Array.from(columns)\n : hasHeader\n ? (header: string[]) => {\n const trimmed = header.map((h) => h.trim())\n assertUniqueHeaders(trimmed)\n return trimmed\n }\n : false\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * Feed reader dispatcher (commerce-demo phase 02, D005). Selects the reader by\n * `format` and yields the uniform `AsyncIterable<{ rowNumber, row }>` the runner\n * consumes — csv/tsv via the existing `streamFeed` (csv-parse), xlsx via the\n * exceljs-backed `streamXlsx`.\n *\n * **Back-compat**: with no `format` (the carmaker worker passes only `filePath`),\n * this defaults to tab-delimited — the original `streamFeed` behaviour — so the\n * existing import path is unchanged.\n *\n * **Bundle safety**: the xlsx reader (and its heavy, Node-only exceljs dep) is\n * `await import()`-ed ONLY for `format: 'xlsx'`, so the csv/tsv path — and anything\n * that statically imports the runner — never pulls exceljs into its module graph.\n */\n\nimport { z } from 'zod'\nimport { type StreamFeedOptions, type StreamFeedRow, streamFeed } from './streaming.js'\n\n/**\n * The reader-config subset safe to persist in `import_run.params.feed` and rehydrate\n * in the worker (commerce-demo PR3). It is exactly the {@link ReadFeedOptions} fields\n * that describe HOW to parse a feed — `filePath` is supplied by the worker after it\n * materialises the upload, never carried in params. Validated at BOTH ends: the\n * commerce run route builds it from a saved `import_mapping`, the worker re-parses it\n * before handing it to {@link readFeed} (defense-in-depth against a tampered row).\n */\nexport const feedReaderConfigSchema = z\n .object({\n format: z.enum(['csv', 'tsv', 'xlsx']).optional(),\n delimiter: z.string().min(1).max(4).optional(),\n hasHeader: z.boolean().optional(),\n headerRowIndex: z.number().int().min(0).optional(),\n sheet: z.string().min(1).max(128).optional(),\n /**\n * csv-parse's `quote`. Absent (or `false`) = quotes are ordinary data, which is this\n * package's long-standing default and what the tab-delimited supplier dumps require. A\n * single character opts into RFC 4180 for feeds that genuinely quote their fields.\n *\n * Persisted deliberately: the two shapes are mutually exclusive on real data (R027 —\n * each of the three ground-truth feeds hard-fails under the other's setting), so this\n * is a per-file fact a mapping has to carry. Deriving it from `format` would be the\n * same class of mistake as a global decimal separator.\n */\n quote: z.union([z.string().length(1), z.literal(false)]).optional(),\n })\n .strict()\nexport type FeedReaderConfig = z.infer<typeof feedReaderConfigSchema>\n\n/**\n * Rehydrate the reader config a run persisted in `import_run.params.feed`.\n *\n * ONE implementation, because two would drift and drift here is silent: the\n * worker uses it to decide how to READ a feed, and the #842 audit uses it to\n * re-read the same file the same way. An audit that rehydrates differently from\n * the run it is auditing invents corruption, or misses it.\n *\n * ABSENT means \"the carmaker default\", i.e. tab-delimited, which is what an\n * empty config parses to — so a non-object `params`, or `params` with no\n * `feed`, is a real answer rather than a gap. A PRESENT `feed` is validated\n * whatever its shape: a scalar or an array there is a malformed persisted blob,\n * and reading it as \"no config\" would silently import an xlsx run as tsv.\n */\nexport function readPersistedFeedConfig(\n params: unknown,\n): ReturnType<typeof feedReaderConfigSchema.safeParse> {\n const record =\n params && typeof params === 'object' && !Array.isArray(params)\n ? (params as Record<string, unknown>)\n : {}\n // `null` is how \"absent\" round-trips through JSONB, so it defaults with\n // `undefined`. Everything else present goes to the schema and is rejected on\n // its merits rather than being flattened into the default.\n const feed = record.feed === undefined || record.feed === null ? {} : record.feed\n return feedReaderConfigSchema.safeParse(feed)\n}\n\nexport interface ReadFeedOptions {\n /** Path to the file on local disk. */\n filePath: string\n /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */\n format?: 'csv' | 'tsv' | 'xlsx'\n /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\\t`). */\n delimiter?: string\n /** First row is the header. Default `true`. */\n hasHeader?: boolean\n /** 0-based header row index (xlsx). */\n headerRowIndex?: number\n /** xlsx worksheet as a 1-based index string. Omit → first sheet. */\n sheet?: string\n /** Explicit column-name list (csv/tsv) — see `streamFeed`. */\n columns?: ReadonlyArray<string>\n /** Forward to csv-parse `relax_column_count` (csv/tsv). */\n relaxColumnCount?: boolean\n /** Forward to csv-parse `quote` (csv/tsv). */\n quote?: string | false\n}\n\nfunction toStreamFeedOptions(o: ReadFeedOptions): StreamFeedOptions {\n // No format → tab (the carmaker feed default). `csv` → comma; everything else → tab.\n const delimiter = o.delimiter ?? (o.format === 'csv' ? ',' : '\\t')\n return {\n filePath: o.filePath,\n delimiter,\n ...(o.hasHeader !== undefined && { hasHeader: o.hasHeader }),\n ...(o.columns !== undefined && { columns: o.columns }),\n ...(o.relaxColumnCount !== undefined && { relaxColumnCount: o.relaxColumnCount }),\n ...(o.quote !== undefined && { quote: o.quote }),\n }\n}\n\n/** Stream a feed file's data rows, dispatching on `format`. */\nexport async function* readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow> {\n if (options.format === 'xlsx') {\n const { streamXlsx } = await import('./xlsx-reader.js')\n yield* streamXlsx({\n filePath: options.filePath,\n ...(options.hasHeader !== undefined && { hasHeader: options.hasHeader }),\n ...(options.headerRowIndex !== undefined && { headerRowIndex: options.headerRowIndex }),\n ...(options.sheet !== undefined && { sheet: options.sheet }),\n })\n return\n }\n yield* streamFeed(toStreamFeedOptions(options))\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e}from"./read-feed-DGjqejw3.mjs";import{bulkPartialUpdate as t,bulkUpsert as n}from"@murumets-ee/search-elasticsearch";const r=50,i=5;var a=class{patterns=new Map;maxPatterns;maxSamplesPerPattern;droppedSignatures=0;constructor(e={}){this.maxPatterns=e.maxPatterns??50,this.maxSamplesPerPattern=e.maxSamplesPerPattern??5}addError(e,t,n,r,i){let a=`${t}:${r??`GENERAL`}:${n}`,o=this.patterns.get(a);if(!o){if(this.patterns.size>=this.maxPatterns){this.droppedSignatures+=1;return}o={errorType:t,field:r??null,message:n,count:0,firstOccurrence:e,lastOccurrence:e,samples:[]},this.patterns.set(a,o)}o.count+=1,o.lastOccurrence=e,o.samples.length<this.maxSamplesPerPattern&&o.samples.push({rowNumber:e,rowData:i})}getTotalErrorCount(){let e=0;for(let t of this.patterns.values())e+=t.count;return e}getDistinctPatternCount(){return this.patterns.size}getDroppedSignatureCount(){return this.droppedSignatures}getTopPatterns(){let e=Array.from(this.patterns.values()).sort((e,t)=>t.count-e.count),t=e.reduce((e,t)=>e+t.count,0);return e.map(e=>({errorType:e.errorType,field:e.field,message:e.message,count:e.count,firstOccurrence:e.firstOccurrence,lastOccurrence:e.lastOccurrence,samples:e.samples.slice(),percentage:t>0?e.count/t*100:0}))}snapshot(){return{totalErrors:this.getTotalErrorCount(),distinctPatterns:this.getDistinctPatternCount(),droppedSignatures:this.droppedSignatures,patterns:this.getTopPatterns()}}};function o(e,t){let n=e.totalErrors+t.totalErrors,r=e=>({...e,percentage:n>0?e.count/n*100:0});return{totalErrors:n,distinctPatterns:e.distinctPatterns+t.distinctPatterns,droppedSignatures:e.droppedSignatures+t.droppedSignatures,patterns:[...e.patterns.map(r),...t.patterns.map(r)]}}const s={count:0,nullRate:0,min:null,median:null,max:null},c=5e4;var l=class{count=0;nullCount=0;nonNullSeen=0;samples=[];record(e){if(this.count+=1,e===void 0){this.nullCount+=1;return}if(this.nonNullSeen+=1,this.samples.length<5e4){this.samples.push(e);return}let t=Math.floor(Math.random()*this.nonNullSeen);t<5e4&&(this.samples[t]=e)}snapshot(){if(this.count===0)return{...s};let e=this.nullCount/this.count;if(this.samples.length===0)return{count:this.count,nullRate:e,min:null,median:null,max:null};let t=[...this.samples].sort((e,t)=>e-t),n=t[0]??null,r=t[t.length-1]??null,i=Math.floor(t.length/2),a;if(t.length%2==0){let e=t[i-1],n=t[i];a=e!==void 0&&n!==void 0?(e+n)/2:null}else a=t[i]??null;return{count:this.count,nullRate:e,min:n,median:a,max:r}}};const u=10;function d(e,t){if(e.median===null||t.median===null||e.median<=0||t.median<=0)return null;let n=t.median/e.median;return n>=10||n<=1/10?{ratio:n,prevMedian:e.median,currMedian:t.median}:null}function f(e){if(typeof e!=`object`||!e)return null;let t=e;return typeof t.count!=`number`||typeof t.nullRate!=`number`?null:{count:t.count,nullRate:t.nullRate,min:typeof t.min==`number`?t.min:null,median:typeof t.median==`number`?t.median:null,max:typeof t.max==`number`?t.max:null}}const p=1e3;async function m(r){let{importRunId:i,runLabel:o,params:s,transform:c,feed:u,esClient:d,esIndex:f,batchSize:m=p,onProgress:h,rowLimit:g,signal:_,errorTracker:v=new a,warningTracker:y=new a,priceStats:b=new l,logger:x}=r;if(m<1)throw Error(`batchSize must be >= 1 (got ${m})`);let S=Date.now();x?.info({importRunId:i,runLabel:o,batchSize:m,esIndex:f},`imports.runner: started`);let C=0,w=0,T=0,E=0,D=0,O=0,k=[],A=[],j=[],M=async()=>{if(j.length===0)return;let e=j;j=[];let t=Date.now(),r;try{r=await n(d,{index:f,docs:e.map(({id:e,doc:t})=>({id:e,doc:t})),..._!==void 0&&{signal:_}})}catch(t){let n=_?.aborted??(t instanceof Error&&(t.name===`AbortError`||/abort/i.test(t.message)))?`aborted`:`bulk_request_failed`,r=t instanceof Error?t.message:String(t);for(let{rowNumber:t}of e)v.addError(t,n,r,void 0,null);throw T+=e.length,t}w+=r.succeeded,T+=r.failures.length,O+=1;let a=Date.now()-t,o=(Date.now()-S)/1e3;if(x?.info({importRunId:i,batch:O,flushMs:a,submitted:e.length,succeeded:r.succeeded,failed:r.failures.length,rowsRead:C,rowsPerSecond:o>0?Math.round(C/o):0},`imports.runner: batch flushed`),r.failures.length>0){let t=new Map(e.map(e=>[e.id,e.rowNumber]));for(let e of r.failures){let n=t.get(e.id)??-1;v.addError(n,e.type,e.reason,void 0,{id:e.id})}}let s=new Set(r.failures.map(e=>e.id));for(let t of e)if(!(!t.warnings||t.warnings.length===0)&&!s.has(t.id)){D+=1;for(let e of t.warnings)y.addError(t.rowNumber,e.warningType,e.message,e.field,t.rowData)}for(let t of e)!t.pairs||t.pairs.length===0||s.has(t.id)||k.push(...t.pairs);for(let t of e)s.has(t.id)||b.record(t.priceSample);if(h){let e=(Date.now()-S)/1e3;h({rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,rowsWarned:D,batchesCompleted:O,elapsedSeconds:e,rowsPerSecond:e>0?C/e:0,distinctErrorPatterns:v.getDistinctPatternCount()})}},N=m,P=new Map;for await(let{rowNumber:t,row:n}of e(u)){if(_?.aborted||g!==void 0&&C>=g)break;if(C+=1,C===1&&x?.info({importRunId:i,rowNumber:t,sampleKeys:Object.keys(n)},`imports.runner: STREAM_FIRST_ROW`),C%N===0){let e=(Date.now()-S)/1e3;x?.info({importRunId:i,rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,pending:j.length,elapsedSec:Math.round(e*10)/10,rowsPerSecond:e>0?Math.round(C/e):0,distinctFirstErrorTypes:P.size},`imports.runner: STREAM_TICK`)}let e={importRunId:i,params:s,runLabel:o,rowNumber:t},r;try{r=await c(n,e)}catch(e){let r=e instanceof Error?e.message:String(e);v.addError(t,`transform_threw`,r,void 0,n),P.has(`transform_threw`)||(P.set(`transform_threw`,{rowNumber:t,message:r}),x?.warn({importRunId:i,rowNumber:t,errorType:`transform_threw`,message:r},`imports.runner: FIRST_ERROR transform_threw`)),T+=1;continue}if(r.kind===`skip`){E+=1,r.patches&&r.patches.length>0&&A.push(...r.patches);continue}if(r.kind===`error`){v.addError(t,r.error.errorType,r.error.message,r.error.field,n),P.has(r.error.errorType)||(P.set(r.error.errorType,{rowNumber:t,message:r.error.message}),x?.warn({importRunId:i,rowNumber:t,errorType:r.error.errorType,field:r.error.field,message:r.error.message},`imports.runner: FIRST_ERROR ${r.error.errorType}`)),T+=1;continue}j.push({id:r.id,doc:r.doc,rowNumber:t,rowData:n,...r.warnings&&r.warnings.length>0&&{warnings:r.warnings},...r.pairs&&r.pairs.length>0&&{pairs:r.pairs},...r.priceSample!==void 0&&{priceSample:r.priceSample}}),j.length>=m&&await M()}await M();let F=0,I=0;for(let e=0;e<A.length;e+=m){if(_?.aborted)throw Error(`imports: run aborted during core-charge attach (phase 2)`);let n=A.slice(e,e+m),r;try{r=await t(d,{index:f,updates:n.map(({id:e,doc:t})=>({id:e,doc:t})),..._!==void 0&&{signal:_}})}catch(e){if(_?.aborted??(e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message))))throw e;let t=e instanceof Error?e.message:String(e);for(let e of n)y.addError(-1,`patch_attach_failed`,t,e.orphanWarning?.field,{target:e.id});continue}if(F+=r.succeeded,r.failures.length>0){let e=new Map(n.map(e=>[e.id,e]));for(let t of r.failures){let n=e.get(t.id)?.orphanWarning;t.status===404||t.type===`document_missing_exception`?(I+=1,y.addError(-1,n?.warningType??`orphan_patch`,n?.message??t.reason,n?.field,{target:t.id})):y.addError(-1,`patch_attach_failed`,t.reason,n?.field,{target:t.id})}}}let L=(Date.now()-S)/1e3;return x?.info({importRunId:i,rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,batchesCompleted:O,patchesApplied:F,patchesOrphaned:I,totalSec:Math.round(L*10)/10,rowsPerSecond:L>0?Math.round(C/L):0},`imports.runner: finished`),{rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,rowsWarned:D,batchesCompleted:O,errors:v.snapshot(),warnings:y.snapshot(),pairs:k,patchesApplied:F,patchesOrphaned:I,priceStats:b.snapshot()}}export{c as a,f as c,a as d,o as f,u as i,r as l,m as n,l as o,s as r,d as s,p as t,i as u};
|
|
2
|
+
//# sourceMappingURL=runner-C9GIe5Dr.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner-C9GIe5Dr.mjs","names":[],"sources":["../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n\n/**\n * Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.\n * the runner's per-row `warningSummary` plus a worker-level signal computed\n * AFTER the run completes (F010 / #602: the price-drift anomaly check runs\n * in `worker.ts`, after `runImport` returns, using a fresh single-pattern\n * tracker of its own). Patterns concatenate rather than re-dedup: the two\n * snapshots are assumed to use disjoint `errorType` signature spaces (a\n * worker-level tracker uses a type — `price_anomaly` — the runner's own\n * warning aggregation never emits), so counts sum directly with no risk of\n * double-counting one bucket as two.\n */\nexport function mergeErrorTrackerSnapshots(\n a: ErrorTrackerSnapshot,\n b: ErrorTrackerSnapshot,\n): ErrorTrackerSnapshot {\n const totalErrors = a.totalErrors + b.totalErrors\n // Each source snapshot computed `percentage` against its OWN `totalErrors`\n // (see `getTopPatterns` above); the merged total is different, so every\n // pattern's `percentage` must be recomputed against it — a straight\n // concat would leave every value stale (e.g. `20/100=20%` staying `20`\n // once the true merged denominator is `101`). `0` when the merged total\n // is `0`, matching `getTopPatterns`' own zero-total convention.\n const rescale = (p: ErrorPattern): ErrorPattern => ({\n ...p,\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n })\n return {\n totalErrors,\n distinctPatterns: a.distinctPatterns + b.distinctPatterns,\n droppedSignatures: a.droppedSignatures + b.droppedSignatures,\n patterns: [...a.patterns.map(rescale), ...b.patterns.map(rescale)],\n }\n}\n","/**\n * Price-distribution stats + prior-run anomaly detection (F010 / issue #602).\n *\n * The import pipeline's existing anomaly detection — register-handler.ts's\n * \"6b. Header-presence drift check\" — only catches STRUCTURAL drift: a\n * mapped column vanishing (or getting renamed) from the header row. It does\n * NOT catch VALUE drift: a supplier silently flipping their decimal\n * separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT\n * touching a single column header. Since the saved\n * `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,\n * every price in that run parses as a NUMERICALLY VALID but WRONG value —\n * silently corrupting the catalog's prices. Nothing upstream of this module\n * flags it.\n *\n * This module computes a per-run price-distribution snapshot (`PriceStats`)\n * over `PartsDocument.purchase_price_minor` — the DERIVED cost field,\n * rather than the raw net/list price columns, because both the direct-net\n * AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel\n * through it, so a decimal-format flip on EITHER source column surfaces\n * here regardless of which pricing path a supplier's feed uses.\n *\n * Collection (`PriceStatsCollector`) happens in the SAME streaming pass\n * `runner.ts` already uses to build `totals` — one `.record()` call per\n * ES-accepted row, no second pass over the file, no extra DB round-trip.\n * The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`\n * AFTER the run completes, because it needs `AdminClient` to look the\n * prior run up — this module stays DB-agnostic, pure data + pure functions.\n */\n\n/** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */\nexport interface PriceStats {\n /** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */\n count: number\n /** Fraction (0..1) of `count` whose price sample was absent (e.g. a list-only row with no resolvable purchase cost — the same rows that carry a `no_purchase_price` warning). `0` when `count` is `0`. */\n nullRate: number\n /** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n min: number | null\n /** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n median: number | null\n /** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n max: number | null\n}\n\n/** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */\nexport const EMPTY_PRICE_STATS: PriceStats = {\n count: 0,\n nullRate: 0,\n min: null,\n median: null,\n max: null,\n}\n\n/**\n * Bounded sample cap for the min/median/max computation — the same\n * \"bounded, uniform sample\" philosophy `ErrorTracker` uses top-N patterns\n * for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers\n * already in flight for a run this size, and far more than needed for a\n * stable median estimate. A feed with more ES-accepted rows than this cap\n * still gets an EXACT `count` / `nullRate` (those are running counters, not\n * sample-bounded) — only min/median/max become an approximation over a\n * UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not \"the\n * first N encountered\" (see `record`'s reservoir-sampling note for why\n * order-independence matters specifically for this module's job).\n */\nexport const PRICE_SAMPLE_CAP = 50_000\n\n/**\n * Accumulates price samples during a run's streaming pass. `record` is O(1)\n * (amortized array push below the cap, one RNG draw above it); `snapshot`\n * sorts the retained samples once at the end — cheap relative to the ES\n * bulk-write the run already performs every batch.\n */\nexport class PriceStatsCollector {\n private count = 0\n private nullCount = 0\n private nonNullSeen = 0\n private readonly samples: number[] = []\n\n /**\n * Record one ES-accepted row's price sample. Call exactly once per\n * accepted row (mirrors the `rowsWarned` / `pairs` accounting in\n * `runner.ts`'s flush loop) — `undefined` means \"this row had no price to\n * sample\" and counts toward `nullRate` rather than being skipped.\n *\n * Reservoir sampling (Algorithm R) past the cap, not \"first N wins\": each\n * new non-null value replaces a uniformly-random existing slot with\n * probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null\n * sample seen so far an equal chance of surviving into the final\n * reservoir. Order-independence matters here specifically — the anomaly\n * this module exists to catch (a supplier's decimal-format flip) could\n * start partway through a feed larger than the cap (e.g. a corrupted\n * re-export appended to an otherwise-normal catalogue), and a first-N\n * sample would never see a single row past the cutover point, silently\n * missing exactly the drift F010 was built to detect.\n */\n record(value: number | undefined): void {\n this.count += 1\n if (value === undefined) {\n this.nullCount += 1\n return\n }\n this.nonNullSeen += 1\n if (this.samples.length < PRICE_SAMPLE_CAP) {\n this.samples.push(value)\n return\n }\n const j = Math.floor(Math.random() * this.nonNullSeen)\n if (j < PRICE_SAMPLE_CAP) this.samples[j] = value\n }\n\n snapshot(): PriceStats {\n if (this.count === 0) return { ...EMPTY_PRICE_STATS }\n const nullRate = this.nullCount / this.count\n if (this.samples.length === 0) {\n return { count: this.count, nullRate, min: null, median: null, max: null }\n }\n const sorted = [...this.samples].sort((a, b) => a - b)\n const min = sorted[0] ?? null\n const max = sorted[sorted.length - 1] ?? null\n const mid = Math.floor(sorted.length / 2)\n let median: number | null\n if (sorted.length % 2 === 0) {\n const lo = sorted[mid - 1]\n const hi = sorted[mid]\n median = lo !== undefined && hi !== undefined ? (lo + hi) / 2 : null\n } else {\n median = sorted[mid] ?? null\n }\n return { count: this.count, nullRate, min, median, max }\n }\n}\n\n/**\n * Anomaly threshold: a run whose median purchase price is >= 10x higher OR\n * <= 1/10 (0.1x) of the prior successful run's median (for the same\n * transform + supplier + brand) is flagged.\n *\n * Chosen generously above the range a real repricing plausibly covers\n * (even an aggressive markup/discount renegotiation is a low-single-digit\n * multiplier — 2x-3x would already be an extreme outlier for a legitimate\n * price change) but comfortably BELOW the ~100x-1000x magnitude a\n * decimal/thousands-separator flip produces for typical EUR part prices:\n * `\"1.234,56\"` (a European-format €1234.56) misparsed with dot-decimal\n * conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the\n * true value was e.g. €1.23; a thousands-separator flip on `\"1234,56\"`\n * (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is\n * a ~100x inflation. 10x leaves a wide safety margin against false\n * positives on genuine price changes while still comfortably catching\n * every realistic decimal/thousands-separator flip.\n */\nexport const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10\n\nexport interface PriceAnomaly {\n /** `curr.median / prev.median`. */\n ratio: number\n /** The prior run's median (minor units). */\n prevMedian: number\n /** This run's median (minor units). */\n currMedian: number\n}\n\n/**\n * Compare two runs' price stats for a magnitude-scale anomaly. Returns\n * `null` when there's nothing to compare (either side has no median — zero\n * non-null price samples, e.g. a schema/transform that never emits a price\n * sample, or a run with zero ES-accepted rows) or the ratio falls within\n * the safe band.\n */\nexport function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null {\n if (prev.median === null || curr.median === null) return null\n // A zero/negative median can't produce a meaningful ratio. Shouldn't occur\n // for real minor-unit prices (`toMinorUnits` on a positive price); this is\n // defensive-only so a pathological value can't produce Infinity/NaN below.\n if (prev.median <= 0 || curr.median <= 0) return null\n const ratio = curr.median / prev.median\n if (\n ratio >= PRICE_ANOMALY_THRESHOLD_MULTIPLIER ||\n ratio <= 1 / PRICE_ANOMALY_THRESHOLD_MULTIPLIER\n ) {\n return { ratio, prevMedian: prev.median, currMedian: curr.median }\n }\n return null\n}\n\n/**\n * Narrow an `unknown` value (a `field.json()` column read back through\n * `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,\n * or `null` when it doesn't structurally match — e.g. a run written before\n * this field existed, or a run whose transform never emitted a price\n * sample so `min`/`median`/`max` serialised as `null`. Defensive, matching\n * the `params` narrowing pattern already used in `worker.ts`.\n */\nexport function parsePriceStats(value: unknown): PriceStats | null {\n if (typeof value !== 'object' || value === null) return null\n const v = value as Record<string, unknown>\n if (typeof v.count !== 'number' || typeof v.nullRate !== 'number') return null\n return {\n count: v.count,\n nullRate: v.nullRate,\n min: typeof v.min === 'number' ? v.min : null,\n median: typeof v.median === 'number' ? v.median : null,\n max: typeof v.max === 'number' ? v.max : null,\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport {\n type BulkIndexResult,\n bulkPartialUpdate,\n bulkUpsert,\n type EsClientLike,\n} from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { type PriceStats, PriceStatsCollector } from './price-stats.js'\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\nimport type {\n DirectionalPair,\n DocPatch,\n RowTransform,\n RowWarning,\n TransformContext,\n} from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */\n feed: ReadFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional separate tracker for NON-FATAL row warnings (commerce-demo PR4 —\n * `no_purchase_price`). Distinct from {@link errorTracker} so warnings never\n * inflate `totals.failed` / the error summary: a warned row is a SUCCESS whose\n * doc is indexed. Default: a fresh tracker with the same caps.\n */\n warningTracker?: ErrorTracker\n /**\n * Optional price-stats collector (F010 / #602) — accumulates a\n * min/median/max/nullRate distribution over ES-accepted rows' optional\n * `priceSample`s in the SAME streaming pass that builds `totals`. Default:\n * a fresh collector. Tests can inject their own to inspect it directly —\n * mirrors the {@link errorTracker} / {@link warningTracker} DI shape.\n */\n priceStats?: PriceStatsCollector\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n /** Rows indexed WITH a non-fatal warning (commerce-demo PR4 — `no_purchase_price`). */\n rowsWarned: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /**\n * Rows that succeeded (doc indexed) but carried ≥1 non-fatal warning —\n * counted SEPARATELY from `rowsSucceeded`/`rowsFailed` (a warned row is also\n * counted in `rowsSucceeded`). commerce-demo PR4: `no_purchase_price`.\n */\n rowsWarned: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n /** Aggregated NON-FATAL warnings (commerce-demo PR4). Empty when no row warned. */\n warnings: ReturnType<ErrorTracker['snapshot']>\n /**\n * Directional supersession pairs harvested as a side-output (commerce-demo PR6,\n * SD007). Buffered in memory from ES-ACCEPTED rows only (same accounting as\n * warnings — a row whose doc was rejected by ES contributes no pair). Empty unless\n * a schema emits them (the `parts` schema, when a row's `superseding_code` is set).\n * The worker hands these to an injected `pairSink` AFTER the run for the commerce\n * replacement-merge engine; the runner itself never writes the consumer's tables.\n */\n pairs: readonly DirectionalPair[]\n /**\n * Doc-patches applied by the phase-2 pass (commerce-demo PR7): a `<code>*S`\n * core-fee row is not a saleable doc — it emits a {@link DocPatch} that merges its\n * deposit onto the PARENT part's `core_charge_minor`. The runner buffers these\n * during the stream (sparse) and applies them via `bulkPartialUpdate` AFTER the ES\n * pass. `patchesApplied` = targets the cluster merged; `patchesOrphaned` = patches\n * whose parent genuinely wasn't in the index (a `document_missing`/404 → an\n * `orphan_core_charge` warning). An INFRA failure (whole-batch transport error, or a\n * non-404 per-doc failure) is NOT counted as orphaned — it lands in a distinct\n * `patch_attach_failed` warning bucket so it can't masquerade as a data orphan. Zero\n * unless a schema emits patches.\n */\n patchesApplied: number\n patchesOrphaned: number\n /**\n * Price-distribution snapshot (F010 / #602) accumulated from ES-accepted\n * rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.\n * `count` is `0` (all fields `null`/`0`) for a run whose schema never emits\n * a price sample.\n */\n priceStats: PriceStats\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n warningTracker = new ErrorTracker(),\n priceStats: priceStatsCollector = new PriceStatsCollector(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info({ importRunId, runLabel, batchSize, esIndex }, 'imports.runner: started')\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let rowsWarned = 0\n let batchesCompleted = 0\n\n // Side-output buffer (commerce-demo PR6, SD007): directional supersession pairs\n // accumulated from ES-ACCEPTED rows across the whole run. An array append per\n // sparse superseded row — it never blocks the `bulkUpsert` loop. Handed to the\n // injected `pairSink` by the worker AFTER the run (read-once / merge-after).\n const collectedPairs: DirectionalPair[] = []\n\n // Side-output buffer (commerce-demo PR7): doc-patches from `*S` core-fee rows —\n // each attaches a core charge onto a DIFFERENT (parent) doc. Collected off the\n // `skip` result (a `*S` row is never a saleable doc), sparse, applied in a phase-2\n // `bulkPartialUpdate` after the ES pass (below) — order-independent because ES\n // `update` is realtime by `_id`.\n const collectedPatches: DocPatch<TDoc>[] = []\n\n // Warnings + pairs ride on the pending item (with the raw row for the warning\n // sample) so they're accounted at FLUSH time — only for rows ES actually accepted.\n // A warned row is by contract a SUCCESSFUL row, so counting it at push time (before\n // the bulk response) would wrongly keep a later-ES-rejected row in rowsWarned /\n // warnings; pairs follow the same accepted-rows-only rule.\n let pending: Array<{\n id: string\n doc: TDoc\n rowNumber: number\n rowData: Record<string, string>\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n // Account non-fatal warnings ONLY for rows ES accepted (commerce-demo PR4) — a\n // warned row is by contract a SUCCESSFUL row, so a row that warned AND then failed\n // the bulk write must NOT land in rowsWarned/warnings (it's already in rowsFailed).\n const failedIds = new Set(result.failures.map((f) => f.id))\n for (const item of batch) {\n if (!item.warnings || item.warnings.length === 0) continue\n if (failedIds.has(item.id)) continue\n rowsWarned += 1\n for (const w of item.warnings) {\n warningTracker.addError(item.rowNumber, w.warningType, w.message, w.field, item.rowData)\n }\n }\n\n // Buffer directional pairs for rows ES accepted (commerce-demo PR6, SD007) — same\n // accepted-rows-only rule as warnings, so a pair from a doc ES rejected is dropped.\n for (const item of batch) {\n if (!item.pairs || item.pairs.length === 0) continue\n if (failedIds.has(item.id)) continue\n collectedPairs.push(...item.pairs)\n }\n\n // Price-distribution sampling (F010 / #602) — same accepted-rows-only rule as\n // warnings/pairs: a row ES rejected contributes no sample. Every ES-accepted row\n // records exactly once (`undefined` counts toward `nullRate` rather than being\n // skipped), so `priceStats.count` tracks `rowsSucceeded` exactly.\n for (const item of batch) {\n if (failedIds.has(item.id)) continue\n priceStatsCollector.record(item.priceSample)\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of readFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info(\n { importRunId, rowNumber, sampleKeys: Object.keys(row) },\n 'imports.runner: STREAM_FIRST_ROW',\n )\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n // A `*S` core-fee row skips the saleable index but may carry a doc-patch\n // (commerce-demo PR7) — buffer it for the phase-2 attach. Unlike pairs, a\n // patch has no ES write of its own to gate on, so it's collected here\n // unconditionally; its success is decided by the phase-2 update.\n if (result.patches && result.patches.length > 0) {\n collectedPatches.push(...result.patches)\n }\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n // Non-fatal warnings ride on a SUCCESS — the doc is still indexed\n // (commerce-demo PR4: `no_purchase_price`). Carry them (+ the raw row for the\n // sample) on the pending item; the flush aggregates them into the separate\n // `warningTracker` AFTER the bulk response confirms the row was accepted, so a\n // warned-then-ES-rejected row never inflates rowsWarned / the warning summary.\n pending.push({\n id: result.id,\n doc: result.doc,\n rowNumber,\n rowData: row,\n ...(result.warnings && result.warnings.length > 0 && { warnings: result.warnings }),\n ...(result.pairs && result.pairs.length > 0 && { pairs: result.pairs }),\n ...(result.priceSample !== undefined && { priceSample: result.priceSample }),\n })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n // Phase 2 (commerce-demo PR7): attach the buffered `*S` core-charge patches onto\n // their PARENT docs. Runs AFTER the whole stream so every parent that exists in\n // this run is already indexed; ES `update` is realtime by `_id`, so the merge\n // lands regardless of whether the parent streamed before or after its `*S` row\n // (order-independent). Patches are sparse (only reman parts carry `*S`), but batch\n // anyway for a giant catalogue. A patch whose parent isn't in the index comes back\n // as a per-doc failure (`document_missing_exception`) → an `orphan_core_charge`\n // warning, never a phantom doc, never a silent drop (D004). A cluster failure of\n // the whole batch is logged as a warning too — the saleable docs already indexed;\n // only the deferred core-charge attach didn't happen — so we never fail the run\n // over it.\n let patchesApplied = 0\n let patchesOrphaned = 0\n for (let i = 0; i < collectedPatches.length; i += batchSize) {\n // Operator-driven cancel BETWEEN batches: THROW rather than break, so a run\n // cancelled after phase 1 is not marked `succeeded` with core charges left\n // unattached. Mirrors phase-1's flush, which also re-throws on abort. The queue\n // handler catches this and marks the run failed.\n if (signal?.aborted) {\n throw new Error('imports: run aborted during core-charge attach (phase 2)')\n }\n const slice = collectedPatches.slice(i, i + batchSize)\n let updateResult: BulkIndexResult\n try {\n updateResult = await bulkPartialUpdate<TDoc>(esClient, {\n index: esIndex,\n updates: slice.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // An abort surfacing as an AbortError from the ES client is a cancel, NOT an\n // attach failure — re-throw so the run fails (same as phase 1), never a silent\n // \"succeeded with patches unapplied\".\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n if (isAbort) throw err\n // Whole-batch transport/cluster failure (ES down, timeout). The parents may\n // well exist — the attach simply didn't happen — so this is an INFRA failure,\n // NOT a data orphan: it gets the distinct `patch_attach_failed` bucket and is\n // NOT counted in patchesOrphaned. Best-effort: the saleable docs already\n // indexed, and a re-import re-attaches, so we warn + continue rather than fail.\n const reason = err instanceof Error ? err.message : String(err)\n for (const patch of slice) {\n warningTracker.addError(-1, 'patch_attach_failed', reason, patch.orphanWarning?.field, {\n target: patch.id,\n })\n }\n continue\n }\n patchesApplied += updateResult.succeeded\n if (updateResult.failures.length > 0) {\n const byId = new Map(slice.map((p) => [p.id, p]))\n for (const fail of updateResult.failures) {\n const w = byId.get(fail.id)?.orphanWarning\n // A missing target (`document_missing_exception` / 404) is a genuine data\n // orphan — the referenced parent isn't in this import (D004: warn, never a\n // phantom doc). A stable message (from the patch's orphanWarning) aggregates\n // orphans; the missing parent id rides in the sample. Any OTHER per-doc\n // failure (mapper error, etc.) is an infra/attach failure kept in a distinct\n // bucket so it can't masquerade as a data orphan.\n const isOrphan = fail.status === 404 || fail.type === 'document_missing_exception'\n if (isOrphan) {\n patchesOrphaned += 1\n warningTracker.addError(\n -1,\n w?.warningType ?? 'orphan_patch',\n w?.message ?? fail.reason,\n w?.field,\n {\n target: fail.id,\n },\n )\n } else {\n warningTracker.addError(-1, 'patch_attach_failed', fail.reason, w?.field, {\n target: fail.id,\n })\n }\n }\n }\n }\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n patchesApplied,\n patchesOrphaned,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n warnings: warningTracker.snapshot(),\n pairs: collectedPairs,\n patchesApplied,\n patchesOrphaned,\n priceStats: priceStatsCollector.snapshot(),\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{a as e,i as t,r as n}from"./parts-schema-
|
|
2
|
-
//# sourceMappingURL=schema-registry-
|
|
1
|
+
import{a as e,i as t,r as n}from"./parts-schema-QqLci_Pl.mjs";const r={name:`discount_table`,fields:[{key:`discount_group`,label:`Discount group`,required:!0,type:`string`},{key:`discount_pct`,label:`Discount %`,required:!0,type:`number`}],buildRow(n,r){let i=typeof n.discount_group==`string`?n.discount_group.trim():``;if(i===``)return e(`missing_discount_group`,`discount_group is empty`,`discount_group`);let a=t(n.discount_pct);return a.kind===`invalid`?e(`invalid_discount_pct`,`discount_pct is not a number`,`discount_pct`):a.kind===`absent`?e(`missing_discount_pct`,`discount_pct is empty`,`discount_pct`):a.value<0||a.value>100?e(`discount_pct_out_of_range`,`discount_pct ${a.value} is outside 0–100`,`discount_pct`):{kind:`success`,id:i,doc:{discountGroup:i,discountPct:a.value}}}},i={[n.name]:n,[r.name]:r};function a(e){return i[e]}function o(){return Object.keys(i)}function s(){return Object.values(i).map(e=>({name:e.name,fields:e.fields}))}export{r as i,a as n,o as r,s as t};
|
|
2
|
+
//# sourceMappingURL=schema-registry-DB5aIZMN.mjs.map
|