@murumets-ee/imports 0.28.0 → 0.29.1
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transforms.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"transforms.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;cAqCa,4BAAA;;;;;;;;cASA,wBAAA,EAAwB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;EA+BzB;;;;;;;;;;;;;;;;;;;;;;KAAA,kBAAA,GAAqB,CAAA,CAAE,KAAK,QAAQ,wBAAA;AAAA,cAoFnC,qBAAA,EAAuB,YAAY,CAAC,aAAA"}
|
package/dist/transforms.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{z as e}from"zod";const t=`commerce:carmaker-feed`,n=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),importBatchId:e.string().uuid()}),r=new WeakMap;function i(e){let t=r.get(e);if(t)return t;let i=n.parse(e);return r.set(e,i),i}const a=/[\s-]/g;function o(e){return e.toUpperCase().replace(a,``)}const s=/^-?\d+(\.\d+)?$/;function c(e){let t=e.includes(`,`),n=e.includes(`.`),r;if(t&&n)r=e.replace(/\./g,``).replace(`,`,`.`);else if(t)r=e.replace(`,`,`.`);else if(n)return NaN;else r=e;return s.test(r)?Number.parseFloat(r):NaN}function l(e){if(e===void 0)return null;let t=e.trim();return t===``?null:t}const u=async(e,t)=>{let n=i(t.params),r=e.ArticleName?.trim();if(!r)return{kind:`error`,error:{errorType:`missing_article_name`,message:`ArticleName is empty`,field:`ArticleName`}};let a=(e.CurrencyCd??``).trim();if(a!==`EUR`)return{kind:`error`,error:{errorType:`unsupported_currency`,message:`Currency "${a}" not supported (PoC is EUR-only)`,field:`CurrencyCd`}};let s=o(n.supplierCodePrefix&&r.startsWith(n.supplierCodePrefix)?r.slice(n.supplierCodePrefix.length):r);if(!s)return{kind:`error`,error:{errorType:`empty_normalized_code`,message:`Code "${r}" is empty after normalization`,field:`ArticleName`}};let u=e[`NetPrice/Discount`]??``,d=c(u);if(Number.isNaN(d))return{kind:`error`,error:{errorType:`invalid_net_price`,message:`Could not parse "${u}" as a number`,field:`NetPrice/Discount`}};let f=(e.GrossPrice??``).trim(),p=null;if(f!==``&&f!==`NA`){let e=c(f);if(Number.isNaN(e))return{kind:`error`,error:{errorType:`invalid_gross_price`,message:`Could not parse "${f}" as a number`,field:`GrossPrice`}};p=e}let m=`${s}__${n.supplierId}`;return{kind:`success`,id:m,doc:{doc_id:m,code:r,code_normalized:s,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:d,gross_price_eur:p,currency:`EUR`,barcode:l(e.Barcode),name_de:l(e.Description_DE),name_en:l(e.Description_EN),name_es:l(e.Description_ES),name_fr:l(e.Description_FR),name_it:l(e.Description_IT),name_nl:l(e.Description_NL),name_pt:l(e.Description_PT),description1:l(e.Description1),description2:l(e.Description2),import_batch_id:n.importBatchId,imported_at:new Date().toISOString()}}};export{t as CARMAKER_FEED_TRANSFORM_NAME,n as carmakerFeedParamsSchema,u as carmakerFeedTransform};
|
|
1
|
+
import{z as e}from"zod";const t=`commerce:carmaker-feed`,n=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),importBatchId:e.string().uuid()}),r=new WeakMap;function i(e){let t=r.get(e);if(t)return t;let i=n.parse(e);return r.set(e,i),i}const a=/[\s-]/g;function o(e){return e.toUpperCase().replace(a,``)}const s=/^-?\d+(\.\d+)?$/;function c(e){let t=e.includes(`,`),n=e.includes(`.`),r;if(t&&n)r=e.replace(/\./g,``).replace(`,`,`.`);else if(t)r=e.replace(`,`,`.`);else if(n)return NaN;else r=e;return s.test(r)?Number.parseFloat(r):NaN}function l(e){if(e===void 0)return null;let t=e.trim();return t===``?null:t}const u=async(e,t)=>{let n=i(t.params),r=e.ArticleName?.trim();if(!r)return{kind:`error`,error:{errorType:`missing_article_name`,message:`ArticleName is empty`,field:`ArticleName`}};let a=(e.CurrencyCd??``).trim();if(a!==`EUR`)return{kind:`error`,error:{errorType:`unsupported_currency`,message:`Currency "${a}" not supported (PoC is EUR-only)`,field:`CurrencyCd`}};let s=o(n.supplierCodePrefix&&r.startsWith(n.supplierCodePrefix)?r.slice(n.supplierCodePrefix.length):r);if(!s)return{kind:`error`,error:{errorType:`empty_normalized_code`,message:`Code "${r}" is empty after normalization`,field:`ArticleName`}};let u=e[`NetPrice/Discount`]??``,d=c(u);if(Number.isNaN(d))return{kind:`error`,error:{errorType:`invalid_net_price`,message:`Could not parse "${u}" as a number`,field:`NetPrice/Discount`}};let f=(e.GrossPrice??``).trim(),p=null;if(f!==``&&f!==`NA`){let e=c(f);if(Number.isNaN(e))return{kind:`error`,error:{errorType:`invalid_gross_price`,message:`Could not parse "${f}" as a number`,field:`GrossPrice`}};p=e}let m=`${s}__${n.supplierId}`;return{kind:`success`,id:m,doc:{doc_id:m,code:r,code_normalized:s,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:d,gross_price_eur:p,currency:`EUR`,barcode:l(e.Barcode),name_de:l(e.Description_DE),name_en:l(e.Description_EN),name_es:l(e.Description_ES),name_fr:l(e.Description_FR),name_it:l(e.Description_IT),name_nl:l(e.Description_NL),name_pt:l(e.Description_PT),description1:l(e.Description1),description2:l(e.Description2),name_ro:null,list_price_minor:null,purchase_price_minor:null,discount_group:null,superseding_code:null,core_charge_minor:null,core_charge_currency:null,core_charge_refundable:null,core_charge_tax_class:null,core_charge_tax_inclusive:null,import_batch_id:n.importBatchId,imported_at:new Date().toISOString()}}};export{t as CARMAKER_FEED_TRANSFORM_NAME,n as carmakerFeedParamsSchema,u as carmakerFeedTransform};
|
|
2
2
|
//# sourceMappingURL=transforms.mjs.map
|
package/dist/transforms.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transforms.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — converts the giga-test 14-column tab-\n * delimited row into a `PartsDocument` for the parts ES alias.\n *\n * Per PLAN-ECOMMERCE.md PR 8 (PoC scope): ONE generic transform\n * covering every supplier whose feed matches this header. Per-job\n * `(brandId, brandSlug, supplierId, supplierDisplayName, supplierCodePrefix?)`\n * config is fully resolved at enqueue time (PR 8a's route looks up\n * the slug + display_name from the UUIDs before enqueuing) so the\n * transform is a pure function with no DB lookups per row.\n *\n * Decisions enforced:\n * - **D2** Code = bare manufacturer code, normalized. Strip the\n * supplier prefix (e.g. `ME-` for Mercedes, `TO-` for Toyota) if\n * present, then uppercase + dash/space-stripped.\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>` so re-running\n * the same feed idempotently overwrites instead of duplicating.\n * - **D9** Single currency (EUR). Non-EUR rows are rejected as\n * row-level errors — at the PoC stage, only EUR data is loaded.\n * - **D21** Bulk-write path bypasses entity hooks. Per-batch audit\n * lives on the surrounding `import_run`, NOT per-row.\n *\n * **`*S` rows pass through unchanged.** The replacement-code side\n * effect (R1 / D11) is deferred to PR 5e — the row goes into ES\n * with its `*S` suffix preserved, no out-of-band write to\n * `replacement_group` / `replacement_member`.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { z } from 'zod'\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 configuration. PR 8a's upload route validates the\n * brandId/supplierId UUIDs, looks up the brand slug + supplier\n * display_name from the commerce entities, and writes the resolved\n * shape into `import_run.params` before enqueuing — that keeps\n * this transform free of DB I/O.\n */\nexport const carmakerFeedParamsSchema = z.object({\n /** UUID of the brand. Forwarded to ES doc per D24 (typed FK). */\n brandId: z.string().uuid(),\n /** Brand slug for facet labels — pre-resolved by the route. */\n brandSlug: z.string().min(1).max(64),\n /** UUID of the supplier. */\n supplierId: z.string().uuid(),\n /**\n * Customer-facing supplier alias (D24 anti-disintermediation) —\n * pre-resolved. NEVER `supplier.name` (the legal name).\n */\n supplierDisplayName: z.string().min(1).max(255),\n /**\n * Optional supplier-specific code prefix to strip from\n * `ArticleName` before normalization. e.g. `'ME-'` for Mercedes,\n * `'TO-'` for Toyota. When absent, the full ArticleName is\n * normalized as-is.\n */\n supplierCodePrefix: z\n .string()\n .max(8)\n .regex(/^[A-Z0-9-]+$/, 'must be uppercase alphanumeric or \"-\"')\n .optional(),\n /**\n * Stable identifier for this run's batch — written verbatim to\n * `PartsDocument.import_batch_id` so D20 source-attribution holds\n * for the rare cases an operator needs to roll back a single feed\n * import in ES.\n */\n importBatchId: z.string().uuid(),\n})\nexport type CarmakerFeedParams = z.infer<typeof carmakerFeedParamsSchema>\n\n/**\n * Run-level memoization of the parsed params. The runner passes the\n * same `ctx.params` reference for every row of a run, so this caches\n * the Zod parse result by reference identity. Garbage collection\n * clears the entry naturally once the run finishes and the params\n * object goes out of scope.\n *\n * Failed parses are NOT cached — a malformed-params run throws on\n * every row, but the ErrorTracker collapses identical Zod errors\n * into one bucket so the noise is bounded.\n */\nconst paramsCache = new WeakMap<object, CarmakerFeedParams>()\n\nfunction resolveParams(raw: Record<string, unknown>): CarmakerFeedParams {\n const cached = paramsCache.get(raw)\n if (cached) return cached\n const parsed = carmakerFeedParamsSchema.parse(raw)\n paramsCache.set(raw, parsed)\n return parsed\n}\n\nconst NORMALIZE_CODE_RE = /[\\s-]/g\n\n/** Uppercase + dash/whitespace-stripped (per D2). */\nfunction normalizeCode(s: string): string {\n return s.toUpperCase().replace(NORMALIZE_CODE_RE, '')\n}\n\n/** Validates the post-cleanup numeric form before accepting it. */\nconst STRICT_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\n\n/**\n * Parse a European-formatted decimal: `,` is the decimal separator, `.`\n * is the (optional) thousands separator. Returns `NaN` on unparseable\n * input — the caller surfaces that as a row-level error rather than\n * silently coercing to `0` or to a wrong value.\n *\n * Cases handled:\n * - `'1,98'` → `1.98` (decimal only)\n * - `'1.234,56'` → `1234.56` (thousands + decimal)\n * - `'5'` → `5` (integer)\n * - `'-5,00'` → `-5` (signed)\n *\n * Cases REJECTED (return `NaN`):\n * - `'1.98'` — bare-dot decimal is ambiguous in this format. The\n * carmaker feeds are documented as comma-decimal; a `.` without a\n * `,` could mean US-format decimal (`1.98`) OR a thousands marker\n * without decimal part. Rather than guess, reject. If a future\n * supplier sends US-format prices, that's a per-supplier transform\n * decision, not a quiet promotion of every dot.\n * - `'1,98EUR'`, `'1,98 '`, `'1,98abc'` — trailing junk. `parseFloat`\n * would silently accept the leading numeric prefix; the strict regex\n * post-check ensures the entire string is a clean number.\n * - `'1,2,3'` — multiple commas.\n * - `''`, `'NA'`, anything else non-numeric.\n */\nfunction parseEuropeanNumber(s: string): number {\n const hasComma = s.includes(',')\n const hasDot = s.includes('.')\n let cleaned: string\n if (hasComma && hasDot) {\n // European thousands+decimal: strip every `.`, then swap the (sole)\n // `,` for `.`. The strict regex below catches multi-comma input.\n cleaned = s.replace(/\\./g, '').replace(',', '.')\n } else if (hasComma) {\n cleaned = s.replace(',', '.')\n } else if (hasDot) {\n return Number.NaN\n } else {\n cleaned = s\n }\n if (!STRICT_NUMBER_RE.test(cleaned)) return Number.NaN\n return Number.parseFloat(cleaned)\n}\n\n/** Trim + collapse `''` and `undefined` to `null` (matches the `PartsDocument.* | null` shape). */\nfunction emptyToNull(s: string | undefined): string | null {\n if (s === undefined) return null\n const trimmed = s.trim()\n return trimmed === '' ? null : trimmed\n}\n\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = async (row, ctx) => {\n const params = resolveParams(ctx.params)\n\n // 1. ArticleName is the only required cell that drives both `code`\n // and the ES `_id`. An empty value cannot be normalized; reject\n // rather than emit a doc with `_id = '__<supplierId>'`.\n const articleName = row['ArticleName']?.trim()\n if (!articleName) {\n return {\n kind: 'error',\n error: {\n errorType: 'missing_article_name',\n message: 'ArticleName is empty',\n field: 'ArticleName',\n },\n }\n }\n\n // 2. Currency gate (D9). The PoC is EUR-only — non-EUR data is not\n // converted, it's rejected. When PR 5c lands the price-application\n // layer with FxRate, this gate widens (or moves to the indexer).\n const currency = (row['CurrencyCd'] ?? '').trim()\n if (currency !== 'EUR') {\n return {\n kind: 'error',\n error: {\n errorType: 'unsupported_currency',\n message: `Currency \"${currency}\" not supported (PoC is EUR-only)`,\n field: 'CurrencyCd',\n },\n }\n }\n\n // 3. Strip supplier prefix if configured + present, then normalize.\n // A row whose ArticleName doesn't start with the configured prefix\n // is normalized as-is — this matches feeds that mix prefixed and\n // bare codes (rare but observed).\n const stripped =\n params.supplierCodePrefix && articleName.startsWith(params.supplierCodePrefix)\n ? articleName.slice(params.supplierCodePrefix.length)\n : articleName\n const codeNormalized = normalizeCode(stripped)\n if (!codeNormalized) {\n return {\n kind: 'error',\n error: {\n errorType: 'empty_normalized_code',\n message: `Code \"${articleName}\" is empty after normalization`,\n field: 'ArticleName',\n },\n }\n }\n\n // 4. Parse prices. Asymmetric: NetPrice is required (empty / unparseable\n // fails as `invalid_net_price`); GrossPrice is optional (`'NA'` or\n // empty → null, only a non-empty unparseable value fails). Required\n // because every parts row carries a wholesale price; gross is a\n // derived retail figure the supplier doesn't always populate.\n const netPriceRaw = row['NetPrice/Discount'] ?? ''\n const netPrice = parseEuropeanNumber(netPriceRaw)\n if (Number.isNaN(netPrice)) {\n return {\n kind: 'error',\n error: {\n errorType: 'invalid_net_price',\n message: `Could not parse \"${netPriceRaw}\" as a number`,\n field: 'NetPrice/Discount',\n },\n }\n }\n\n const grossRaw = (row['GrossPrice'] ?? '').trim()\n let grossPrice: number | null = null\n if (grossRaw !== '' && grossRaw !== 'NA') {\n const g = parseEuropeanNumber(grossRaw)\n if (Number.isNaN(g)) {\n return {\n kind: 'error',\n error: {\n errorType: 'invalid_gross_price',\n message: `Could not parse \"${grossRaw}\" as a number`,\n field: 'GrossPrice',\n },\n }\n }\n grossPrice = g\n }\n\n // 5. Build the ES doc. `imported_at` is generated per-row rather\n // than per-batch so the document carries a usable timestamp even\n // when the batch boundary is invisible to the reader.\n const docId = `${codeNormalized}__${params.supplierId}`\n const doc: PartsDocument = {\n doc_id: docId,\n code: articleName,\n code_normalized: codeNormalized,\n brand_id: params.brandId,\n brand_slug: params.brandSlug,\n supplier_id: params.supplierId,\n supplier_display_name: params.supplierDisplayName,\n net_price_eur: netPrice,\n gross_price_eur: grossPrice,\n currency: 'EUR',\n barcode: emptyToNull(row['Barcode']),\n name_de: emptyToNull(row['Description_DE']),\n name_en: emptyToNull(row['Description_EN']),\n name_es: emptyToNull(row['Description_ES']),\n name_fr: emptyToNull(row['Description_FR']),\n name_it: emptyToNull(row['Description_IT']),\n name_nl: emptyToNull(row['Description_NL']),\n name_pt: emptyToNull(row['Description_PT']),\n description1: emptyToNull(row['Description1']),\n description2: emptyToNull(row['Description2']),\n import_batch_id: params.importBatchId,\n imported_at: new Date().toISOString(),\n }\n\n return { kind: 'success', id: docId, doc }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"transforms.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — converts the giga-test 14-column tab-\n * delimited row into a `PartsDocument` for the parts ES alias.\n *\n * Per PLAN-ECOMMERCE.md PR 8 (PoC scope): ONE generic transform\n * covering every supplier whose feed matches this header. Per-job\n * `(brandId, brandSlug, supplierId, supplierDisplayName, supplierCodePrefix?)`\n * config is fully resolved at enqueue time (PR 8a's route looks up\n * the slug + display_name from the UUIDs before enqueuing) so the\n * transform is a pure function with no DB lookups per row.\n *\n * Decisions enforced:\n * - **D2** Code = bare manufacturer code, normalized. Strip the\n * supplier prefix (e.g. `ME-` for Mercedes, `TO-` for Toyota) if\n * present, then uppercase + dash/space-stripped.\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>` so re-running\n * the same feed idempotently overwrites instead of duplicating.\n * - **D9** Single currency (EUR). Non-EUR rows are rejected as\n * row-level errors — at the PoC stage, only EUR data is loaded.\n * - **D21** Bulk-write path bypasses entity hooks. Per-batch audit\n * lives on the surrounding `import_run`, NOT per-row.\n *\n * **`*S` rows pass through unchanged** in this legacy transform — the\n * row goes into ES with its `*S` suffix preserved, no side effect.\n * Per commerce-demo SD002 (resolving PLAN-ECOMMERCE D11), `*S` marks a\n * **core charge** (NOT a replacement code): a `<code>*S` row's price is\n * the parent part's core-charge deposit. That routing lands when the\n * configurable mapping engine (commerce-demo PR2) replaces this transform\n * and the core-charge ingestion (PR7) consumes it; replacement codes come\n * from the dealer superseding-code column (D003), not `*S`.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { z } from 'zod'\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 configuration. PR 8a's upload route validates the\n * brandId/supplierId UUIDs, looks up the brand slug + supplier\n * display_name from the commerce entities, and writes the resolved\n * shape into `import_run.params` before enqueuing — that keeps\n * this transform free of DB I/O.\n */\nexport const carmakerFeedParamsSchema = z.object({\n /** UUID of the brand. Forwarded to ES doc per D24 (typed FK). */\n brandId: z.string().uuid(),\n /** Brand slug for facet labels — pre-resolved by the route. */\n brandSlug: z.string().min(1).max(64),\n /** UUID of the supplier. */\n supplierId: z.string().uuid(),\n /**\n * Customer-facing supplier alias (D24 anti-disintermediation) —\n * pre-resolved. NEVER `supplier.name` (the legal name).\n */\n supplierDisplayName: z.string().min(1).max(255),\n /**\n * Optional supplier-specific code prefix to strip from\n * `ArticleName` before normalization. e.g. `'ME-'` for Mercedes,\n * `'TO-'` for Toyota. When absent, the full ArticleName is\n * normalized as-is.\n */\n supplierCodePrefix: z\n .string()\n .max(8)\n .regex(/^[A-Z0-9-]+$/, 'must be uppercase alphanumeric or \"-\"')\n .optional(),\n /**\n * Stable identifier for this run's batch — written verbatim to\n * `PartsDocument.import_batch_id` so D20 source-attribution holds\n * for the rare cases an operator needs to roll back a single feed\n * import in ES.\n */\n importBatchId: z.string().uuid(),\n})\nexport type CarmakerFeedParams = z.infer<typeof carmakerFeedParamsSchema>\n\n/**\n * Run-level memoization of the parsed params. The runner passes the\n * same `ctx.params` reference for every row of a run, so this caches\n * the Zod parse result by reference identity. Garbage collection\n * clears the entry naturally once the run finishes and the params\n * object goes out of scope.\n *\n * Failed parses are NOT cached — a malformed-params run throws on\n * every row, but the ErrorTracker collapses identical Zod errors\n * into one bucket so the noise is bounded.\n */\nconst paramsCache = new WeakMap<object, CarmakerFeedParams>()\n\nfunction resolveParams(raw: Record<string, unknown>): CarmakerFeedParams {\n const cached = paramsCache.get(raw)\n if (cached) return cached\n const parsed = carmakerFeedParamsSchema.parse(raw)\n paramsCache.set(raw, parsed)\n return parsed\n}\n\nconst NORMALIZE_CODE_RE = /[\\s-]/g\n\n/** Uppercase + dash/whitespace-stripped (per D2). */\nfunction normalizeCode(s: string): string {\n return s.toUpperCase().replace(NORMALIZE_CODE_RE, '')\n}\n\n/** Validates the post-cleanup numeric form before accepting it. */\nconst STRICT_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\n\n/**\n * Parse a European-formatted decimal: `,` is the decimal separator, `.`\n * is the (optional) thousands separator. Returns `NaN` on unparseable\n * input — the caller surfaces that as a row-level error rather than\n * silently coercing to `0` or to a wrong value.\n *\n * Cases handled:\n * - `'1,98'` → `1.98` (decimal only)\n * - `'1.234,56'` → `1234.56` (thousands + decimal)\n * - `'5'` → `5` (integer)\n * - `'-5,00'` → `-5` (signed)\n *\n * Cases REJECTED (return `NaN`):\n * - `'1.98'` — bare-dot decimal is ambiguous in this format. The\n * carmaker feeds are documented as comma-decimal; a `.` without a\n * `,` could mean US-format decimal (`1.98`) OR a thousands marker\n * without decimal part. Rather than guess, reject. If a future\n * supplier sends US-format prices, that's a per-supplier transform\n * decision, not a quiet promotion of every dot.\n * - `'1,98EUR'`, `'1,98 '`, `'1,98abc'` — trailing junk. `parseFloat`\n * would silently accept the leading numeric prefix; the strict regex\n * post-check ensures the entire string is a clean number.\n * - `'1,2,3'` — multiple commas.\n * - `''`, `'NA'`, anything else non-numeric.\n */\nfunction parseEuropeanNumber(s: string): number {\n const hasComma = s.includes(',')\n const hasDot = s.includes('.')\n let cleaned: string\n if (hasComma && hasDot) {\n // European thousands+decimal: strip every `.`, then swap the (sole)\n // `,` for `.`. The strict regex below catches multi-comma input.\n cleaned = s.replace(/\\./g, '').replace(',', '.')\n } else if (hasComma) {\n cleaned = s.replace(',', '.')\n } else if (hasDot) {\n return Number.NaN\n } else {\n cleaned = s\n }\n if (!STRICT_NUMBER_RE.test(cleaned)) return Number.NaN\n return Number.parseFloat(cleaned)\n}\n\n/** Trim + collapse `''` and `undefined` to `null` (matches the `PartsDocument.* | null` shape). */\nfunction emptyToNull(s: string | undefined): string | null {\n if (s === undefined) return null\n const trimmed = s.trim()\n return trimmed === '' ? null : trimmed\n}\n\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = async (row, ctx) => {\n const params = resolveParams(ctx.params)\n\n // 1. ArticleName is the only required cell that drives both `code`\n // and the ES `_id`. An empty value cannot be normalized; reject\n // rather than emit a doc with `_id = '__<supplierId>'`.\n const articleName = row['ArticleName']?.trim()\n if (!articleName) {\n return {\n kind: 'error',\n error: {\n errorType: 'missing_article_name',\n message: 'ArticleName is empty',\n field: 'ArticleName',\n },\n }\n }\n\n // 2. Currency gate (D9). The PoC is EUR-only — non-EUR data is not\n // converted, it's rejected. When PR 5c lands the price-application\n // layer with FxRate, this gate widens (or moves to the indexer).\n const currency = (row['CurrencyCd'] ?? '').trim()\n if (currency !== 'EUR') {\n return {\n kind: 'error',\n error: {\n errorType: 'unsupported_currency',\n message: `Currency \"${currency}\" not supported (PoC is EUR-only)`,\n field: 'CurrencyCd',\n },\n }\n }\n\n // 3. Strip supplier prefix if configured + present, then normalize.\n // A row whose ArticleName doesn't start with the configured prefix\n // is normalized as-is — this matches feeds that mix prefixed and\n // bare codes (rare but observed).\n const stripped =\n params.supplierCodePrefix && articleName.startsWith(params.supplierCodePrefix)\n ? articleName.slice(params.supplierCodePrefix.length)\n : articleName\n const codeNormalized = normalizeCode(stripped)\n if (!codeNormalized) {\n return {\n kind: 'error',\n error: {\n errorType: 'empty_normalized_code',\n message: `Code \"${articleName}\" is empty after normalization`,\n field: 'ArticleName',\n },\n }\n }\n\n // 4. Parse prices. Asymmetric: NetPrice is required (empty / unparseable\n // fails as `invalid_net_price`); GrossPrice is optional (`'NA'` or\n // empty → null, only a non-empty unparseable value fails). Required\n // because every parts row carries a wholesale price; gross is a\n // derived retail figure the supplier doesn't always populate.\n const netPriceRaw = row['NetPrice/Discount'] ?? ''\n const netPrice = parseEuropeanNumber(netPriceRaw)\n if (Number.isNaN(netPrice)) {\n return {\n kind: 'error',\n error: {\n errorType: 'invalid_net_price',\n message: `Could not parse \"${netPriceRaw}\" as a number`,\n field: 'NetPrice/Discount',\n },\n }\n }\n\n const grossRaw = (row['GrossPrice'] ?? '').trim()\n let grossPrice: number | null = null\n if (grossRaw !== '' && grossRaw !== 'NA') {\n const g = parseEuropeanNumber(grossRaw)\n if (Number.isNaN(g)) {\n return {\n kind: 'error',\n error: {\n errorType: 'invalid_gross_price',\n message: `Could not parse \"${grossRaw}\" as a number`,\n field: 'GrossPrice',\n },\n }\n }\n grossPrice = g\n }\n\n // 5. Build the ES doc. `imported_at` is generated per-row rather\n // than per-batch so the document carries a usable timestamp even\n // when the batch boundary is invisible to the reader.\n const docId = `${codeNormalized}__${params.supplierId}`\n const doc: PartsDocument = {\n doc_id: docId,\n code: articleName,\n code_normalized: codeNormalized,\n brand_id: params.brandId,\n brand_slug: params.brandSlug,\n supplier_id: params.supplierId,\n supplier_display_name: params.supplierDisplayName,\n net_price_eur: netPrice,\n gross_price_eur: grossPrice,\n currency: 'EUR',\n barcode: emptyToNull(row['Barcode']),\n name_de: emptyToNull(row['Description_DE']),\n name_en: emptyToNull(row['Description_EN']),\n name_es: emptyToNull(row['Description_ES']),\n name_fr: emptyToNull(row['Description_FR']),\n name_it: emptyToNull(row['Description_IT']),\n name_nl: emptyToNull(row['Description_NL']),\n name_pt: emptyToNull(row['Description_PT']),\n description1: emptyToNull(row['Description1']),\n description2: emptyToNull(row['Description2']),\n // commerce-demo fields (PartsDocument PR1 foundation). This legacy\n // hardcoded transform does NOT populate them — the configurable\n // mapping engine (commerce-demo PR2) supersedes it and fills these from\n // the supplier feed (purchase price PR4, superseding code PR6, core\n // charge PR7, `Description_RO` 15-col drift PR2). Null until then.\n name_ro: null,\n list_price_minor: null,\n purchase_price_minor: null,\n discount_group: null,\n superseding_code: null,\n core_charge_minor: null,\n core_charge_currency: null,\n core_charge_refundable: null,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: null,\n import_batch_id: params.importBatchId,\n imported_at: new Date().toISOString(),\n }\n\n return { kind: 'success', id: docId, doc }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/imports",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,13 +40,13 @@
|
|
|
40
40
|
"csv-parse": "^5.5.3",
|
|
41
41
|
"drizzle-orm": "^0.45.2",
|
|
42
42
|
"zod": "^3.24.1",
|
|
43
|
-
"@murumets-ee/core": "0.
|
|
44
|
-
"@murumets-ee/db": "0.
|
|
45
|
-
"@murumets-ee/entity": "0.
|
|
46
|
-
"@murumets-ee/logging": "0.
|
|
47
|
-
"@murumets-ee/queue": "0.
|
|
48
|
-
"@murumets-ee/search-elasticsearch": "0.
|
|
49
|
-
"@murumets-ee/storage": "0.
|
|
43
|
+
"@murumets-ee/core": "0.29.1",
|
|
44
|
+
"@murumets-ee/db": "0.29.1",
|
|
45
|
+
"@murumets-ee/entity": "0.29.1",
|
|
46
|
+
"@murumets-ee/logging": "0.29.1",
|
|
47
|
+
"@murumets-ee/queue": "0.29.1",
|
|
48
|
+
"@murumets-ee/search-elasticsearch": "0.29.1",
|
|
49
|
+
"@murumets-ee/storage": "0.29.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^20.19.42",
|