@murumets-ee/imports 0.49.0 → 0.52.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.
Files changed (70) hide show
  1. package/dist/analyze/index.d.mts +265 -0
  2. package/dist/analyze/index.d.mts.map +1 -0
  3. package/dist/analyze/index.mjs +2 -0
  4. package/dist/analyze/index.mjs.map +1 -0
  5. package/dist/carmaker-feed-B-QYr3PI.mjs +2 -0
  6. package/dist/{carmaker-feed-CxhZBrkO.mjs.map → carmaker-feed-B-QYr3PI.mjs.map} +1 -1
  7. package/dist/carmaker-feed-_QVQFLUl.d.mts +59 -0
  8. package/dist/carmaker-feed-_QVQFLUl.d.mts.map +1 -0
  9. package/dist/{cleanup-CHjDgvKT.mjs → cleanup-DDljBe0Z.mjs} +2 -2
  10. package/dist/{cleanup-CHjDgvKT.mjs.map → cleanup-DDljBe0Z.mjs.map} +1 -1
  11. package/dist/engine-B0ExE4jL.mjs +2 -0
  12. package/dist/engine-B0ExE4jL.mjs.map +1 -0
  13. package/dist/{parts-schema-x8_GRYWc.d.mts → engine-DiM4r2Vj.d.mts} +349 -49
  14. package/dist/engine-DiM4r2Vj.d.mts.map +1 -0
  15. package/dist/index-Bm69N5DW.d.mts +97 -0
  16. package/dist/index-Bm69N5DW.d.mts.map +1 -0
  17. package/dist/index.d.mts +30 -6
  18. package/dist/index.d.mts.map +1 -1
  19. package/dist/index.mjs +1 -1
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/mapping/index.d.mts +3 -359
  22. package/dist/mapping/index.d.mts.map +1 -1
  23. package/dist/mapping/index.mjs +1 -1
  24. package/dist/mapping-transform-CO4MNhMe.mjs +2 -0
  25. package/dist/{mapping-transform-D76b-P-p.mjs.map → mapping-transform-CO4MNhMe.mjs.map} +1 -1
  26. package/dist/matching/index.d.mts +2 -0
  27. package/dist/matching/index.mjs +1 -0
  28. package/dist/matching-Sr0z4HvN.mjs +2 -0
  29. package/dist/matching-Sr0z4HvN.mjs.map +1 -0
  30. package/dist/parts-schema-DOWhBJYh.d.mts +61 -0
  31. package/dist/parts-schema-DOWhBJYh.d.mts.map +1 -0
  32. package/dist/parts-schema-QqLci_Pl.mjs +2 -0
  33. package/dist/parts-schema-QqLci_Pl.mjs.map +1 -0
  34. package/dist/plugin.d.mts +1 -1
  35. package/dist/plugin.mjs +1 -1
  36. package/dist/plugin.mjs.map +1 -1
  37. package/dist/read-feed-DGjqejw3.mjs +2 -0
  38. package/dist/read-feed-DGjqejw3.mjs.map +1 -0
  39. package/dist/runner-C9GIe5Dr.mjs +2 -0
  40. package/dist/runner-C9GIe5Dr.mjs.map +1 -0
  41. package/dist/{schema-registry-C_lQje-K.mjs → schema-registry-DB5aIZMN.mjs} +2 -2
  42. package/dist/{schema-registry-C_lQje-K.mjs.map → schema-registry-DB5aIZMN.mjs.map} +1 -1
  43. package/dist/storage-resolver.d.mts +1 -1
  44. package/dist/transform-Cu5YEoOl.mjs +2 -0
  45. package/dist/transform-Cu5YEoOl.mjs.map +1 -0
  46. package/dist/{transform-CABBaYmF.d.mts → transform-WeiiLdYP.d.mts} +12 -1
  47. package/dist/transform-WeiiLdYP.d.mts.map +1 -0
  48. package/dist/transforms.d.mts +2 -46
  49. package/dist/transforms.mjs +1 -1
  50. package/dist/{worker-DRTgbtT7.d.mts → worker-_MlRH5hd.d.mts} +209 -8
  51. package/dist/worker-_MlRH5hd.d.mts.map +1 -0
  52. package/dist/worker.d.mts +1 -1
  53. package/dist/worker.mjs +1 -1
  54. package/dist/worker.mjs.map +1 -1
  55. package/dist/xlsx-reader-_ioPAqm5.mjs +2 -0
  56. package/dist/{xlsx-reader-Ccesszbc.mjs.map → xlsx-reader-_ioPAqm5.mjs.map} +1 -1
  57. package/package.json +16 -8
  58. package/dist/carmaker-feed-CxhZBrkO.mjs +0 -2
  59. package/dist/mapping-transform-D76b-P-p.mjs +0 -2
  60. package/dist/parts-schema-D0gMhNWj.mjs +0 -2
  61. package/dist/parts-schema-D0gMhNWj.mjs.map +0 -1
  62. package/dist/parts-schema-x8_GRYWc.d.mts.map +0 -1
  63. package/dist/runner-C-s6SD7t.mjs +0 -2
  64. package/dist/runner-C-s6SD7t.mjs.map +0 -1
  65. package/dist/transform-CABBaYmF.d.mts.map +0 -1
  66. package/dist/transform-DIzWppgJ.mjs +0 -2
  67. package/dist/transform-DIzWppgJ.mjs.map +0 -1
  68. package/dist/transforms.d.mts.map +0 -1
  69. package/dist/worker-DRTgbtT7.d.mts.map +0 -1
  70. package/dist/xlsx-reader-Ccesszbc.mjs +0 -2
@@ -0,0 +1,265 @@
1
+ import { t as MappingColumn, w as NumberFormat } from "../engine-DiM4r2Vj.mjs";
2
+ import { t as HeaderSample } from "../index-Bm69N5DW.mjs";
3
+
4
+ //#region src/analyze/dialect.d.ts
5
+ /**
6
+ * Decide how a delimited feed is actually laid out — delimiter, header row, and quoting —
7
+ * from the file itself rather than from its extension or from a project default.
8
+ *
9
+ * ## Why all three are per-file
10
+ *
11
+ * The three ground-truth supplier feeds (R027) disagree on two of these axes at once, and each
12
+ * disagreement is load-bearing:
13
+ *
14
+ * - Two are TAB-delimited `.txt`, one is comma-delimited `.csv`. An extension gets this right
15
+ * only because these suppliers happen to be tidy; `.txt` says nothing.
16
+ * - The CSV is genuine RFC 4180 — 1,476 of its rows carry a comma inside a quoted description
17
+ * (`1023031,"STEUN, DEMPER",809.41,0,1201134`) — while the two TAB feeds use `"` as ordinary
18
+ * text and carry 2,321 lines with an ODD number of them (`"O"-RING`, `RACCORDO A "T`).
19
+ * Each file HARD-FAILS under the other's quoting: the CSV dies at row 11,126 of 808,147 with
20
+ * `CSV_RECORD_INCONSISTENT_COLUMNS`, the TSV at row 1,476 of 865,612 with
21
+ * `INVALID_OPENING_QUOTE`.
22
+ *
23
+ * ## How the quoting decision is made: by trying it, not by pattern-matching
24
+ *
25
+ * Quoting is settled by TRIAL PARSE over the whole file (or `maxRows` of it) using the same
26
+ * reader the run uses, once per candidate. A heuristic over raw bytes — "count the quotes, look
27
+ * for odd lines" — is cheaper and wrong: RFC 4180 permits a newline inside a quoted field, so
28
+ * an odd count per physical line is evidence of nothing in a file that legitimately has them.
29
+ * Running the real parser and seeing whether it completes is both simpler to justify and
30
+ * exactly the question that matters.
31
+ *
32
+ * The common case costs one pass: a file with no `"` in it at all is not ambiguous, and the
33
+ * first trial already establishes that while it counts rows.
34
+ */
35
+ /** Delimiters worth trying. Ordered by how often they appear in supplier feeds. */
36
+ declare const CANDIDATE_DELIMITERS: readonly ["\t", ",", ";", "|"];
37
+ type CandidateDelimiter = (typeof CANDIDATE_DELIMITERS)[number];
38
+ /** How many bytes of the head of the file the cheap sample-based passes read. */
39
+ declare const DIALECT_SAMPLE_BYTES: number;
40
+ /** How many records the sample-based passes consider. */
41
+ declare const DIALECT_SAMPLE_ROWS = 200;
42
+ interface DelimiterInference {
43
+ delimiter: CandidateDelimiter;
44
+ /** Field count the winning delimiter produces on the sample. */
45
+ fieldCount: number;
46
+ /** Fraction of sampled rows that produced exactly `fieldCount` fields. */
47
+ consistency: number;
48
+ evidence: string;
49
+ }
50
+ /** Printable name for a delimiter, so error text does not contain a raw control character. */
51
+ declare function delimiterName(d: string): string;
52
+ /**
53
+ * Pick the delimiter that splits the sample into the most fields MOST CONSISTENTLY.
54
+ *
55
+ * Consistency is what does the work. A tab-delimited feed whose descriptions contain commas
56
+ * still splits into a stable 15 columns on TAB, while the same rows split into 1, 2 or 3 on
57
+ * comma depending on the description — so comma scores near zero even though it "matches"
58
+ * plenty of lines. Field count breaks ties in favour of the delimiter that actually structures
59
+ * the file rather than one that happens to appear in every row.
60
+ *
61
+ * Parsed with quoting OFF, deliberately: at this stage the quoting policy is not known, and
62
+ * quoting cannot change which character separates fields — only how many fields a handful of
63
+ * rows produce. Strictness is off too, since an inconsistent count is the measurement here
64
+ * rather than an error.
65
+ */
66
+ declare function inferDelimiter(sample: string): DelimiterInference | null;
67
+ interface HeaderInference {
68
+ hasHeader: boolean;
69
+ /** The first record's cells, trimmed — the header candidate, whatever the verdict. */
70
+ firstRowCells: string[];
71
+ evidence: string;
72
+ }
73
+ /**
74
+ * Decide whether the first record is a header.
75
+ *
76
+ * Two independent signals, either sufficient:
77
+ *
78
+ * 1. **The matcher recognises it.** If `matchesTargets` finds at least one target field for
79
+ * the first row's cells, they are column NAMES — a data row of part codes and prices does
80
+ * not match `article name` or `currency cd`. This is the strong signal and it is the one
81
+ * that fires on all three real feeds.
82
+ * 2. **A type change down the column.** The first row is all non-numeric and distinct, while
83
+ * at least one column below it is predominantly numeric. That is the shape of a header over
84
+ * a price column, and it catches feeds whose headers we have no alias for.
85
+ *
86
+ * Both are stated in the evidence string so an operator can see which one carried the verdict.
87
+ */
88
+ declare function inferHasHeader(records: readonly string[][], matchesTargets: (cells: readonly string[]) => number): HeaderInference;
89
+ /** Parse the first `DIALECT_SAMPLE_ROWS` records of a sample positionally, for header detection. */
90
+ declare function sampleRecords(sample: string, delimiter: string): string[][];
91
+ /** Outcome of parsing the whole feed under one candidate quoting policy. */
92
+ interface TrialParse {
93
+ quote: string | false;
94
+ ok: boolean;
95
+ /** Parser message when `ok` is false. */
96
+ error: string | null;
97
+ /** Data rows successfully read before completion or failure. */
98
+ rows: number;
99
+ /**
100
+ * True when any cell contained a `"`. Only meaningful on the `quote: false` trial, where the
101
+ * reader preserves quote characters verbatim; under RFC 4180 the parser consumes the
102
+ * structural ones, so their absence there proves nothing. This is the flag that decides
103
+ * whether a second trial is needed at all.
104
+ */
105
+ sawQuoteChar: boolean;
106
+ /**
107
+ * Sampled cell values per column, capped — the input to number-format inference.
108
+ *
109
+ * COMPACTED: empty cells are skipped, so these arrays are NOT row-aligned with each other and
110
+ * must never be zipped by index into "rows". Use {@link TrialParse.sampleRows} for anything
111
+ * that has to show a row. (Zipping them is exactly how the header snapshot came to attribute
112
+ * one part's barcode to the part above it.)
113
+ */
114
+ samples: Map<string, string[]>;
115
+ /**
116
+ * The first few WHOLE data rows, verbatim — for the header snapshot the wizard renders as its
117
+ * sample table. Kept separately from `samples` precisely because that one is compacted: this
118
+ * is the operator's verification surface, and a fabricated row on it is worse than no sample.
119
+ */
120
+ sampleRows: Array<Record<string, string>>;
121
+ /** Column names as the reader derived them. */
122
+ columns: string[];
123
+ }
124
+ interface TrialParseOptions {
125
+ filePath: string;
126
+ format: 'csv' | 'tsv';
127
+ delimiter: string;
128
+ hasHeader: boolean;
129
+ quote: string | false;
130
+ /** Stop after this many data rows. `0`/absent = read the whole file. */
131
+ maxRows?: number;
132
+ /** Cap on retained sample values PER COLUMN. */
133
+ maxSamplesPerColumn?: number;
134
+ /** Cap on the TOTAL retained sample values, across all columns. */
135
+ maxSamplesTotal?: number;
136
+ /** Cap on retained whole rows. */
137
+ maxSampleRows?: number;
138
+ }
139
+ /**
140
+ * Parse the feed exactly as a run would, under one quoting policy, and report what happened.
141
+ *
142
+ * `readFeed` is used rather than a private parser so that "this analysis says the file parses"
143
+ * and "the import parses this file" are the same claim. Strictness stays at the reader's
144
+ * default (`relaxColumnCount` unset → false), which is what turns a wrong quoting policy into a
145
+ * loud error at a named line instead of a quietly shifted column.
146
+ */
147
+ declare function trialParse(options: TrialParseOptions): Promise<TrialParse>;
148
+ //#endregion
149
+ //#region src/analyze/number-format.d.ts
150
+ /** How confident the inference is, and therefore whether a caller may act on it unattended. */
151
+ type NumberFormatConfidence = /** Direct evidence, no contradiction. */'high' /** No separated values at all — every sample was a bare integer, so any format parses them. */ | 'no-evidence' /** Every separated value has exactly 3 trailing digits: `1,234` is 1234 or 1.234 and nothing distinguishes them. */ | 'ambiguous' /** The file contains values that argue for BOTH decimal separators. Mixed conventions in one feed. */ | 'conflict';
152
+ interface NumberFormatInference {
153
+ /**
154
+ * The inferred format. Present for every confidence EXCEPT `ambiguous` / `conflict`, where
155
+ * there is deliberately no answer — a caller must obtain one from the operator.
156
+ */
157
+ format: NumberFormat | null;
158
+ confidence: NumberFormatConfidence;
159
+ /** Human-readable statement of what was measured, for the operator to audit the decision. */
160
+ evidence: string;
161
+ /**
162
+ * Sample values that the inferred format does NOT parse. Empty on a clean inference. These
163
+ * are not necessarily a defect — a feed with junk rows is normal and `ErrorTracker` exists
164
+ * for it — but a long list means the inference is probably wrong.
165
+ */
166
+ unparsed: string[];
167
+ /** How many sample values were numeric-shaped, and how many were considered at all. */
168
+ counts: {
169
+ considered: number;
170
+ numeric: number;
171
+ separated: number;
172
+ };
173
+ }
174
+ /**
175
+ * Infer the number format from sample cell values.
176
+ *
177
+ * Pass every non-empty cell from the columns that map to `type: 'number'` targets. Cells that
178
+ * are not numeric-shaped (`NA`, blank, a description) are skipped rather than held against the
179
+ * inference — `GrossPrice` is the literal string `NA` on all 865,612 rows of one real feed, and
180
+ * a rule that treated that as malformed would learn nothing from that supplier at all.
181
+ */
182
+ declare function inferNumberFormat(samples: readonly string[]): NumberFormatInference;
183
+ //#endregion
184
+ //#region src/analyze/analyze-feed.d.ts
185
+ /**
186
+ * Document version. `imports:mapping:create` refuses a document it does not understand rather
187
+ * than reading fields positionally out of a shape that has moved.
188
+ */
189
+ declare const FEED_ANALYSIS_VERSION = 1;
190
+ interface UnmappedColumn {
191
+ source: string;
192
+ /** Why it was not assigned, in words an operator can act on. */
193
+ reason: string;
194
+ /**
195
+ * Targets the operator should choose BETWEEN, when the column is recognisably one of a known
196
+ * ambiguous pair. Empty when nothing plausible matched at all.
197
+ */
198
+ candidates: string[];
199
+ }
200
+ interface FeedAnalysisReader {
201
+ format: 'csv' | 'tsv';
202
+ /** Always explicit, so the document states what was measured rather than implying a default. */
203
+ delimiter: string;
204
+ hasHeader: boolean;
205
+ /** `false` = quotes are ordinary data; `'"'` = RFC 4180. */
206
+ quote: string | false;
207
+ }
208
+ interface FeedAnalysis {
209
+ version: typeof FEED_ANALYSIS_VERSION;
210
+ file: {
211
+ path: string;
212
+ bytes: number;
213
+ };
214
+ targetSchema: string;
215
+ reader: FeedAnalysisReader;
216
+ numberFormat: NumberFormat | null;
217
+ numberFormatConfidence: NumberFormatConfidence;
218
+ /** Proposed source→target assignments, with the wizard's own default transform chains. */
219
+ columnMap: MappingColumn[];
220
+ /** Every source column with no assignment, each with a reason. Never silently dropped. */
221
+ unmapped: UnmappedColumn[];
222
+ /** Sample header + rows, shaped and capped exactly as the wizard's save does (#667). */
223
+ headerSnapshot: HeaderSample | null;
224
+ /** Data rows the trial parse read. Exact when the whole file was scanned. */
225
+ dataRows: number;
226
+ /** True when `maxRows` cut the scan short, so `dataRows` is a floor rather than a count. */
227
+ truncated: boolean;
228
+ /** What was measured, per decision, for the operator to audit rather than trust. */
229
+ evidence: {
230
+ delimiter: string;
231
+ hasHeader: string;
232
+ quote: string;
233
+ numberFormat: string;
234
+ };
235
+ /** Non-fatal things the operator should read before saving. */
236
+ warnings: string[];
237
+ }
238
+ declare class FeedAnalysisError extends Error {
239
+ readonly hint: string | undefined;
240
+ constructor(message: string, hint?: string);
241
+ }
242
+ interface AnalyzeFeedOptions {
243
+ filePath: string;
244
+ /** Registered target schema name. Defaults to `parts`. */
245
+ targetSchema?: string;
246
+ /** Stop scanning after this many data rows. `0`/absent = the whole file. */
247
+ maxRows?: number;
248
+ /** Injected for tests; defaults to reading the head of `filePath`. */
249
+ readSample?: (filePath: string, bytes: number) => Promise<string>;
250
+ /** Injected for tests; defaults to `fs.stat`. */
251
+ fileSize?: (filePath: string) => Promise<number>;
252
+ }
253
+ /**
254
+ * Analyse a delimited supplier feed. Reads the file; writes nothing.
255
+ *
256
+ * xlsx is deliberately out of scope: none of its four decisions apply (a sheet has no delimiter,
257
+ * no quoting and no text number format — exceljs hands back typed cells), so an "analysis" of one
258
+ * would be four constants and a column list. Map those in the wizard.
259
+ */
260
+ declare function analyzeFeed(options: AnalyzeFeedOptions): Promise<FeedAnalysis>;
261
+ /** Every source column the analysis did not assign — the set a caller must resolve explicitly. */
262
+ declare function unmappedSources(analysis: FeedAnalysis): string[];
263
+ //#endregion
264
+ export { type AnalyzeFeedOptions, CANDIDATE_DELIMITERS, type CandidateDelimiter, DIALECT_SAMPLE_BYTES, DIALECT_SAMPLE_ROWS, type DelimiterInference, FEED_ANALYSIS_VERSION, type FeedAnalysis, FeedAnalysisError, type FeedAnalysisReader, type HeaderInference, type NumberFormatConfidence, type NumberFormatInference, type TrialParse, type TrialParseOptions, type UnmappedColumn, analyzeFeed, delimiterName, inferDelimiter, inferHasHeader, inferNumberFormat, sampleRecords, trialParse, unmappedSources };
265
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/analyze/dialect.ts","../../src/analyze/number-format.ts","../../src/analyze/analyze-feed.ts"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{n as e}from"../read-feed-DGjqejw3.mjs";import{c as t}from"../parts-schema-QqLci_Pl.mjs";import{n,r}from"../schema-registry-DB5aIZMN.mjs";import{o as i,s as a}from"../matching-Sr0z4HvN.mjs";import{parse as o}from"csv-parse/sync";const s=[` `,`,`,`;`,`|`],c=512*1024,l=200;function u(e){return e===` `?`TAB`:e===` `?`SPACE`:`"${e}"`}function d(e){let t=null;for(let n of s){let r;try{r=o(e,{delimiter:n,columns:!1,bom:!0,skip_empty_lines:!0,relax_column_count:!0,quote:!1,to_line:200})}catch{continue}if(r.length===0)continue;let i=new Map;for(let e of r)i.set(e.length,(i.get(e.length)??0)+1);let a=0,s=0;for(let[e,t]of i)(t>s||t===s&&e>a)&&(a=e,s=t);if(a<2)continue;let c=s/r.length;(t===null||c>t.consistency||c===t.consistency&&a>t.fieldCount)&&(t={delimiter:n,fieldCount:a,consistency:c,evidence:`${u(n)} yields ${a} fields on ${Math.round(c*100)}% of ${r.length} sampled row(s)`})}return t}function f(e,t){let n=(e[0]??[]).map(e=>e.trim());if(e.length===0)return{hasHeader:!1,firstRowCells:n,evidence:`the file has no records`};let r=t(n);if(r>0)return{hasHeader:!0,firstRowCells:n,evidence:`the first row names ${r} recognisable target field(s)`};let i=n.filter(e=>e!==``),a=new Set(i).size===i.length,o=/^-?\d+(?:[., ]\d+)*$/,s=i.length>0&&!n.some(e=>o.test(e)),c=e.slice(1),l=!1;if(c.length>0)for(let e=0;e<n.length;e+=1){let t=0,n=0;for(let r of c){let i=r[e]?.trim();i===void 0||i===``||(n+=1,o.test(i)&&(t+=1))}if(n>=3&&t/n>=.8){l=!0;break}}return s&&a&&l?{hasHeader:!0,firstRowCells:n,evidence:`the first row is entirely non-numeric with distinct values while a column below it is predominantly numeric`}:{hasHeader:!1,firstRowCells:n,evidence:`the first row matches no target field and does not read as a header over the rows below — treating the feed as positional`}}function p(e,t){try{return o(e,{delimiter:t,columns:!1,bom:!0,skip_empty_lines:!0,relax_column_count:!0,quote:!1,to_line:200})}catch{return[]}}async function m(t){let{filePath:n,format:r,delimiter:i,hasHeader:a,quote:o,maxRows:s}=t,c=t.maxSamplesPerColumn??2e3,l=t.maxSamplesTotal??2e4,u=t.maxSampleRows??5,d=new Map,f=[],p=[],m=0,h=0,g=!1;try{for await(let{row:t}of e({filePath:n,format:r,delimiter:i,hasHeader:a,quote:o})){m+=1,p.length===0&&(p=Object.keys(t),!g&&p.some(e=>e.includes(`"`))&&(g=!0)),f.length<u&&f.push({...t});for(let[e,n]of Object.entries(t)){if(n===``||(!g&&n.includes(`"`)&&(g=!0),h>=l))continue;let t=d.get(e);t===void 0?(d.set(e,[n]),h+=1):t.length<c&&(t.push(n),h+=1)}if(s!==void 0&&s>0&&m>=s)break}}catch(e){return{quote:o,ok:!1,error:e instanceof Error?e.message:String(e),rows:m,sawQuoteChar:g,samples:d,sampleRows:f,columns:p}}return{quote:o,ok:!0,error:null,rows:m,sawQuoteChar:g,samples:d,sampleRows:f,columns:p}}const h=[`.`,`,`,` `],g=/^-?\d+(?:[., ]\d+)*$/;function _(){return{decimal:{".":0,",":0," ":0},grouping:{".":0,",":0," ":0},ambiguous:{".":0,",":0," ":0},ambiguousSamples:[]}}function v(e){return e===`.`||e===`,`||e===` `}function y(e){let t=[];for(let n=0;n<e.length;n+=1){let r=e[n];r!==void 0&&v(r)&&t.push({char:r,index:n})}return t}function b(e,t){let n=y(e);if(n.length===0)return;if(new Set(n.map(e=>e.char)).size>=2){let e=n[n.length-1];if(e===void 0)return;t.decimal[e.char]+=1;for(let r of n)r.char!==e.char&&(t.grouping[r.char]+=1);return}let r=n[0]?.char;if(r!==void 0){if(n.length>=2){t.grouping[r]+=1;return}if(e.length-(n[0]?.index??0)-1==3){t.ambiguous[r]+=1,t.ambiguousSamples.length<5&&t.ambiguousSamples.push(e);return}t.decimal[r]+=1}}function x(e){let n=_(),r=0,i=[];for(let t of e){let e=t.trim();e!==``&&g.test(e)&&(r+=1,i.push(e),b(e,n))}n.grouping[` `]+=n.decimal[` `]+n.ambiguous[` `],n.decimal[` `]=0,n.ambiguous[` `]=0;let a=n.decimal[`.`]+n.decimal[`,`],o=n.grouping[`.`]+n.grouping[`,`]+n.grouping[` `],s=n.ambiguous[`.`]+n.ambiguous[`,`],c={considered:e.length,numeric:r,separated:a+o},l=n.decimal[`.`],u=n.decimal[`,`];if(l>0&&u>0)return{format:null,confidence:`conflict`,evidence:`the file argues for BOTH decimal separators — ${l} value(s) put a non-3-digit run after "." and ${u} after ",". One feed is using two conventions, or the delimiter is wrong and two columns have been merged into one.`,unparsed:[],counts:c};let d=null;if(l>0?d=`.`:u>0&&(d=`,`),d===null){if(s>0)return{format:null,confidence:`ambiguous`,evidence:`every separated value has EXACTLY 3 digits after its separator (${h.filter(e=>n.ambiguous[e]>0).map(e=>`"${e}"`).join(`/`)}), e.g. ${n.ambiguousSamples.join(`, `)}. "1,234" is one-thousand-two-hundred-thirty-four or one-point-two-three-four and this file contains nothing that distinguishes them. Guessing here is a silent 1000x error, so state the format explicitly.`,unparsed:[],counts:c};let e=h.find(e=>n.grouping[e]>0);if(e!==void 0){let t=e===`.`?`,`:`.`;return{format:{decimal:t,thousands:e},confidence:`no-evidence`,evidence:`thousands "${e===` `?` `:e}" from ${o} grouped value(s), but NO value shows a decimal separator, so the decimal is a default ("${t}", chosen to differ from the grouping) rather than a reading. Check it against the supplier's own documentation before saving.`,unparsed:[],counts:c}}return{format:{decimal:`.`,thousands:``},confidence:`no-evidence`,evidence:r===0?`no numeric-shaped values were found in the sampled columns.`:`all ${r} numeric sample(s) were bare integers with no separator, so the file says nothing about its decimal separator. Defaulted to "." / none.`,unparsed:[],counts:c}}let f=h.filter(e=>e!==d&&n.grouping[e]>0),p=f[0]??``;if(f.length>1)return{format:null,confidence:`conflict`,evidence:`decimal is "${d}", but ${f.length} different characters (${f.map(e=>`"${e}"`).join(`, `)}) are each used as a thousands separator. A feed uses one.`,unparsed:[],counts:c};let m={decimal:d,thousands:p},v=[];for(let e of i)Number.isNaN(t(e,m))&&v.length<5&&v.push(e);return{format:m,confidence:`high`,evidence:`decimal "${d}" from ${d===`.`?l:u} value(s) with a non-3-digit run after it; thousands ${p===``?`none`:`"${p}"`} from ${o} grouped value(s). ${r} numeric sample(s) considered.`,unparsed:v,counts:c}}const S=1,C=new Set([`supersededno`,`supersededarticle`,`supersedednumber`,`ersatznummer`,`ersatznr`,`replacementcode`,`replacementno`]),w=[`superseding_code`,`replaces_code`];var T=class extends Error{hint;constructor(e,t){super(e),this.name=`FeedAnalysisError`,this.hint=t}};function E(e){return e===`number`?[{op:`trim`},{op:`number`}]:[{op:`trim`}]}function D(e){return e.toLowerCase().replace(/[^a-z0-9]/g,``)}async function O(e,t){let{open:n}=await import(`node:fs/promises`),r=await n(e,`r`);try{let e=Buffer.alloc(t),{bytesRead:n}=await r.read(e,0,t,0);return e.subarray(0,n).toString(`utf8`)}finally{await r.close()}}async function k(e){let{stat:t}=await import(`node:fs/promises`);return(await t(e)).size}function A(e,t){if(t===null)return{chosen:e,evidence:`no quote character appears anywhere in ${e.rows} scanned row(s), so quoting is immaterial; parsed ${e.rows} row(s) cleanly with quotes as data`,warning:null};if(e.ok&&!t.ok)return{chosen:e,evidence:`quotes are ordinary data: parsed ${e.rows} row(s) cleanly, while RFC 4180 failed after ${t.rows} row(s) — ${t.error}`,warning:null};if(!e.ok&&t.ok)return{chosen:t,evidence:`RFC 4180 quoting: parsed ${t.rows} row(s) cleanly, while treating quotes as data failed after ${e.rows} row(s) — ${e.error}`,warning:null};if(e.ok&&t.ok)return{chosen:t,evidence:`BOTH policies parse this file cleanly (${e.rows} row(s) each); chose RFC 4180 because every quote character sits at a field boundary`,warning:`Quoting is ambiguous — both policies parse this file. RFC 4180 was chosen, which STRIPS the enclosing quotes from field values; treating them as data would keep them. Check a sample row before saving, and set the policy explicitly if the other reading is right.`};throw new T(`neither quoting policy can parse this file.\n quotes-as-data: ${e.error} (after ${e.rows} row(s))\n RFC 4180: ${t.error} (after ${t.rows} row(s))`,`Both failures usually mean the DELIMITER or the header verdict is wrong, not the quoting. Check the reported delimiter against the file, and re-run with an explicit --delimiter.`)}async function j(e){let{filePath:t}=e,o=e.targetSchema??`parts`,s=n(o);if(s===void 0)throw new T(`Unknown target schema "${o}".`,`Registered schemas: ${r().join(`, `)}`);let l=e.readSample??O,u=await(e.fileSize??k)(t),h=await l(t,c);if(h.trim()===``)throw new T(`${t} is empty.`);let g=d(h);if(g===null)throw new T(`Could not determine a field delimiter for ${t}.`,`No candidate (TAB, comma, semicolon, pipe) split the sample into more than one column consistently. If the feed uses another delimiter, or is a spreadsheet, map it in the admin wizard instead.`);let{delimiter:_}=g,v=_===`,`?`csv`:`tsv`,y=f(p(h,_),e=>a(e.filter(e=>e!==``),s.fields).size),{hasHeader:b}=y,S={filePath:t,format:v,delimiter:_,hasHeader:b,...e.maxRows!==void 0&&{maxRows:e.maxRows}},C=await m({...S,quote:!1}),{chosen:w,evidence:D,warning:j}=A(C,C.sawQuoteChar||!C.ok?await m({...S,quote:`"`}):null),N=[];j!==null&&N.push(j);let P=w.columns;if(P.length===0)throw new T(`${t} parsed to zero columns.`,`The file may be empty below its header.`);let F=a(P,s.fields),I=new Map(s.fields.map(e=>[e.key,e])),L=[],R=[];for(let e of P){let t=F.get(e);if(t!==void 0){let n=I.get(t);L.push({source:e,target:t,transforms:E(n?.type??`string`)});continue}R.push(M(e))}let z=L.filter(e=>I.get(e.target)?.type===`number`).map(e=>e.source),B=[];for(let e of z)for(let t of w.samples.get(e)??[])B.push(t);let V=x(B);V.format===null&&N.push(`The number format could NOT be decided (${V.confidence}): ${V.evidence} Supply { decimal, thousands } explicitly before saving.`),V.unparsed.length>0&&N.push(`${V.unparsed.length}+ sampled value(s) in the number columns do not parse under the inferred format: ${V.unparsed.join(`, `)}. Those rows will be row errors at import.`),z.length===0&&N.push(`No column mapped to a numeric target, so the number format was inferred from nothing. Check it before saving.`);let H=b?i({columns:[...P],rows:w.sampleRows}):null;return{version:1,file:{path:t,bytes:u},targetSchema:o,reader:{format:v,delimiter:_,hasHeader:b,quote:w.quote},numberFormat:V.format,numberFormatConfidence:V.confidence,columnMap:L,unmapped:R,headerSnapshot:H,dataRows:w.rows,truncated:e.maxRows!==void 0&&e.maxRows>0&&w.rows>=e.maxRows,evidence:{delimiter:g.evidence,hasHeader:y.evidence,quote:D,numberFormat:V.evidence},warnings:N}}function M(e){return C.has(D(e))?{source:e,reason:`"${e}" names a replacement code but not its DIRECTION, and the two readings are opposites: the column may hold the NEWER code that supersedes this row (superseding_code), or the OLDER code this row supersedes (replaces_code). This is deliberately not guessed — an inverted feed builds well-formed chains with valid heads and no warnings, and the merge is append-only, so re-importing the right way round does NOT repair it. Check a superseded code against the supplier's current catalogue and state the direction.`,candidates:[...w]}:{source:e,reason:`no target field matches "${e}".`,candidates:[]}}function N(e){return e.unmapped.map(e=>e.source)}export{s as CANDIDATE_DELIMITERS,c as DIALECT_SAMPLE_BYTES,l as DIALECT_SAMPLE_ROWS,S as FEED_ANALYSIS_VERSION,T as FeedAnalysisError,j as analyzeFeed,u as delimiterName,d as inferDelimiter,f as inferHasHeader,x as inferNumberFormat,p as sampleRecords,m as trialParse,N as unmappedSources};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["parseSync"],"sources":["../../src/analyze/dialect.ts","../../src/analyze/number-format.ts","../../src/analyze/analyze-feed.ts"],"sourcesContent":["/**\n * Decide how a delimited feed is actually laid out — delimiter, header row, and quoting —\n * from the file itself rather than from its extension or from a project default.\n *\n * ## Why all three are per-file\n *\n * The three ground-truth supplier feeds (R027) disagree on two of these axes at once, and each\n * disagreement is load-bearing:\n *\n * - Two are TAB-delimited `.txt`, one is comma-delimited `.csv`. An extension gets this right\n * only because these suppliers happen to be tidy; `.txt` says nothing.\n * - The CSV is genuine RFC 4180 — 1,476 of its rows carry a comma inside a quoted description\n * (`1023031,\"STEUN, DEMPER\",809.41,0,1201134`) — while the two TAB feeds use `\"` as ordinary\n * text and carry 2,321 lines with an ODD number of them (`\"O\"-RING`, `RACCORDO A \"T`).\n * Each file HARD-FAILS under the other's quoting: the CSV dies at row 11,126 of 808,147 with\n * `CSV_RECORD_INCONSISTENT_COLUMNS`, the TSV at row 1,476 of 865,612 with\n * `INVALID_OPENING_QUOTE`.\n *\n * ## How the quoting decision is made: by trying it, not by pattern-matching\n *\n * Quoting is settled by TRIAL PARSE over the whole file (or `maxRows` of it) using the same\n * reader the run uses, once per candidate. A heuristic over raw bytes — \"count the quotes, look\n * for odd lines\" — is cheaper and wrong: RFC 4180 permits a newline inside a quoted field, so\n * an odd count per physical line is evidence of nothing in a file that legitimately has them.\n * Running the real parser and seeing whether it completes is both simpler to justify and\n * exactly the question that matters.\n *\n * The common case costs one pass: a file with no `\"` in it at all is not ambiguous, and the\n * first trial already establishes that while it counts rows.\n */\n\nimport { parse as parseSync } from 'csv-parse/sync'\nimport { readFeed } from '../read-feed.js'\n\n/** Delimiters worth trying. Ordered by how often they appear in supplier feeds. */\nexport const CANDIDATE_DELIMITERS = ['\\t', ',', ';', '|'] as const\nexport type CandidateDelimiter = (typeof CANDIDATE_DELIMITERS)[number]\n\n/** How many bytes of the head of the file the cheap sample-based passes read. */\nexport const DIALECT_SAMPLE_BYTES = 512 * 1024\n/** How many records the sample-based passes consider. */\nexport const DIALECT_SAMPLE_ROWS = 200\n\nexport interface DelimiterInference {\n delimiter: CandidateDelimiter\n /** Field count the winning delimiter produces on the sample. */\n fieldCount: number\n /** Fraction of sampled rows that produced exactly `fieldCount` fields. */\n consistency: number\n evidence: string\n}\n\n/** Printable name for a delimiter, so error text does not contain a raw control character. */\nexport function delimiterName(d: string): string {\n if (d === '\\t') return 'TAB'\n if (d === ' ') return 'SPACE'\n return `\"${d}\"`\n}\n\n/**\n * Pick the delimiter that splits the sample into the most fields MOST CONSISTENTLY.\n *\n * Consistency is what does the work. A tab-delimited feed whose descriptions contain commas\n * still splits into a stable 15 columns on TAB, while the same rows split into 1, 2 or 3 on\n * comma depending on the description — so comma scores near zero even though it \"matches\"\n * plenty of lines. Field count breaks ties in favour of the delimiter that actually structures\n * the file rather than one that happens to appear in every row.\n *\n * Parsed with quoting OFF, deliberately: at this stage the quoting policy is not known, and\n * quoting cannot change which character separates fields — only how many fields a handful of\n * rows produce. Strictness is off too, since an inconsistent count is the measurement here\n * rather than an error.\n */\nexport function inferDelimiter(sample: string): DelimiterInference | null {\n let best: DelimiterInference | null = null\n for (const delimiter of CANDIDATE_DELIMITERS) {\n let rows: string[][]\n try {\n rows = parseSync(sample, {\n delimiter,\n columns: false,\n bom: true,\n skip_empty_lines: true,\n relax_column_count: true,\n quote: false,\n to_line: DIALECT_SAMPLE_ROWS,\n }) as string[][]\n } catch {\n // A delimiter this file does not use can still produce a parser error (a stray quote\n // where a field boundary lands differently). That is a vote against it, not a fault.\n continue\n }\n if (rows.length === 0) continue\n\n const counts = new Map<number, number>()\n for (const r of rows) counts.set(r.length, (counts.get(r.length) ?? 0) + 1)\n let modeCount = 0\n let modeFreq = 0\n for (const [fields, freq] of counts) {\n if (freq > modeFreq || (freq === modeFreq && fields > modeCount)) {\n modeCount = fields\n modeFreq = freq\n }\n }\n if (modeCount < 2) continue\n\n const consistency = modeFreq / rows.length\n // Consistency dominates; field count only separates two EQUALLY consistent readings — and\n // that is expressed as an ordered comparison rather than a weighted sum.\n //\n // `consistency * 1000 + modeCount` says the same thing only while `modeCount < 1000`. Above\n // that the field count starts outranking consistency, inverting the rule the docblock\n // states: a delimiter splitting a line into 4,000 low-consistency fields would beat one\n // splitting it into 15 with perfect consistency. That is not hypothetical for this module —\n // `DEFAULT_SAMPLES_TOTAL` exists in this same file because a mis-detected delimiter\n // collapsing a file into thousands of columns is the input it is written to survive.\n //\n // Two axes compared in order cannot be traded off against each other by accident. A magic\n // multiplier is a bound you have to remember; an ordered comparison is one you cannot forget.\n if (\n best === null ||\n consistency > best.consistency ||\n (consistency === best.consistency && modeCount > best.fieldCount)\n ) {\n best = {\n delimiter,\n fieldCount: modeCount,\n consistency,\n evidence:\n `${delimiterName(delimiter)} yields ${modeCount} fields on ` +\n `${Math.round(consistency * 100)}% of ${rows.length} sampled row(s)`,\n }\n }\n }\n return best\n}\n\nexport interface HeaderInference {\n hasHeader: boolean\n /** The first record's cells, trimmed — the header candidate, whatever the verdict. */\n firstRowCells: string[]\n evidence: string\n}\n\n/**\n * Decide whether the first record is a header.\n *\n * Two independent signals, either sufficient:\n *\n * 1. **The matcher recognises it.** If `matchesTargets` finds at least one target field for\n * the first row's cells, they are column NAMES — a data row of part codes and prices does\n * not match `article name` or `currency cd`. This is the strong signal and it is the one\n * that fires on all three real feeds.\n * 2. **A type change down the column.** The first row is all non-numeric and distinct, while\n * at least one column below it is predominantly numeric. That is the shape of a header over\n * a price column, and it catches feeds whose headers we have no alias for.\n *\n * Both are stated in the evidence string so an operator can see which one carried the verdict.\n */\nexport function inferHasHeader(\n records: readonly string[][],\n matchesTargets: (cells: readonly string[]) => number,\n): HeaderInference {\n const first = records[0] ?? []\n const firstRowCells = first.map((c) => c.trim())\n if (records.length === 0) {\n return { hasHeader: false, firstRowCells, evidence: 'the file has no records' }\n }\n\n const matched = matchesTargets(firstRowCells)\n if (matched > 0) {\n return {\n hasHeader: true,\n firstRowCells,\n evidence: `the first row names ${matched} recognisable target field(s)`,\n }\n }\n\n const nonEmpty = firstRowCells.filter((c) => c !== '')\n const allDistinct = new Set(nonEmpty).size === nonEmpty.length\n const numericRe = /^-?\\d+(?:[., ]\\d+)*$/\n const headerLooksTextual = nonEmpty.length > 0 && !firstRowCells.some((c) => numericRe.test(c))\n\n const body = records.slice(1)\n let numericColumn = false\n if (body.length > 0) {\n for (let col = 0; col < firstRowCells.length; col += 1) {\n let numeric = 0\n let seen = 0\n for (const row of body) {\n const cell = row[col]?.trim()\n if (cell === undefined || cell === '') continue\n seen += 1\n if (numericRe.test(cell)) numeric += 1\n }\n if (seen >= 3 && numeric / seen >= 0.8) {\n numericColumn = true\n break\n }\n }\n }\n\n if (headerLooksTextual && allDistinct && numericColumn) {\n return {\n hasHeader: true,\n firstRowCells,\n evidence:\n 'the first row is entirely non-numeric with distinct values while a column below it is ' +\n 'predominantly numeric',\n }\n }\n\n return {\n hasHeader: false,\n firstRowCells,\n evidence:\n 'the first row matches no target field and does not read as a header over the rows below ' +\n '— treating the feed as positional',\n }\n}\n\n/** Parse the first `DIALECT_SAMPLE_ROWS` records of a sample positionally, for header detection. */\nexport function sampleRecords(sample: string, delimiter: string): string[][] {\n try {\n return parseSync(sample, {\n delimiter,\n columns: false,\n bom: true,\n skip_empty_lines: true,\n relax_column_count: true,\n quote: false,\n to_line: DIALECT_SAMPLE_ROWS,\n }) as string[][]\n } catch {\n // Sampling is best-effort: the caller already has a delimiter verdict and can proceed\n // without header detection rather than failing the whole analysis on a head-of-file quirk.\n return []\n }\n}\n\n/** Outcome of parsing the whole feed under one candidate quoting policy. */\nexport interface TrialParse {\n quote: string | false\n ok: boolean\n /** Parser message when `ok` is false. */\n error: string | null\n /** Data rows successfully read before completion or failure. */\n rows: number\n /**\n * True when any cell contained a `\"`. Only meaningful on the `quote: false` trial, where the\n * reader preserves quote characters verbatim; under RFC 4180 the parser consumes the\n * structural ones, so their absence there proves nothing. This is the flag that decides\n * whether a second trial is needed at all.\n */\n sawQuoteChar: boolean\n /**\n * Sampled cell values per column, capped — the input to number-format inference.\n *\n * COMPACTED: empty cells are skipped, so these arrays are NOT row-aligned with each other and\n * must never be zipped by index into \"rows\". Use {@link TrialParse.sampleRows} for anything\n * that has to show a row. (Zipping them is exactly how the header snapshot came to attribute\n * one part's barcode to the part above it.)\n */\n samples: Map<string, string[]>\n /**\n * The first few WHOLE data rows, verbatim — for the header snapshot the wizard renders as its\n * sample table. Kept separately from `samples` precisely because that one is compacted: this\n * is the operator's verification surface, and a fabricated row on it is worse than no sample.\n */\n sampleRows: Array<Record<string, string>>\n /** Column names as the reader derived them. */\n columns: string[]\n}\n\nexport interface TrialParseOptions {\n filePath: string\n format: 'csv' | 'tsv'\n delimiter: string\n hasHeader: boolean\n quote: string | false\n /** Stop after this many data rows. `0`/absent = read the whole file. */\n maxRows?: number\n /** Cap on retained sample values PER COLUMN. */\n maxSamplesPerColumn?: number\n /** Cap on the TOTAL retained sample values, across all columns. */\n maxSamplesTotal?: number\n /** Cap on retained whole rows. */\n maxSampleRows?: number\n}\n\nconst DEFAULT_SAMPLES_PER_COLUMN = 2_000\n/**\n * Global ceiling on retained sample strings.\n *\n * The per-column cap alone bounds nothing in the column dimension: a 4,000-column feed would\n * retain 8M strings per trial, and both trials are live at once. Number-format inference converges\n * on a few thousand values regardless of how they are distributed, so a total cap costs the\n * inference nothing and makes the bound real.\n */\nconst DEFAULT_SAMPLES_TOTAL = 20_000\n/** Matches `SNAPSHOT_MAX_ROWS` in `../matching/header-snapshot.ts` — the wizard shows 5. */\nconst DEFAULT_SAMPLE_ROWS = 5\n\n/**\n * Parse the feed exactly as a run would, under one quoting policy, and report what happened.\n *\n * `readFeed` is used rather than a private parser so that \"this analysis says the file parses\"\n * and \"the import parses this file\" are the same claim. Strictness stays at the reader's\n * default (`relaxColumnCount` unset → false), which is what turns a wrong quoting policy into a\n * loud error at a named line instead of a quietly shifted column.\n */\nexport async function trialParse(options: TrialParseOptions): Promise<TrialParse> {\n const { filePath, format, delimiter, hasHeader, quote, maxRows } = options\n const perColumn = options.maxSamplesPerColumn ?? DEFAULT_SAMPLES_PER_COLUMN\n const total = options.maxSamplesTotal ?? DEFAULT_SAMPLES_TOTAL\n const rowCap = options.maxSampleRows ?? DEFAULT_SAMPLE_ROWS\n\n const samples = new Map<string, string[]>()\n const sampleRows: Array<Record<string, string>> = []\n let columns: string[] = []\n let rows = 0\n let retained = 0\n let sawQuoteChar = false\n\n try {\n for await (const { row } of readFeed({ filePath, format, delimiter, hasHeader, quote })) {\n rows += 1\n if (columns.length === 0) {\n columns = Object.keys(row)\n // A quote in a HEADER cell counts too. Under `hasHeader: true` the reader consumes the\n // header into column names, so scanning only values would report \"no quote character\n // appears anywhere\" about a file whose headers are quoted — and skip the second trial\n // on that basis.\n if (!sawQuoteChar && columns.some((c) => c.includes('\"'))) sawQuoteChar = true\n }\n if (sampleRows.length < rowCap) sampleRows.push({ ...row })\n for (const [col, value] of Object.entries(row)) {\n if (value === '') continue\n if (!sawQuoteChar && value.includes('\"')) sawQuoteChar = true\n if (retained >= total) continue\n const bucket = samples.get(col)\n if (bucket === undefined) {\n samples.set(col, [value])\n retained += 1\n } else if (bucket.length < perColumn) {\n bucket.push(value)\n retained += 1\n }\n }\n if (maxRows !== undefined && maxRows > 0 && rows >= maxRows) break\n }\n } catch (err) {\n return {\n quote,\n ok: false,\n error: err instanceof Error ? err.message : String(err),\n rows,\n sawQuoteChar,\n samples,\n sampleRows,\n columns,\n }\n }\n\n return { quote, ok: true, error: null, rows, sawQuoteChar, samples, sampleRows, columns }\n}\n","/**\n * Infer a feed's `{ decimal, thousands }` from its own sample values.\n *\n * ## Why this has to exist, and why it must be allowed to refuse\n *\n * `number-format.ts` states the package's standing position outright: *\"The mapping stores an\n * explicit `{ decimal, thousands }` per profile so there is no guessing — `parseNumber` trusts\n * the configured format.\"* That is the right position for the RUN path and nothing here\n * changes it. What it leaves unanswered is where the operator's answer comes from, and until\n * now the answer was \"a dropdown in the wizard, set by hand, per feed\".\n *\n * Getting it wrong is not a parse error. `326,88` read as dot-decimal with comma-thousands\n * parses cleanly to `32688` — a 100× price, with no warning, on a row that imports\n * successfully. Measured on the real feeds (R027), two of the three suppliers use a comma\n * decimal (`1,35`, `348,86`) and the third uses a dot (`0.67`, `809.41`), so a single default\n * is wrong for someone no matter which one is picked.\n *\n * ## The rule: decide on evidence, and say so when there is none\n *\n * A separator is a DECIMAL when it is followed by a run of digits whose length is not 3\n * (`1,35` → 2 digits; `809.41` → 2; `0.1` → 1; `1,2345` → 4). It is a GROUPING separator when\n * it appears more than once in one value (`1.234.567`) or when a value contains two different\n * separators, in which case the LAST one is the decimal and the other is the grouping.\n *\n * A separator followed by exactly 3 digits and nothing else — `1,234` — is the one case that\n * carries no information at all: it reads equally as one-thousand-two-hundred-thirty-four and\n * as one-point-two-three-four. When a file's evidence is only of that shape, this returns\n * `ambiguous` and names the values, because the alternative is a silent 1000× error. The\n * operator states the format; nothing is inferred from where the supplier is registered.\n *\n * The chosen format is then validated by running the REAL {@link parseNumber} over every\n * sample — one parser, not two, so a format this module blesses cannot be one the run rejects.\n */\n\nimport { type NumberFormat, parseNumber } from '../mapping/number-format.js'\n\n/** Separator characters a feed may plausibly use. Space covers `1 234,56` (fr/nb grouping). */\nconst SEPARATORS = ['.', ',', ' '] as const\ntype Separator = (typeof SEPARATORS)[number]\n\n/**\n * A value that is only digits and separators (plus an optional leading sign). Anything else —\n * `NA`, `EUR 12,00`, a part code — is not a number and contributes no evidence rather than\n * counting as malformed.\n */\nconst NUMERIC_SHAPE_RE = /^-?\\d+(?:[., ]\\d+)*$/\n\n/** How confident the inference is, and therefore whether a caller may act on it unattended. */\nexport type NumberFormatConfidence =\n /** Direct evidence, no contradiction. */\n | 'high'\n /** No separated values at all — every sample was a bare integer, so any format parses them. */\n | 'no-evidence'\n /** Every separated value has exactly 3 trailing digits: `1,234` is 1234 or 1.234 and nothing distinguishes them. */\n | 'ambiguous'\n /** The file contains values that argue for BOTH decimal separators. Mixed conventions in one feed. */\n | 'conflict'\n\nexport interface NumberFormatInference {\n /**\n * The inferred format. Present for every confidence EXCEPT `ambiguous` / `conflict`, where\n * there is deliberately no answer — a caller must obtain one from the operator.\n */\n format: NumberFormat | null\n confidence: NumberFormatConfidence\n /** Human-readable statement of what was measured, for the operator to audit the decision. */\n evidence: string\n /**\n * Sample values that the inferred format does NOT parse. Empty on a clean inference. These\n * are not necessarily a defect — a feed with junk rows is normal and `ErrorTracker` exists\n * for it — but a long list means the inference is probably wrong.\n */\n unparsed: string[]\n /** How many sample values were numeric-shaped, and how many were considered at all. */\n counts: { considered: number; numeric: number; separated: number }\n}\n\ninterface Evidence {\n decimal: Record<Separator, number>\n grouping: Record<Separator, number>\n ambiguous: Record<Separator, number>\n ambiguousSamples: string[]\n}\n\nfunction emptyEvidence(): Evidence {\n return {\n decimal: { '.': 0, ',': 0, ' ': 0 },\n grouping: { '.': 0, ',': 0, ' ': 0 },\n ambiguous: { '.': 0, ',': 0, ' ': 0 },\n ambiguousSamples: [],\n }\n}\n\nfunction isSeparator(ch: string): ch is Separator {\n return ch === '.' || ch === ',' || ch === ' '\n}\n\n/** Positions of every separator character in a numeric-shaped value, in order. */\nfunction separatorPositions(value: string): Array<{ char: Separator; index: number }> {\n const out: Array<{ char: Separator; index: number }> = []\n for (let i = 0; i < value.length; i += 1) {\n const ch = value[i]\n if (ch !== undefined && isSeparator(ch)) out.push({ char: ch, index: i })\n }\n return out\n}\n\n/**\n * Accumulate what one value proves about the file's convention.\n *\n * Note the two-separator branch does not care WHICH characters they are: `1.234,56` and\n * `1,234.56` and `1 234,56` are all \"the last separator is the decimal\". That is the whole\n * rule, and it is why this needs no locale table.\n */\nfunction recordValue(value: string, ev: Evidence): void {\n const seps = separatorPositions(value)\n if (seps.length === 0) return\n\n const distinct = new Set(seps.map((s) => s.char))\n if (distinct.size >= 2) {\n const last = seps[seps.length - 1]\n if (last === undefined) return\n ev.decimal[last.char] += 1\n for (const s of seps) if (s.char !== last.char) ev.grouping[s.char] += 1\n return\n }\n\n const char = seps[0]?.char\n if (char === undefined) return\n if (seps.length >= 2) {\n // `1.234.567` — a decimal separator cannot repeat.\n ev.grouping[char] += 1\n return\n }\n const trailing = value.length - (seps[0]?.index ?? 0) - 1\n if (trailing === 3) {\n ev.ambiguous[char] += 1\n if (ev.ambiguousSamples.length < 5) ev.ambiguousSamples.push(value)\n return\n }\n ev.decimal[char] += 1\n}\n\n/**\n * Infer the number format from sample cell values.\n *\n * Pass every non-empty cell from the columns that map to `type: 'number'` targets. Cells that\n * are not numeric-shaped (`NA`, blank, a description) are skipped rather than held against the\n * inference — `GrossPrice` is the literal string `NA` on all 865,612 rows of one real feed, and\n * a rule that treated that as malformed would learn nothing from that supplier at all.\n */\nexport function inferNumberFormat(samples: readonly string[]): NumberFormatInference {\n const ev = emptyEvidence()\n let numeric = 0\n const numericValues: string[] = []\n for (const raw of samples) {\n const v = raw.trim()\n if (v === '') continue\n if (!NUMERIC_SHAPE_RE.test(v)) continue\n numeric += 1\n numericValues.push(v)\n recordValue(v, ev)\n }\n\n // A space is never a decimal separator in ANY convention, so neither its \"decimal\" evidence\n // nor its \"ambiguous\" evidence is a real reading — both are artefacts of a rule written for\n // `.` and `,`. Fold both into grouping before deciding.\n //\n // The ambiguous half was the bug: `recordValue` routes a single separator with exactly three\n // trailing digits to `ambiguous` before the character is considered, so `1 234` landed there\n // and a file of `['1 234', '5 678']` came back `ambiguous` with `format: null` — refusing to\n // decide something that has only one reading. Worse, the refusal text quoted `\"1,234\"` and\n // claimed two readings for values that have one.\n ev.grouping[' '] += ev.decimal[' '] + ev.ambiguous[' ']\n ev.decimal[' '] = 0\n ev.ambiguous[' '] = 0\n\n // Counted AFTER the fold, so `grouped` in the evidence string includes the space values it\n // just absorbed rather than reporting a figure from before they moved.\n const separated = ev.decimal['.'] + ev.decimal[',']\n const grouped = ev.grouping['.'] + ev.grouping[','] + ev.grouping[' ']\n const ambiguousTotal = ev.ambiguous['.'] + ev.ambiguous[',']\n const counts = { considered: samples.length, numeric, separated: separated + grouped }\n\n const dotDec = ev.decimal['.']\n const commaDec = ev.decimal[',']\n\n if (dotDec > 0 && commaDec > 0) {\n return {\n format: null,\n confidence: 'conflict',\n evidence:\n `the file argues for BOTH decimal separators — ${dotDec} value(s) put a non-3-digit run ` +\n `after \".\" and ${commaDec} after \",\". One feed is using two conventions, or the ` +\n `delimiter is wrong and two columns have been merged into one.`,\n unparsed: [],\n counts,\n }\n }\n\n let decimal: '.' | ',' | null = null\n if (dotDec > 0) decimal = '.'\n else if (commaDec > 0) decimal = ','\n\n if (decimal === null) {\n if (ambiguousTotal > 0) {\n const chars = SEPARATORS.filter((c) => ev.ambiguous[c] > 0).map((c) => `\"${c}\"`)\n return {\n format: null,\n confidence: 'ambiguous',\n evidence:\n `every separated value has EXACTLY 3 digits after its separator (${chars.join('/')}), ` +\n `e.g. ${ev.ambiguousSamples.join(', ')}. \"1,234\" is one-thousand-two-hundred-thirty-four ` +\n `or one-point-two-three-four and this file contains nothing that distinguishes them. ` +\n `Guessing here is a silent 1000x error, so state the format explicitly.`,\n unparsed: [],\n counts,\n }\n }\n // Grouping IS known, the decimal is not — `1 234` with no decimal anywhere. Report the\n // grouping that was measured and default only the part that was not, rather than reporting\n // \"no evidence\" about a file that supplied some. The decimal default must differ from the\n // chosen thousands (`parseNumber` strips thousands first, so equal separators would strip\n // the decimal too), which is what picks `,` when the grouping is `.`.\n const groupingOnly = SEPARATORS.find((c) => ev.grouping[c] > 0)\n if (groupingOnly !== undefined) {\n const impliedDecimal: '.' | ',' = groupingOnly === '.' ? ',' : '.'\n return {\n format: { decimal: impliedDecimal, thousands: groupingOnly },\n confidence: 'no-evidence',\n evidence:\n `thousands \"${groupingOnly === ' ' ? ' ' : groupingOnly}\" from ${grouped} grouped ` +\n `value(s), but NO value shows a decimal separator, so the decimal is a default ` +\n `(\"${impliedDecimal}\", chosen to differ from the grouping) rather than a reading. ` +\n `Check it against the supplier's own documentation before saving.`,\n unparsed: [],\n counts,\n }\n }\n\n // Every sample was a bare integer. Any format parses them identically today, but the\n // choice is still recorded on the mapping and will apply to tomorrow's file.\n const fallback: NumberFormat = { decimal: '.', thousands: '' }\n return {\n format: fallback,\n confidence: 'no-evidence',\n evidence:\n numeric === 0\n ? 'no numeric-shaped values were found in the sampled columns.'\n : `all ${numeric} numeric sample(s) were bare integers with no separator, so the file ` +\n `says nothing about its decimal separator. Defaulted to \".\" / none.`,\n unparsed: [],\n counts,\n }\n }\n\n const groupingCandidates = SEPARATORS.filter((c) => c !== decimal && ev.grouping[c] > 0)\n const thousands: NumberFormat['thousands'] = groupingCandidates[0] ?? ''\n if (groupingCandidates.length > 1) {\n return {\n format: null,\n confidence: 'conflict',\n evidence:\n `decimal is \"${decimal}\", but ${groupingCandidates.length} different characters ` +\n `(${groupingCandidates.map((c) => `\"${c}\"`).join(', ')}) are each used as a thousands ` +\n `separator. A feed uses one.`,\n unparsed: [],\n counts,\n }\n }\n\n const format: NumberFormat = { decimal, thousands }\n // Validate against the REAL parser rather than against this module's own reasoning: if the\n // two ever disagree, the run is what ships, so the run is what decides.\n const unparsed: string[] = []\n for (const v of numericValues) {\n if (Number.isNaN(parseNumber(v, format))) {\n if (unparsed.length < 5) unparsed.push(v)\n }\n }\n\n return {\n format,\n confidence: 'high',\n evidence:\n `decimal \"${decimal}\" from ${decimal === '.' ? dotDec : commaDec} value(s) with a ` +\n `non-3-digit run after it; thousands ${thousands === '' ? 'none' : `\"${thousands}\"`} ` +\n `from ${grouped} grouped value(s). ${numeric} numeric sample(s) considered.`,\n unparsed,\n counts,\n }\n}\n","/**\n * Analyse a supplier feed and propose the `import_mapping` it needs — read-only.\n *\n * This is the thing `lumi imports:analyze` is. It writes nothing, touches no database, and\n * returns a document that `lumi imports:mapping:create` turns into a row. Splitting it that way\n * is the point: the operator reads the proposal, corrects the parts a machine cannot know, and\n * only then does anything get written.\n *\n * ## What it decides, and what it refuses to decide\n *\n * DECIDES, from the file: delimiter, whether row 0 is a header, the quoting policy, and the\n * `{ decimal, thousands }` number format — each by measurement, each per file, because the real\n * feeds disagree on every one of them (R027).\n *\n * REFUSES, on principle: any column it cannot match to a target field. Those are reported by\n * NAME in `unmapped`, never dropped and never guessed at. Two reasons, and the second is the\n * one that matters:\n *\n * 1. A silently dropped column is a silently missing price, barcode or description, and the\n * import succeeds either way.\n * 2. Some columns are ambiguous in a way no amount of cleverness resolves. `superseded no` is\n * the canonical one: TecDoc reads that phrasing as the OLD code (the row is current), while\n * `ersetzt durch` / `replaced by` mean the NEW one (the row is obsolete). Both are\n * \"replacement\" words and only grammatical voice separates them. `parts-schema.ts`\n * deliberately aliases those spellings to NEITHER target so an operator has to state the\n * direction — and getting it backwards builds well-formed chains with valid heads, no\n * warnings, and no repair, because the merge is append-only. This module upholds that\n * refusal rather than working around it, and says WHY in the reason text.\n */\n\nimport type { MappingColumn } from '../mapping/engine.js'\nimport type { NumberFormat } from '../mapping/number-format.js'\nimport { getTargetSchema, listTargetSchemas } from '../mapping/schema-registry.js'\nimport type { TargetField } from '../mapping/target-schema.js'\nimport { type HeaderSample, suggestTargets, toHeaderSnapshot } from '../matching/index.js'\nimport {\n DIALECT_SAMPLE_BYTES,\n delimiterName,\n inferDelimiter,\n inferHasHeader,\n sampleRecords,\n type TrialParse,\n trialParse,\n} from './dialect.js'\nimport { inferNumberFormat, type NumberFormatConfidence } from './number-format.js'\n\n/**\n * Document version. `imports:mapping:create` refuses a document it does not understand rather\n * than reading fields positionally out of a shape that has moved.\n */\nexport const FEED_ANALYSIS_VERSION = 1\n\n/**\n * Header spellings that name a supersession relationship WITHOUT stating its direction — see the\n * module docblock.\n *\n * `ersatzteilnummer` is deliberately NOT here, and the near-miss is the point: it looks like\n * `ersatznummer` plus three letters and means something entirely different — *Ersatz**teil**nummer*\n * is the part's own OE identification number, not a replacement code at all. `parts-schema.ts`\n * calls it a false friend and `auto-assign.test.ts` asserts it resolves to neither supersession\n * target. Listing it here would have made `imports:analyze` hand the operator the direction\n * question about an OE number column and offer both chain targets as the fix — prompting them\n * into precisely the silent, unrepairable mistake the prompt exists to prevent.\n */\nconst AMBIGUOUS_DIRECTION_HEADERS = new Set([\n 'supersededno',\n 'supersededarticle',\n 'supersedednumber',\n 'ersatznummer',\n 'ersatznr',\n 'replacementcode',\n 'replacementno',\n])\n\n/** The two targets an ambiguous-direction column must choose between. */\nconst DIRECTION_TARGETS = ['superseding_code', 'replaces_code'] as const\n\nexport interface UnmappedColumn {\n source: string\n /** Why it was not assigned, in words an operator can act on. */\n reason: string\n /**\n * Targets the operator should choose BETWEEN, when the column is recognisably one of a known\n * ambiguous pair. Empty when nothing plausible matched at all.\n */\n candidates: string[]\n}\n\nexport interface FeedAnalysisReader {\n format: 'csv' | 'tsv'\n /** Always explicit, so the document states what was measured rather than implying a default. */\n delimiter: string\n hasHeader: boolean\n /** `false` = quotes are ordinary data; `'\"'` = RFC 4180. */\n quote: string | false\n}\n\nexport interface FeedAnalysis {\n version: typeof FEED_ANALYSIS_VERSION\n file: { path: string; bytes: number }\n targetSchema: string\n reader: FeedAnalysisReader\n numberFormat: NumberFormat | null\n numberFormatConfidence: NumberFormatConfidence\n /** Proposed source→target assignments, with the wizard's own default transform chains. */\n columnMap: MappingColumn[]\n /** Every source column with no assignment, each with a reason. Never silently dropped. */\n unmapped: UnmappedColumn[]\n /** Sample header + rows, shaped and capped exactly as the wizard's save does (#667). */\n headerSnapshot: HeaderSample | null\n /** Data rows the trial parse read. Exact when the whole file was scanned. */\n dataRows: number\n /** True when `maxRows` cut the scan short, so `dataRows` is a floor rather than a count. */\n truncated: boolean\n /** What was measured, per decision, for the operator to audit rather than trust. */\n evidence: {\n delimiter: string\n hasHeader: string\n quote: string\n numberFormat: string\n }\n /** Non-fatal things the operator should read before saving. */\n warnings: string[]\n}\n\nexport class FeedAnalysisError extends Error {\n readonly hint: string | undefined\n constructor(message: string, hint?: string) {\n super(message)\n this.name = 'FeedAnalysisError'\n this.hint = hint\n }\n}\n\nexport interface AnalyzeFeedOptions {\n filePath: string\n /** Registered target schema name. Defaults to `parts`. */\n targetSchema?: string\n /** Stop scanning after this many data rows. `0`/absent = the whole file. */\n maxRows?: number\n /** Injected for tests; defaults to reading the head of `filePath`. */\n readSample?: (filePath: string, bytes: number) => Promise<string>\n /** Injected for tests; defaults to `fs.stat`. */\n fileSize?: (filePath: string) => Promise<number>\n}\n\n/** Per-target-type default transform chain — mirrors the wizard's `defaultTransforms`. */\nfunction defaultTransforms(type: TargetField['type']): MappingColumn['transforms'] {\n return type === 'number' ? [{ op: 'trim' }, { op: 'number' }] : [{ op: 'trim' }]\n}\n\n/** Bare-alphanumeric form, matching the matcher's own normalisation. */\nfunction normalize(s: string): string {\n return s.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\nasync function defaultReadSample(filePath: string, bytes: number): Promise<string> {\n const { open } = await import('node:fs/promises')\n const handle = await open(filePath, 'r')\n try {\n const buf = Buffer.alloc(bytes)\n const { bytesRead } = await handle.read(buf, 0, bytes, 0)\n return buf.subarray(0, bytesRead).toString('utf8')\n } finally {\n await handle.close()\n }\n}\n\nasync function defaultFileSize(filePath: string): Promise<number> {\n const { stat } = await import('node:fs/promises')\n return (await stat(filePath)).size\n}\n\n/**\n * Choose the quoting policy by trying both and seeing which one the real reader survives.\n *\n * The three outcomes and what each means:\n * - only one trial completes → that is the policy, and the other's parser error is the proof;\n * - neither completes → the delimiter or header verdict is wrong, and reporting both errors\n * is more useful than picking the one that got further;\n * - both complete AND the file contains `\"` → genuinely ambiguous. RFC 4180 is preferred,\n * because a file whose every quote sits at a field boundary is far likelier to be quoted\n * than to coincidentally contain balanced quote characters — but the operator is told, since\n * the two readings differ in the VALUES they produce even where they agree on the columns.\n */\nfunction chooseQuoting(\n plain: TrialParse,\n quoted: TrialParse | null,\n): { chosen: TrialParse; evidence: string; warning: string | null } {\n if (quoted === null) {\n return {\n chosen: plain,\n evidence: `no quote character appears anywhere in ${plain.rows} scanned row(s), so quoting is immaterial; parsed ${plain.rows} row(s) cleanly with quotes as data`,\n warning: null,\n }\n }\n if (plain.ok && !quoted.ok) {\n return {\n chosen: plain,\n evidence: `quotes are ordinary data: parsed ${plain.rows} row(s) cleanly, while RFC 4180 failed after ${quoted.rows} row(s) — ${quoted.error}`,\n warning: null,\n }\n }\n if (!plain.ok && quoted.ok) {\n return {\n chosen: quoted,\n evidence: `RFC 4180 quoting: parsed ${quoted.rows} row(s) cleanly, while treating quotes as data failed after ${plain.rows} row(s) — ${plain.error}`,\n warning: null,\n }\n }\n if (plain.ok && quoted.ok) {\n return {\n chosen: quoted,\n evidence: `BOTH policies parse this file cleanly (${plain.rows} row(s) each); chose RFC 4180 because every quote character sits at a field boundary`,\n warning:\n 'Quoting is ambiguous — both policies parse this file. RFC 4180 was chosen, which STRIPS ' +\n 'the enclosing quotes from field values; treating them as data would keep them. Check a ' +\n 'sample row before saving, and set the policy explicitly if the other reading is right.',\n }\n }\n throw new FeedAnalysisError(\n `neither quoting policy can parse this file.\\n` +\n ` quotes-as-data: ${plain.error} (after ${plain.rows} row(s))\\n` +\n ` RFC 4180: ${quoted.error} (after ${quoted.rows} row(s))`,\n 'Both failures usually mean the DELIMITER or the header verdict is wrong, not the quoting. ' +\n 'Check the reported delimiter against the file, and re-run with an explicit --delimiter.',\n )\n}\n\n/**\n * Analyse a delimited supplier feed. Reads the file; writes nothing.\n *\n * xlsx is deliberately out of scope: none of its four decisions apply (a sheet has no delimiter,\n * no quoting and no text number format — exceljs hands back typed cells), so an \"analysis\" of one\n * would be four constants and a column list. Map those in the wizard.\n */\nexport async function analyzeFeed(options: AnalyzeFeedOptions): Promise<FeedAnalysis> {\n const { filePath } = options\n const schemaName = options.targetSchema ?? 'parts'\n const schema = getTargetSchema(schemaName)\n if (schema === undefined) {\n throw new FeedAnalysisError(\n `Unknown target schema \"${schemaName}\".`,\n `Registered schemas: ${listTargetSchemas().join(', ')}`,\n )\n }\n const readSample = options.readSample ?? defaultReadSample\n const fileSize = options.fileSize ?? defaultFileSize\n\n const bytes = await fileSize(filePath)\n const sample = await readSample(filePath, DIALECT_SAMPLE_BYTES)\n if (sample.trim() === '') {\n throw new FeedAnalysisError(`${filePath} is empty.`)\n }\n\n // ── 1. Delimiter ────────────────────────────────────────────────────────────────────────\n const delimiterInference = inferDelimiter(sample)\n if (delimiterInference === null) {\n throw new FeedAnalysisError(\n `Could not determine a field delimiter for ${filePath}.`,\n 'No candidate (TAB, comma, semicolon, pipe) split the sample into more than one column ' +\n 'consistently. If the feed uses another delimiter, or is a spreadsheet, map it in the ' +\n 'admin wizard instead.',\n )\n }\n const { delimiter } = delimiterInference\n const format: 'csv' | 'tsv' = delimiter === ',' ? 'csv' : 'tsv'\n\n // ── 2. Header ───────────────────────────────────────────────────────────────────────────\n const records = sampleRecords(sample, delimiter)\n const headerInference = inferHasHeader(\n records,\n (cells) =>\n suggestTargets(\n cells.filter((c) => c !== ''),\n schema.fields,\n ).size,\n )\n const { hasHeader } = headerInference\n\n // ── 3. Quoting — by trial parse, using the reader the run uses ──────────────────────────\n const base = {\n filePath,\n format,\n delimiter,\n hasHeader,\n ...(options.maxRows !== undefined && { maxRows: options.maxRows }),\n }\n const plain = await trialParse({ ...base, quote: false })\n // A second pass is only worth its cost when the file actually contains quote characters, or\n // when the first pass failed and RFC 4180 is the remaining explanation.\n const needsQuotedTrial = plain.sawQuoteChar || !plain.ok\n const quoted = needsQuotedTrial ? await trialParse({ ...base, quote: '\"' }) : null\n const { chosen, evidence: quoteEvidence, warning: quoteWarning } = chooseQuoting(plain, quoted)\n\n const warnings: string[] = []\n if (quoteWarning !== null) warnings.push(quoteWarning)\n\n const columns = chosen.columns\n if (columns.length === 0) {\n throw new FeedAnalysisError(\n `${filePath} parsed to zero columns.`,\n 'The file may be empty below its header.',\n )\n }\n\n // ── 4. Column map ───────────────────────────────────────────────────────────────────────\n const assigned = suggestTargets(columns, schema.fields)\n const fieldsByKey = new Map(schema.fields.map((f) => [f.key, f]))\n const columnMap: MappingColumn[] = []\n const unmapped: UnmappedColumn[] = []\n for (const source of columns) {\n const target = assigned.get(source)\n if (target !== undefined) {\n const field = fieldsByKey.get(target)\n columnMap.push({\n source,\n target,\n transforms: defaultTransforms(field?.type ?? 'string'),\n })\n continue\n }\n unmapped.push(describeUnmapped(source))\n }\n\n // ── 5. Number format, sampled from the columns that feed number targets ─────────────────\n const numberSources = columnMap\n .filter((c) => fieldsByKey.get(c.target)?.type === 'number')\n .map((c) => c.source)\n const numberSamples: string[] = []\n for (const source of numberSources) {\n for (const v of chosen.samples.get(source) ?? []) numberSamples.push(v)\n }\n const nf = inferNumberFormat(numberSamples)\n if (nf.format === null) {\n // The reason already sits in `evidence.numberFormat`, but that field is an AUDIT surface —\n // something a careful operator reads to check a decision. A refusal is different: it is a\n // thing the caller must act on, and a programmatic consumer of this document (the CLI is\n // one, a future admin route another) reads `warnings`. Leaving the only statement of it in\n // the audit surface makes `numberFormat: null` the sole signal, which reads as \"not\n // applicable\" rather than \"you must supply this\".\n warnings.push(\n `The number format could NOT be decided (${nf.confidence}): ${nf.evidence} ` +\n `Supply { decimal, thousands } explicitly before saving.`,\n )\n }\n if (nf.unparsed.length > 0) {\n warnings.push(\n `${nf.unparsed.length}+ sampled value(s) in the number columns do not parse under the ` +\n `inferred format: ${nf.unparsed.join(', ')}. Those rows will be row errors at import.`,\n )\n }\n if (numberSources.length === 0) {\n warnings.push(\n 'No column mapped to a numeric target, so the number format was inferred from nothing. ' +\n 'Check it before saving.',\n )\n }\n\n // ── 6. Header snapshot, shaped exactly as a wizard save would shape it (#667) ───────────\n //\n // From `sampleRows` — the WHOLE rows the trial retained — and emphatically NOT by zipping\n // `chosen.samples` by index. That map is compacted (empty cells are skipped), so its columns\n // are not row-aligned: zipping them produced a snapshot where a part carried the barcode of\n // the part BELOW it, every value plausible and every pairing invented. This snapshot is the\n // table the wizard renders for the operator to map against, so a fabricated row there is\n // worse than no sample at all.\n const headerSnapshot = hasHeader\n ? toHeaderSnapshot({ columns: [...columns], rows: chosen.sampleRows })\n : null\n\n return {\n version: FEED_ANALYSIS_VERSION,\n file: { path: filePath, bytes },\n targetSchema: schemaName,\n reader: { format, delimiter, hasHeader, quote: chosen.quote },\n numberFormat: nf.format,\n numberFormatConfidence: nf.confidence,\n columnMap,\n unmapped,\n headerSnapshot,\n dataRows: chosen.rows,\n truncated:\n options.maxRows !== undefined && options.maxRows > 0 && chosen.rows >= options.maxRows,\n evidence: {\n delimiter: delimiterInference.evidence,\n hasHeader: headerInference.evidence,\n quote: quoteEvidence,\n numberFormat: nf.evidence,\n },\n warnings,\n }\n}\n\n/**\n * Explain why a column was not assigned — naming the choice when the column is one of the known\n * direction-ambiguous spellings, so the operator gets the actual question rather than a shrug.\n */\nfunction describeUnmapped(source: string): UnmappedColumn {\n if (AMBIGUOUS_DIRECTION_HEADERS.has(normalize(source))) {\n return {\n source,\n reason:\n `\"${source}\" names a replacement code but not its DIRECTION, and the two readings are ` +\n `opposites: the column may hold the NEWER code that supersedes this row ` +\n `(superseding_code), or the OLDER code this row supersedes (replaces_code). This is ` +\n `deliberately not guessed — an inverted feed builds well-formed chains with valid heads ` +\n `and no warnings, and the merge is append-only, so re-importing the right way round does ` +\n `NOT repair it. Check a superseded code against the supplier's current catalogue and ` +\n `state the direction.`,\n candidates: [...DIRECTION_TARGETS],\n }\n }\n return {\n source,\n reason: `no target field matches \"${source}\".`,\n candidates: [],\n }\n}\n\n/** Every source column the analysis did not assign — the set a caller must resolve explicitly. */\nexport function unmappedSources(analysis: FeedAnalysis): string[] {\n return analysis.unmapped.map((u) => u.source)\n}\n\n/** Printable delimiter, re-exported so a CLI does not have to re-derive it. */\nexport { delimiterName }\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{n as e,r as t}from"./parts-schema-QqLci_Pl.mjs";import{t as n}from"./engine-B0ExE4jL.mjs";const r=`commerce:carmaker-feed`,i=e,a={targetSchema:`parts`,numberFormat:{decimal:`,`,thousands:`.`},columnMap:[{source:`ArticleName`,target:`code`,transforms:[{op:`trim`}]},{source:`NetPrice/Discount`,target:`net_price`,transforms:[{op:`europeanNumber`}]},{source:`GrossPrice`,target:`gross_price`,transforms:[{op:`europeanNumber`}]},{source:`CurrencyCd`,target:`currency`,transforms:[{op:`trim`}]},{source:`Barcode`,target:`barcode`,transforms:[{op:`trim`}]},{source:`Description1`,target:`description1`,transforms:[{op:`trim`}]},{source:`Description2`,target:`description2`,transforms:[{op:`trim`}]},{source:`Description_DE`,target:`name_de`,transforms:[{op:`trim`}]},{source:`Description_EN`,target:`name_en`,transforms:[{op:`trim`}]},{source:`Description_ES`,target:`name_es`,transforms:[{op:`trim`}]},{source:`Description_FR`,target:`name_fr`,transforms:[{op:`trim`}]},{source:`Description_IT`,target:`name_it`,transforms:[{op:`trim`}]},{source:`Description_NL`,target:`name_nl`,transforms:[{op:`trim`}]},{source:`Description_PT`,target:`name_pt`,transforms:[{op:`trim`}]},{source:`Description_RO`,target:`name_ro`,transforms:[{op:`trim`}]}]},o=n(a,t);export{o as i,a as n,i as r,r as t};
2
+ //# sourceMappingURL=carmaker-feed-B-QYr3PI.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"carmaker-feed-CxhZBrkO.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — now a THIN WRAPPER over the configurable mapping\n * engine (commerce-demo PR2). The hardcoded 14/15-column parsing logic is RETIRED:\n * this declares the carmaker layout as a built-in `parts` `MappingConfig` and\n * delegates to `buildMappingTransform`. The shared `partsSchema` does code\n * normalization (stripping the per-run `supplierCodePrefix`), the EUR currency\n * gate, price validation, and `PartsDocument` assembly — so the carmaker feed and\n * every operator-built `parts` mapping run through one row taxonomy.\n *\n * **Behaviour**: the emitted `PartsDocument` is equivalent to the old hardcoded\n * transform for in-spec rows, with deliberate differences worth knowing:\n * - It now populates `name_ro` from the 15-column feed's `Description_RO` — the\n * old 14-column transform always left it null (PR1 added the field).\n * - Number/currency cells are more lenient: surrounding whitespace is trimmed,\n * empty / lowercase `na` count as absent, and a blank/unmapped currency\n * defaults to EUR (the old transform rejected a blank `CurrencyCd`).\n * - The error TAXONOMY is uniform target-field naming (`missing_code`/`code`,\n * `unsupported_currency`/`currency`, empty required price → `missing_price`)\n * rather than source-column-specific (`missing_article_name`/`ArticleName`),\n * since the engine is source-column-agnostic.\n *\n * The carmaker doc-shape suite (27 tests) plus the leniency tests below it are the\n * regression net.\n *\n * **Decisions still enforced (now via the shared parts schema):**\n * - **D2** Code normalized (supplier prefix stripped, uppercased, dash/space-stripped).\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>`.\n * - **D9** EUR-only (non-EUR rows rejected).\n * - **D21** Bulk-write path bypasses entity hooks (audit on the `import_run`).\n *\n * **`*S` rows** are core-fee lines (commerce-demo SD002 / D004), handled by the shared\n * parts schema (PR7): a `<code>*S` row is NOT a saleable doc — it skips the index and\n * attaches its price as the PARENT part's `core_charge_minor` in the runner's phase-2\n * pass. So the carmaker feed's `*S` rows now route the core charge instead of passing\n * through as standalone docs. Replacement codes remain the dealer superseding-code\n * column (D003), never `*S`.\n *\n * Numbers use the `europeanNumber` op (comma decimal, dot thousands, bare-dot\n * rejected) to match the carmaker feed's documented format exactly; the\n * config's `numberFormat` is unused by that op but required by the schema.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { buildMappingTransform, type MappingConfig } from '../mapping/engine.js'\nimport { type PartsParams, partsParamsSchema, partsSchema } from '../mapping/parts-schema.js'\nimport type { RowTransform } from '../transform.js'\n\n/** Stable name under which this transform is registered. */\nexport const CARMAKER_FEED_TRANSFORM_NAME = 'commerce:carmaker-feed'\n\n/**\n * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.\n * This IS the parts schema's params (re-exported), so brand/supplier injection +\n * the optional `supplierCodePrefix` strip validate identically everywhere.\n */\nexport const carmakerFeedParamsSchema = partsParamsSchema\nexport type CarmakerFeedParams = PartsParams\n\n/**\n * The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every\n * column carries a `trim` op; the two price columns use `europeanNumber`. The code\n * column is mapped RAW — the parts schema strips the supplier prefix (from params)\n * and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.\n */\nconst CARMAKER_MAPPING: MappingConfig = {\n targetSchema: 'parts',\n numberFormat: { decimal: ',', thousands: '.' },\n columnMap: [\n { source: 'ArticleName', target: 'code', transforms: [{ op: 'trim' }] },\n { source: 'NetPrice/Discount', target: 'net_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'GrossPrice', target: 'gross_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'CurrencyCd', target: 'currency', transforms: [{ op: 'trim' }] },\n { source: 'Barcode', target: 'barcode', transforms: [{ op: 'trim' }] },\n { source: 'Description1', target: 'description1', transforms: [{ op: 'trim' }] },\n { source: 'Description2', target: 'description2', transforms: [{ op: 'trim' }] },\n { source: 'Description_DE', target: 'name_de', transforms: [{ op: 'trim' }] },\n { source: 'Description_EN', target: 'name_en', transforms: [{ op: 'trim' }] },\n { source: 'Description_ES', target: 'name_es', transforms: [{ op: 'trim' }] },\n { source: 'Description_FR', target: 'name_fr', transforms: [{ op: 'trim' }] },\n { source: 'Description_IT', target: 'name_it', transforms: [{ op: 'trim' }] },\n { source: 'Description_NL', target: 'name_nl', transforms: [{ op: 'trim' }] },\n { source: 'Description_PT', target: 'name_pt', transforms: [{ op: 'trim' }] },\n { source: 'Description_RO', target: 'name_ro', transforms: [{ op: 'trim' }] },\n ],\n}\n\n/**\n * The carmaker transform — built once at module load from the static mapping. The\n * build validates every target against the `parts` whitelist (throws on a typo).\n */\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = buildMappingTransform(\n CARMAKER_MAPPING,\n partsSchema,\n)\n"],"mappings":"8DAgDA,MAAa,EAA+B,yBAO/B,EAA2B,EAmC3B,EAAqD,EAChE,CA1BA,aAAc,QACd,aAAc,CAAE,QAAS,IAAK,UAAW,GAAI,EAC7C,UAAW,CACT,CAAE,OAAQ,cAAe,OAAQ,OAAQ,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACtE,CAAE,OAAQ,oBAAqB,OAAQ,YAAa,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EAC3F,CAAE,OAAQ,aAAc,OAAQ,cAAe,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EACtF,CAAE,OAAQ,aAAc,OAAQ,WAAY,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACzE,CAAE,OAAQ,UAAW,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACrE,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,CAC9E,CAQA,EACA,CACF"}
1
+ {"version":3,"file":"carmaker-feed-B-QYr3PI.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — now a THIN WRAPPER over the configurable mapping\n * engine (commerce-demo PR2). The hardcoded 14/15-column parsing logic is RETIRED:\n * this declares the carmaker layout as a built-in `parts` `MappingConfig` and\n * delegates to `buildMappingTransform`. The shared `partsSchema` does code\n * normalization (stripping the per-run `supplierCodePrefix`), the EUR currency\n * gate, price validation, and `PartsDocument` assembly — so the carmaker feed and\n * every operator-built `parts` mapping run through one row taxonomy.\n *\n * **Behaviour**: the emitted `PartsDocument` is equivalent to the old hardcoded\n * transform for in-spec rows, with deliberate differences worth knowing:\n * - It now populates `name_ro` from the 15-column feed's `Description_RO` — the\n * old 14-column transform always left it null (PR1 added the field).\n * - Number/currency cells are more lenient: surrounding whitespace is trimmed,\n * empty / lowercase `na` count as absent, and a blank/unmapped currency\n * defaults to EUR (the old transform rejected a blank `CurrencyCd`).\n * - The error TAXONOMY is uniform target-field naming (`missing_code`/`code`,\n * `unsupported_currency`/`currency`, empty required price → `missing_price`)\n * rather than source-column-specific (`missing_article_name`/`ArticleName`),\n * since the engine is source-column-agnostic.\n *\n * The carmaker doc-shape suite (27 tests) plus the leniency tests below it are the\n * regression net.\n *\n * **Decisions still enforced (now via the shared parts schema):**\n * - **D2** Code normalized (supplier prefix stripped, uppercased, dash/space-stripped).\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>`.\n * - **D9** EUR-only (non-EUR rows rejected).\n * - **D21** Bulk-write path bypasses entity hooks (audit on the `import_run`).\n *\n * **`*S` rows** are core-fee lines (commerce-demo SD002 / D004), handled by the shared\n * parts schema (PR7): a `<code>*S` row is NOT a saleable doc — it skips the index and\n * attaches its price as the PARENT part's `core_charge_minor` in the runner's phase-2\n * pass. So the carmaker feed's `*S` rows now route the core charge instead of passing\n * through as standalone docs. Replacement codes remain the dealer superseding-code\n * column (D003), never `*S`.\n *\n * Numbers use the `europeanNumber` op (comma decimal, dot thousands, bare-dot\n * rejected) to match the carmaker feed's documented format exactly; the\n * config's `numberFormat` is unused by that op but required by the schema.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { buildMappingTransform, type MappingConfig } from '../mapping/engine.js'\nimport { type PartsParams, partsParamsSchema, partsSchema } from '../mapping/parts-schema.js'\nimport type { RowTransform } from '../transform.js'\n\n/** Stable name under which this transform is registered. */\nexport const CARMAKER_FEED_TRANSFORM_NAME = 'commerce:carmaker-feed'\n\n/**\n * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.\n * This IS the parts schema's params (re-exported), so brand/supplier injection +\n * the optional `supplierCodePrefix` strip validate identically everywhere.\n */\nexport const carmakerFeedParamsSchema = partsParamsSchema\nexport type CarmakerFeedParams = PartsParams\n\n/**\n * The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every\n * column carries a `trim` op; the two price columns use `europeanNumber`. The code\n * column is mapped RAW — the parts schema strips the supplier prefix (from params)\n * and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.\n *\n * Exported (not module-local) so a consumer building a real `import_mapping` row\n * for a GIGA brand — which needs a `stripPrefix` transform baked into the `code`\n * column instead of the legacy path's `params.supplierCodePrefix` — can start from\n * this exact layout rather than re-deriving it (see commerce's `seed.ts`).\n */\nexport const CARMAKER_MAPPING: MappingConfig = {\n targetSchema: 'parts',\n numberFormat: { decimal: ',', thousands: '.' },\n columnMap: [\n { source: 'ArticleName', target: 'code', transforms: [{ op: 'trim' }] },\n { source: 'NetPrice/Discount', target: 'net_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'GrossPrice', target: 'gross_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'CurrencyCd', target: 'currency', transforms: [{ op: 'trim' }] },\n { source: 'Barcode', target: 'barcode', transforms: [{ op: 'trim' }] },\n { source: 'Description1', target: 'description1', transforms: [{ op: 'trim' }] },\n { source: 'Description2', target: 'description2', transforms: [{ op: 'trim' }] },\n { source: 'Description_DE', target: 'name_de', transforms: [{ op: 'trim' }] },\n { source: 'Description_EN', target: 'name_en', transforms: [{ op: 'trim' }] },\n { source: 'Description_ES', target: 'name_es', transforms: [{ op: 'trim' }] },\n { source: 'Description_FR', target: 'name_fr', transforms: [{ op: 'trim' }] },\n { source: 'Description_IT', target: 'name_it', transforms: [{ op: 'trim' }] },\n { source: 'Description_NL', target: 'name_nl', transforms: [{ op: 'trim' }] },\n { source: 'Description_PT', target: 'name_pt', transforms: [{ op: 'trim' }] },\n { source: 'Description_RO', target: 'name_ro', transforms: [{ op: 'trim' }] },\n ],\n}\n\n/**\n * The carmaker transform — built once at module load from the static mapping. The\n * build validates every target against the `parts` whitelist (throws on a typo).\n */\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = buildMappingTransform(\n CARMAKER_MAPPING,\n partsSchema,\n)\n"],"mappings":"iGAgDA,MAAa,EAA+B,yBAO/B,EAA2B,EAc3B,EAAkC,CAC7C,aAAc,QACd,aAAc,CAAE,QAAS,IAAK,UAAW,GAAI,EAC7C,UAAW,CACT,CAAE,OAAQ,cAAe,OAAQ,OAAQ,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACtE,CAAE,OAAQ,oBAAqB,OAAQ,YAAa,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EAC3F,CAAE,OAAQ,aAAc,OAAQ,cAAe,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EACtF,CAAE,OAAQ,aAAc,OAAQ,WAAY,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACzE,CAAE,OAAQ,UAAW,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACrE,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,CAC9E,CACF,EAMa,EAAqD,EAChE,EACA,CACF"}
@@ -0,0 +1,59 @@
1
+ import { i as RowTransform } from "./transform-WeiiLdYP.mjs";
2
+ import { n as MappingConfig } from "./engine-DiM4r2Vj.mjs";
3
+ import { t as PartsParams } from "./parts-schema-DOWhBJYh.mjs";
4
+ import { PartsDocument } from "@murumets-ee/search-elasticsearch";
5
+
6
+ //#region src/transforms/carmaker-feed.d.ts
7
+ /** Stable name under which this transform is registered. */
8
+ declare const CARMAKER_FEED_TRANSFORM_NAME = "commerce:carmaker-feed";
9
+ /**
10
+ * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.
11
+ * This IS the parts schema's params (re-exported), so brand/supplier injection +
12
+ * the optional `supplierCodePrefix` strip validate identically everywhere.
13
+ */
14
+ declare const carmakerFeedParamsSchema: import("zod").ZodObject<{
15
+ brandId: import("zod").ZodString;
16
+ brandSlug: import("zod").ZodString;
17
+ supplierId: import("zod").ZodString;
18
+ supplierDisplayName: import("zod").ZodString;
19
+ importBatchId: import("zod").ZodString;
20
+ supplierCodePrefix: import("zod").ZodOptional<import("zod").ZodString>;
21
+ discountGroups: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodNumber>>;
22
+ }, "strip", import("zod").ZodTypeAny, {
23
+ supplierId: string;
24
+ brandId: string;
25
+ brandSlug: string;
26
+ supplierDisplayName: string;
27
+ importBatchId: string;
28
+ supplierCodePrefix?: string | undefined;
29
+ discountGroups?: Record<string, number> | undefined;
30
+ }, {
31
+ supplierId: string;
32
+ brandId: string;
33
+ brandSlug: string;
34
+ supplierDisplayName: string;
35
+ importBatchId: string;
36
+ supplierCodePrefix?: string | undefined;
37
+ discountGroups?: Record<string, number> | undefined;
38
+ }>;
39
+ type CarmakerFeedParams = PartsParams;
40
+ /**
41
+ * The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every
42
+ * column carries a `trim` op; the two price columns use `europeanNumber`. The code
43
+ * column is mapped RAW — the parts schema strips the supplier prefix (from params)
44
+ * and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.
45
+ *
46
+ * Exported (not module-local) so a consumer building a real `import_mapping` row
47
+ * for a GIGA brand — which needs a `stripPrefix` transform baked into the `code`
48
+ * column instead of the legacy path's `params.supplierCodePrefix` — can start from
49
+ * this exact layout rather than re-deriving it (see commerce's `seed.ts`).
50
+ */
51
+ declare const CARMAKER_MAPPING: MappingConfig;
52
+ /**
53
+ * The carmaker transform — built once at module load from the static mapping. The
54
+ * build validates every target against the `parts` whitelist (throws on a typo).
55
+ */
56
+ declare const carmakerFeedTransform: RowTransform<PartsDocument>;
57
+ //#endregion
58
+ export { carmakerFeedTransform as a, carmakerFeedParamsSchema as i, CARMAKER_MAPPING as n, CarmakerFeedParams as r, CARMAKER_FEED_TRANSFORM_NAME as t };
59
+ //# sourceMappingURL=carmaker-feed-_QVQFLUl.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"carmaker-feed-_QVQFLUl.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;;cAgDa,4BAAA;;;;;;cAOA,wBAAA,gBAAwB,SAAA;;;;;;;;;;;;;;;mBAAoB,MAAA;AAAA;;;;;;;mBAAA,MAAA;AAAA;AAAA,KAC7C,kBAAA,GAAqB,WAAW;;;;;;;;;;;;cAa/B,gBAAA,EAAkB,aAoB9B;;;;;cAMY,qBAAA,EAAuB,YAAY,CAAC,aAAA"}
@@ -1,2 +1,2 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-DIzWppgJ.mjs`).then(e=>e.d),v=g(_).getTable(),y=m(h),b=y.key,x=y.bucket,S=y.createdAt;if(!b||!x||!S)throw Error(`cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)`);let C=m(v),w=C.filePath,T=C.id;if(!w||!T)throw Error(`cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)`);let E=new Date(Date.now()-s*60*60*1e3),D=await o.select({key:b}).from(h).leftJoin(v,d(w,b)).where(l(d(x,r),p(S,E),f(T))).orderBy(u(S)).limit(c);if(D.length===0)return a.debug({bucket:r,ttlHours:s},`No orphaned imports uploads found`),0;let O=await t(),k=0;for(let e of D){let t=e.key;try{await O.delete(t),k++}catch(e){a.warn({key:t,err:e},`Failed to delete orphaned imports upload`)}}return a.info({cleaned:k,total:D.length,bucket:r,ttlHours:s},`Cleaned up orphaned imports uploads`),k}const o=[`succeeded`,`failed`,`cancelled`];async function s(e){let{app:n,bucket:r,logger:a}=e,s=n.db.readWrite,{ttlOrRetention:c,limit:l}=i({ttlOrRetention:e.retentionDays,ttlLabel:`retentionDays`,...e.limit!==void 0&&{limit:e.limit}}),{and:u,asc:d,eq:f,getTableColumns:p,inArray:m,lt:h}=await import(`drizzle-orm`),{toolkitFiles:g}=await import(`@murumets-ee/storage`),{createAdminClient:_}=await import(`@murumets-ee/core/clients`),{ImportRun:v}=await import(`./transform-DIzWppgJ.mjs`).then(e=>e.d),y=_(v).getTable(),b=p(g),x=b.key,S=b.bucket;if(!x||!S)throw Error(`cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)`);let C=p(y),w=C.filePath,T=C.status,E=C.finishedAt;if(!w||!T||!E)throw Error(`cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)`);let D=new Date(Date.now()-c*24*60*60*1e3),O=await s.selectDistinctOn([x],{key:x}).from(g).innerJoin(y,f(w,x)).where(u(f(S,r),m(T,[...o]),h(E,D))).orderBy(d(x),d(E)).limit(l);if(O.length===0)return a.debug({bucket:r,retentionDays:c},`No processed-imports uploads ready for cleanup`),0;let k=await t(),A=0;for(let e of O){let t=e.key;try{await k.delete(t),A++}catch(e){a.warn({key:t,err:e},`Failed to delete processed-imports upload`)}}return a.info({cleaned:A,total:O.length,bucket:r,retentionDays:c},`Cleaned up processed imports uploads`),A}export{s as n,n as r,a as t};
2
- //# sourceMappingURL=cleanup-CHjDgvKT.mjs.map
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-Cu5YEoOl.mjs`).then(e=>e.d),v=g(_).getTable(),y=m(h),b=y.key,x=y.bucket,S=y.createdAt;if(!b||!x||!S)throw Error(`cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)`);let C=m(v),w=C.filePath,T=C.id;if(!w||!T)throw Error(`cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)`);let E=new Date(Date.now()-s*60*60*1e3),D=await o.select({key:b}).from(h).leftJoin(v,d(w,b)).where(l(d(x,r),p(S,E),f(T))).orderBy(u(S)).limit(c);if(D.length===0)return a.debug({bucket:r,ttlHours:s},`No orphaned imports uploads found`),0;let O=await t(),k=0;for(let e of D){let t=e.key;try{await O.delete(t),k++}catch(e){a.warn({key:t,err:e},`Failed to delete orphaned imports upload`)}}return a.info({cleaned:k,total:D.length,bucket:r,ttlHours:s},`Cleaned up orphaned imports uploads`),k}const o=[`succeeded`,`failed`,`cancelled`];async function s(e){let{app:n,bucket:r,logger:a}=e,s=n.db.readWrite,{ttlOrRetention:c,limit:l}=i({ttlOrRetention:e.retentionDays,ttlLabel:`retentionDays`,...e.limit!==void 0&&{limit:e.limit}}),{and:u,asc:d,eq:f,getTableColumns:p,inArray:m,lt:h}=await import(`drizzle-orm`),{toolkitFiles:g}=await import(`@murumets-ee/storage`),{createAdminClient:_}=await import(`@murumets-ee/core/clients`),{ImportRun:v}=await import(`./transform-Cu5YEoOl.mjs`).then(e=>e.d),y=_(v).getTable(),b=p(g),x=b.key,S=b.bucket;if(!x||!S)throw Error(`cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)`);let C=p(y),w=C.filePath,T=C.status,E=C.finishedAt;if(!w||!T||!E)throw Error(`cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)`);let D=new Date(Date.now()-c*24*60*60*1e3),O=await s.selectDistinctOn([x],{key:x}).from(g).innerJoin(y,f(w,x)).where(u(f(S,r),m(T,[...o]),h(E,D))).orderBy(d(x),d(E)).limit(l);if(O.length===0)return a.debug({bucket:r,retentionDays:c},`No processed-imports uploads ready for cleanup`),0;let k=await t(),A=0;for(let e of O){let t=e.key;try{await k.delete(t),A++}catch(e){a.warn({key:t,err:e},`Failed to delete processed-imports upload`)}}return a.info({cleaned:A,total:O.length,bucket:r,retentionDays:c},`Cleaned up processed imports uploads`),A}export{s as n,n as r,a as t};
2
+ //# sourceMappingURL=cleanup-DDljBe0Z.mjs.map