From 8ceaa2574d1ecbda195bf04a6906ccd2aa49a8eb Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Mon, 25 May 2026 19:27:35 +0200 Subject: [PATCH 01/10] feat(seed): support Herbatica warehouse stock --- apps/medusa-be/src/scripts/herbatica-seed.ts | 225 ++++++++++++++++-- .../seed/steps/create-inventory-levels.ts | 83 +++++-- .../workflows/seed/steps/create-products.ts | 8 +- .../workflows/seed/workflows/seed-database.ts | 61 +++-- .../src/workflows/seed/workflows/seed-n1.ts | 58 +++-- .../herbatica/herbatica-seed.unit.spec.ts | 174 ++++++++++++++ 6 files changed, 530 insertions(+), 79 deletions(-) diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index 9a3662fa9..2f5e90dbc 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -71,6 +71,12 @@ type ParsedOssTaxRate = { level?: string } +type ParsedStockWarehouse = { + name?: string + quantity?: number + location?: string +} + type ParsedRelatedFile = { url?: string title?: string @@ -107,6 +113,7 @@ type ParsedOfferData = { stockMinimalAmount?: number stockMaximalAmount?: number stockMinSupply?: number + stockWarehouses: ParsedStockWarehouse[] availabilityOutOfStock?: string availabilityInStock?: string imageRef?: string @@ -222,12 +229,16 @@ type CategoryBuildResult = { type BuildResult = { categories: CategorySeedInput[] products: ProductSeedInput[] + stockLocations: SeedDatabaseWorkflowInput["stockLocations"]["locations"] + warnings: string[] stats: { shopItems: number categories: number products: number variants: number hiddenProducts: number + stockLocations: number + warnings: number } } @@ -263,6 +274,14 @@ type BuildVariantsForProductOptions = { referenceDate?: Date } +const DEFAULT_STOCK_LOCATION_NAME = "European Warehouse" +const FALLBACK_SHOPTET_WAREHOUSE_NAME = "Shoptet Warehouse" +const FALLBACK_SHOPTET_WAREHOUSE_ADDRESS = { + address_1: "Shoptet Warehouse", + city: "Unknown", + country_code: "SK", +} + const DEFAULT_PRODUCTS_XML_PATHS = [ resolve(__dirname, "seed-files/productsComplete.xml"), ] as const @@ -1544,6 +1563,70 @@ function normalizeInventoryQuantity(quantity?: number): number { return Math.max(0, Math.trunc(quantity)) } +function resolveWarehouseStockLocationName(warehouse: ParsedStockWarehouse): { + name: string + usedFallback: boolean +} { + const name = normalizeInlineText(warehouse.name) + if (name) { + return { + name, + usedFallback: false, + } + } + + return { + name: FALLBACK_SHOPTET_WAREHOUSE_NAME, + usedFallback: true, + } +} + +function buildWarehouseStockLocationAddress(location?: string): { + city: string + country_code: string + address_1: string +} { + const address = normalizeInlineText(location) + if (!address) { + return { ...FALLBACK_SHOPTET_WAREHOUSE_ADDRESS } + } + + return { + address_1: address, + city: "Unknown", + country_code: "SK", + } +} + +function buildOfferInventoryQuantities(offer: ParsedOfferData): { + quantity?: number + supplier_quantity?: number + locations?: { + stockLocationName: string + quantity: number + }[] +} { + if (offer.stockWarehouses.length === 0) { + const quantity = normalizeInventoryQuantity(offer.stockAmountRaw) + return { + quantity, + locations: [ + { + stockLocationName: DEFAULT_STOCK_LOCATION_NAME, + quantity, + }, + ], + } + } + + return { + locations: offer.stockWarehouses.map((warehouse) => ({ + stockLocationName: resolveWarehouseStockLocationName(warehouse).name, + quantity: normalizeInventoryQuantity(warehouse.quantity), + })), + } +} + function parseParameters( source: string, containerTag: string @@ -1597,12 +1680,26 @@ function parseOssTaxRates(source: string): ParsedOssTaxRate[] { })) } +function parseStockWarehouses(stockRaw?: string): ParsedStockWarehouse[] { + const warehousesRaw = extractFirstElementContent(stockRaw ?? "", "WAREHOUSES") + if (!warehousesRaw) { + return [] + } + + return extractElements(warehousesRaw, "WAREHOUSE").map((warehouse) => ({ + name: extractFirstText(warehouse.inner, "NAME"), + quantity: parseInteger(extractFirstText(warehouse.inner, "VALUE")), + location: extractFirstText(warehouse.inner, "LOCATION"), + })) +} + function parseOfferData( source: string, attributes?: Record ): ParsedOfferData { const stockRaw = extractFirstElementContent(source, "STOCK") const stockAmount = parseInteger(extractFirstText(stockRaw ?? "", "AMOUNT")) + const stockWarehouses = parseStockWarehouses(stockRaw) const stockMinSupply = parseInteger( extractFirstText(source, "STOCK_MIN_SUPPLY") ) @@ -1640,6 +1737,7 @@ function parseOfferData( extractFirstText(stockRaw ?? "", "MAXIMAL_AMOUNT") ), stockMinSupply, + stockWarehouses, availabilityOutOfStock: extractFirstText( source, "AVAILABILITY_OUT_OF_STOCK" @@ -2278,6 +2376,11 @@ function buildVariantMetadata( stock: { amount: offer.stockAmountRaw, location: offer.stockLocation, + warehouses: offer.stockWarehouses.map((warehouse) => ({ + name: warehouse.name, + value: warehouse.quantity, + location: warehouse.location, + })), minimal_amount: offer.stockMinimalAmount, maximal_amount: offer.stockMaximalAmount, min_supply: offer.stockMinSupply, @@ -2421,7 +2524,7 @@ function buildVariantsForProduct({ } const amount = resolveOfferCurrentPrice(topOffer, undefined, referenceDate) const currencyCode = (topOffer.currency ?? "EUR").toLowerCase() - const quantity = normalizeInventoryQuantity(topOffer.stockAmountRaw) + const quantities = buildOfferInventoryQuantities(topOffer) const thumbnail = topOffer.imageRef const optionTitle = DEFAULT_OPTION_TITLE const optionValue = DEFAULT_OPTION_VALUE @@ -2450,9 +2553,7 @@ function buildVariantsForProduct({ images: thumbnail ? [{ url: thumbnail }] : undefined, thumbnail, metadata: buildVariantMetadata(topOffer, undefined, referenceDate), - quantities: { - quantity, - }, + quantities, }, ], } @@ -2534,7 +2635,7 @@ function buildVariantsForProduct({ item.topOffer, referenceDate ) - const quantity = normalizeInventoryQuantity(variant.stockAmountRaw) + const quantities = buildOfferInventoryQuantities(variant) const thumbnail = variant.imageRef const rawEan = normalizeInlineText(variant.ean) const ean = rawEan && !usedEans.has(rawEan) ? rawEan : undefined @@ -2556,9 +2657,7 @@ function buildVariantsForProduct({ images: thumbnail ? [{ url: thumbnail }] : undefined, thumbnail, metadata: buildVariantMetadata(variant, item.topOffer, referenceDate), - quantities: { - quantity, - }, + quantities, } }) @@ -2673,6 +2772,93 @@ function buildProducts( }) } +function getItemOffers(item: ParsedShopItem): ParsedOfferData[] { + return item.variants.length > 0 ? item.variants : [item.topOffer] +} + +function addWarehouseStockLocation( + locationsByName: Map< + string, + SeedDatabaseWorkflowInput["stockLocations"]["locations"][number] + >, + warehouse: ParsedStockWarehouse +): boolean { + const { name, usedFallback } = resolveWarehouseStockLocationName(warehouse) + const address = buildWarehouseStockLocationAddress(warehouse.location) + const existingLocation = locationsByName.get(name) + + if (!existingLocation) { + locationsByName.set(name, { + name, + address, + }) + return usedFallback + } + + if ( + existingLocation.address.address_1 === + FALLBACK_SHOPTET_WAREHOUSE_ADDRESS.address_1 && + address.address_1 !== FALLBACK_SHOPTET_WAREHOUSE_ADDRESS.address_1 + ) { + existingLocation.address = address + } + + return usedFallback +} + +function addDefaultStockLocation( + locationsByName: Map< + string, + SeedDatabaseWorkflowInput["stockLocations"]["locations"][number] + > +) { + locationsByName.set(DEFAULT_STOCK_LOCATION_NAME, { + name: DEFAULT_STOCK_LOCATION_NAME, + address: { + city: "Copenhagen", + country_code: "DK", + address_1: "", + }, + }) +} + +function buildStockLocationsFromItems(items: ParsedShopItem[]): { + locations: SeedDatabaseWorkflowInput["stockLocations"]["locations"] + warnings: string[] +} { + const locationsByName = new Map< + string, + SeedDatabaseWorkflowInput["stockLocations"]["locations"][number] + >() + const warnings: string[] = [] + const offers = items.flatMap(getItemOffers) + const hasSimpleStock = offers.some( + (offer) => offer.stockWarehouses.length === 0 + ) + let missingWarehouseNames = 0 + + for (const warehouse of offers.flatMap((offer) => offer.stockWarehouses)) { + if (addWarehouseStockLocation(locationsByName, warehouse)) { + missingWarehouseNames += 1 + } + } + + if (hasSimpleStock || locationsByName.size === 0) { + addDefaultStockLocation(locationsByName) + } + + if (missingWarehouseNames > 0) { + warnings.push( + `${missingWarehouseNames} Shoptet warehouse stock entries had no warehouse name and were mapped to "${FALLBACK_SHOPTET_WAREHOUSE_NAME}".` + ) + } + + return { + locations: [...locationsByName.values()], + warnings, + } +} + function enforceUniqueVariantSkus(products: ProductSeedInput[]) { const usedSkus = new Set() @@ -2717,6 +2903,8 @@ export function buildSeedInputFromXml( categoryIdToHandle, buildOptions ) + const { locations: stockLocations, warnings } = + buildStockLocationsFromItems(items) enforceUniqueVariantSkus(products) const hiddenProducts = products.filter( (product) => product.status === ProductStatus.DRAFT @@ -2729,12 +2917,16 @@ export function buildSeedInputFromXml( return { categories, products, + stockLocations, + warnings, stats: { shopItems: items.length, categories: categories.length, products: products.length, variants, hiddenProducts, + stockLocations: stockLocations.length, + warnings: warnings.length, }, } } @@ -2819,6 +3011,12 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { logger.info( `Products set to draft due to visibility rules: ${parsed.stats.hiddenProducts}` ) + logger.info( + `Parsed ${parsed.stats.stockLocations} stock locations from stock data` + ) + for (const warning of parsed.warnings) { + logger.warn(warning) + } const regionService = container.resolve(Modules.REGION) const existingRegions = await regionService.listRegions({}) @@ -2914,16 +3112,7 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { countries: [...DEFAULT_COUNTRIES], }, stockLocations: { - locations: [ - { - name: "European Warehouse", - address: { - city: "Copenhagen", - country_code: "DK", - address_1: "", - }, - }, - ], + locations: parsed.stockLocations, }, defaultShippingProfile: { name: "Default Shipping Profile", diff --git a/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts b/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts index 5e6675e30..011ec43d1 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts @@ -17,10 +17,59 @@ export type CreateInventoryLevelsStepInput = { stockLocations: StockLocationDTO[] inventoryItems: { sku: string - quantity: number + quantity?: number + locations?: { + stockLocationName: string + quantity: number + }[] }[] } +type ResolvedInventoryItemInput = + CreateInventoryLevelsStepInput["inventoryItems"][number] & { + id?: string + } + +function buildInventoryLevelsForItem( + inventoryItem: ResolvedInventoryItemInput, + stockLocations: StockLocationDTO[] +): CreateInventoryLevelInput[] { + if (inventoryItem.id === undefined) { + throw new Error(`Inventory item with sku ${inventoryItem.sku} not found.`) + } + const inventoryItemId = inventoryItem.id + + if (inventoryItem.locations?.length) { + return inventoryItem.locations.map((locationQuantity) => { + const stockLocation = stockLocations.find( + (location) => location.name === locationQuantity.stockLocationName + ) + if (!stockLocation) { + throw new Error( + `Stock location "${locationQuantity.stockLocationName}" not found for SKU ${inventoryItem.sku}.` + ) + } + + return { + location_id: stockLocation.id, + stocked_quantity: locationQuantity.quantity, + inventory_item_id: inventoryItemId, + } + }) + } + + if (inventoryItem.quantity === undefined) { + return [] + } + const quantity = inventoryItem.quantity + + return stockLocations.map((stockLocation) => ({ + location_id: stockLocation.id, + stocked_quantity: quantity, + inventory_item_id: inventoryItemId, + })) +} + const CreateInventoryLevelsStepId = "create-inventory-levels-seed-step" export const createInventoryLevelsStep = createStep( CreateInventoryLevelsStepId, @@ -39,27 +88,21 @@ export const createInventoryLevelsStep = createStep( fields: ["id", "sku"], }) - const inventoryItemsMap = input.inventoryItems.map((ii) => ({ - id: inventoryItems.find((i) => i.sku === ii.sku)?.id, - sku: ii.sku, - quantity: ii.quantity, - })) + const inventoryItemsMap = input.inventoryItems.map((ii) => { + const inventoryItem = inventoryItems.find((i) => i.sku === ii.sku) + return { + id: inventoryItem?.id, + sku: ii.sku, + quantity: ii.quantity, + locations: ii.locations, + } + }) const inventoryLevels: CreateInventoryLevelInput[] = [] - for (const stockLocation of input.stockLocations) { - for (const inventoryItem of inventoryItemsMap) { - if (inventoryItem.id === undefined) { - throw new Error( - `Inventory item with sku ${inventoryItem.sku} not found.` - ) - } - const inventoryLevel = { - location_id: stockLocation.id, - stocked_quantity: inventoryItem.quantity, - inventory_item_id: inventoryItem.id, - } - inventoryLevels.push(inventoryLevel) - } + for (const inventoryItem of inventoryItemsMap) { + inventoryLevels.push( + ...buildInventoryLevelsForItem(inventoryItem, input.stockLocations) + ) } logger.info("Checking for existing inventory levels...") diff --git a/apps/medusa-be/src/workflows/seed/steps/create-products.ts b/apps/medusa-be/src/workflows/seed/steps/create-products.ts index 741c0dfc4..41b0974db 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-products.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-products.ts @@ -71,6 +71,10 @@ type ProductInput = { quantities?: { quantity?: number supplier_quantity?: number + locations?: { + stockLocationName: string + quantity: number + }[] } prices?: { amount: number @@ -196,7 +200,9 @@ function findExistingVariant( } } - return (existingProduct.variants ?? []).find((variant) => variant.sku === inputVariant.sku) + return (existingProduct.variants ?? []).find( + (variant) => variant.sku === inputVariant.sku + ) } function processProductProducerInput( diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts index 1f6ed942e..2b1b6e552 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts @@ -1,4 +1,8 @@ -import {createWorkflow, transform, WorkflowResponse,} from "@medusajs/framework/workflows-sdk" +import { + createWorkflow, + transform, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" import * as Steps from "../steps" const SeedDatabaseWorkflowId = "seed-database-workflow" @@ -18,6 +22,38 @@ export type SeedDatabaseWorkflowInput = { products: Steps.CreateProductsStepInput } +function buildInventoryItemsInput( + products: SeedDatabaseWorkflowInput["products"] +): Steps.CreateInventoryLevelsStepInput["inventoryItems"] { + const inventoryItems: Steps.CreateInventoryLevelsStepInput["inventoryItems"] = + [] + + for (const product of products) { + for (const variant of product.variants ?? []) { + if (!variant.sku) { + continue + } + + if (variant.quantities?.locations?.length) { + inventoryItems.push({ + sku: variant.sku, + locations: variant.quantities.locations, + }) + continue + } + + if (variant.quantities?.quantity !== undefined) { + inventoryItems.push({ + sku: variant.sku, + quantity: variant.quantities.quantity, + }) + } + } + } + + return inventoryItems +} + const seedDatabaseWorkflow = createWorkflow( SeedDatabaseWorkflowId, (input: SeedDatabaseWorkflowInput) => { @@ -272,25 +308,10 @@ const seedDatabaseWorkflow = createWorkflow( createStockLocationResult, input, }, - (data) => { - const inventoryItems: Steps.CreateInventoryLevelsStepInput["inventoryItems"] = - [] - for (const p of data.input.products) { - for (const v of p.variants ?? []) { - if (v.quantities?.quantity !== undefined) { - inventoryItems.push({ - sku: v.sku, - quantity: v.quantities.quantity, - }) - } - } - } - - return { - stockLocations: data.createStockLocationResult.result, - inventoryItems, - } - } + (data) => ({ + stockLocations: data.createStockLocationResult.result, + inventoryItems: buildInventoryItemsInput(data.input.products), + }) ) const createInventoryLevelsResult = Steps.createInventoryLevelsStep( diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts index cb1fcbcf9..0380e5ba4 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts @@ -36,6 +36,38 @@ export type SeedN1WorkflowInput = { publishableKey: Steps.CreatePublishableKeyStepInput } +function buildInventoryItemsInput( + products: Steps.CreateProductsStepInput +): Steps.CreateInventoryLevelsStepInput["inventoryItems"] { + const inventoryItems: Steps.CreateInventoryLevelsStepInput["inventoryItems"] = + [] + + for (const product of products) { + for (const variant of product.variants ?? []) { + if (!variant.sku) { + continue + } + + if (variant.quantities?.locations?.length) { + inventoryItems.push({ + sku: variant.sku, + locations: variant.quantities.locations, + }) + continue + } + + if (variant.quantities?.quantity !== undefined) { + inventoryItems.push({ + sku: variant.sku, + quantity: variant.quantities.quantity, + }) + } + } + } + + return inventoryItems +} + const seedN1Workflow = createWorkflow( seedN1WorkflowId, (input: SeedN1WorkflowInput) => { @@ -249,26 +281,12 @@ const seedN1Workflow = createWorkflow( createStockLocationResult, createProductsStepInput, }, - (data) => { - const inventoryItems: Steps.CreateInventoryLevelsStepInput["inventoryItems"] = - [] - for (const p of data.createProductsStepInput) { - for (const v of p.variants ?? []) { - if (!v.sku || v.quantities?.quantity === undefined) { - continue - } - inventoryItems.push({ - sku: v.sku, - quantity: v.quantities.quantity, - }) - } - } - - return { - stockLocations: data.createStockLocationResult.result, - inventoryItems, - } - } + (data) => ({ + stockLocations: data.createStockLocationResult.result, + inventoryItems: buildInventoryItemsInput( + data.createProductsStepInput + ), + }) ) Steps.createInventoryLevelsStep(createInventoryLevelsInput) diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts index ec97f6213..0945e3c04 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts @@ -162,6 +162,178 @@ describe("Herbatica seed promo rebase", () => { }) }) +describe("Herbatica seed stock parsing", () => { + it("preserves simple STOCK/AMOUNT inventory on the default stock location", () => { + const xml = ` + + + Simple stock product + Popis produktu + 9.99 + EUR + 1 + + 7 + + + Doplnky výživy + + + + ` + + const result = buildSeedInputFromXml(xml) + const variant = result.products[0]?.variants?.[0] + + expect(result.stockLocations).toEqual([ + { + name: "European Warehouse", + address: { + city: "Copenhagen", + country_code: "DK", + address_1: "", + }, + }, + ]) + expect(variant?.quantities).toEqual({ + quantity: 7, + locations: [ + { + stockLocationName: "European Warehouse", + quantity: 7, + }, + ], + }) + expect(variant?.metadata).toMatchObject({ + stock: { + amount: 7, + warehouses: [], + }, + }) + }) + + it("preserves STOCK/WAREHOUSES quantities per Shoptet warehouse", () => { + const xml = ` + + + Warehouse stock product + Popis produktu + 9.99 + EUR + 1 + + + + Default stock + 84 + + + Pobočka Čadca + 2 + Čadca branch + + + + + Doplnky výživy + + + + ` + + const result = buildSeedInputFromXml(xml) + const variant = result.products[0]?.variants?.[0] + + expect(result.stockLocations).toEqual([ + { + name: "Default stock", + address: { + address_1: "Shoptet Warehouse", + city: "Unknown", + country_code: "SK", + }, + }, + { + name: "Pobočka Čadca", + address: { + address_1: "Čadca branch", + city: "Unknown", + country_code: "SK", + }, + }, + ]) + expect(variant?.quantities).toEqual({ + locations: [ + { + stockLocationName: "Default stock", + quantity: 84, + }, + { + stockLocationName: "Pobočka Čadca", + quantity: 2, + }, + ], + }) + expect(variant?.metadata).toMatchObject({ + stock: { + warehouses: [ + { + name: "Default stock", + value: 84, + }, + { + name: "Pobočka Čadca", + value: 2, + location: "Čadca branch", + }, + ], + }, + }) + }) + + it("warns and uses fallback stock location name for unnamed warehouses", () => { + const xml = ` + + + Unnamed warehouse product + Popis produktu + 9.99 + EUR + 1 + + + + 5 + + + + + Doplnky výživy + + + + ` + + const result = buildSeedInputFromXml(xml) + const variant = result.products[0]?.variants?.[0] + + expect(result.stockLocations.map((location) => location.name)).toEqual([ + "Shoptet Warehouse", + ]) + expect(result.warnings).toEqual([ + '1 Shoptet warehouse stock entries had no warehouse name and were mapped to "Shoptet Warehouse".', + ]) + expect(variant?.quantities).toEqual({ + locations: [ + { + stockLocationName: "Shoptet Warehouse", + quantity: 5, + }, + ], + }) + }) +}) + describe("Herbatica seed product references", () => { it("keeps raw related product codes and adds resolved handles for published products", () => { const xml = ` @@ -443,6 +615,8 @@ describe("Herbatica committed feed fixtures", () => { products: 4, variants: 5, hiddenProducts: 1, + stockLocations: 1, + warnings: 0, }) expect(result.categories.map((category) => category.handle)).toEqual( expect.arrayContaining([ From fb5f31e29b121aa661f37edffbd107dfb6915ddc Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 16:33:36 +0200 Subject: [PATCH 02/10] feat(seed): import Herbatica price lists --- apps/medusa-be/src/scripts/herbatica-seed.ts | 488 ++++++++++++++-- .../src/workflows/seed/steps/index.ts | 5 +- .../workflows/seed/steps/sync-price-lists.ts | 550 ++++++++++++++++++ .../workflows/seed/workflows/seed-database.ts | 15 + .../herbatica/herbatica-seed.unit.spec.ts | 165 +++++- 5 files changed, 1179 insertions(+), 44 deletions(-) create mode 100644 apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index 2f5e90dbc..227f7e2b6 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -26,6 +26,9 @@ type ProductSeedInput = SeedDatabaseWorkflowInput["products"][number] type VariantSeedInput = NonNullable[number] type ProductOptionSeedInput = NonNullable[number] type CategorySeedInput = SeedDatabaseWorkflowInput["productCategories"][number] +type PriceListsSeedInput = NonNullable +type PriceListPriceSeedInput = + PriceListsSeedInput["overrides"][number]["prices"][number] type XmlElement = { attributes: Record @@ -229,6 +232,7 @@ type CategoryBuildResult = { type BuildResult = { categories: CategorySeedInput[] products: ProductSeedInput[] + priceLists: NonNullable stockLocations: SeedDatabaseWorkflowInput["stockLocations"]["locations"] warnings: string[] stats: { @@ -237,6 +241,9 @@ type BuildResult = { products: number variants: number hiddenProducts: number + overridePriceLists: number + salePriceLists: number + priceListPrices: number stockLocations: number warnings: number } @@ -307,6 +314,11 @@ const DEFAULT_COUNTRIES = [ const MAX_HANDLE_LENGTH = 180 const DEFAULT_OPTION_TITLE = "Variant" const DEFAULT_OPTION_VALUE = "Default" +const DEFAULT_PRICELIST_LABEL = "Default pricelist" +const DEFAULT_SHOPTET_PRICELIST_TITLES = new Set([ + "hlavny cennik", + "default pricelist", +]) const PRODUCT_CONTENT_SECTION_ORDER: ProductContentSectionKey[] = [ "description", "usage", @@ -1335,6 +1347,56 @@ function resolveOfferCurrentPrice( return 0 } +function resolveOfferDefaultPrice( + offer: ParsedOfferData, + fallbackOffer?: ParsedOfferData +): number { + return resolveOfferBasePrice(offer, fallbackOffer) ?? 0 +} + +function priceAmountsEqual(left?: number, right?: number): boolean { + if (left === undefined || right === undefined) { + return left === right + } + + return Math.abs(left - right) < 0.000_001 +} + +function isDefaultPricelistTitle(title?: string): boolean { + const comparable = normalizeComparableText(title) + return !!comparable && DEFAULT_SHOPTET_PRICELIST_TITLES.has(comparable) +} + +function shouldImportActionPrice( + actionPrice?: number, + validUntil?: string, + referenceDate = new Date() +): actionPrice is number { + if (actionPrice === undefined || actionPrice <= 0) { + return false + } + + const until = parseIsoDate(validUntil, true) + return !until || referenceDate <= until +} + +function serializePriceListDate( + value?: string, + endOfDay = false +): string | undefined { + return parseIsoDate(value, endOfDay)?.toISOString() +} + +function buildSalePriceListTitle( + sourceTitle: string, + startsAt?: string, + endsAt?: string +): string { + const windowLabel = + startsAt || endsAt ? `${startsAt ?? "open"}_${endsAt ?? "open"}` : "undated" + return `Herbatica sale - ${sourceTitle} - ${windowLabel}` +} + function rebaseOfferPromotion( offer: ParsedOfferData, buildOptions: ResolvedSeedBuildOptions, @@ -1668,6 +1730,10 @@ function parsePricelists(source: string): ParsedPricelist[] { })) } +function stripNestedPricelists(source: string): string { + return source.replace(/]*)?>[\s\S]*?<\/PRICELISTS>/g, "") +} + function parseOssTaxRates(source: string): ParsedOssTaxRate[] { const ossRatesRaw = extractFirstElementContent(source, "OSS_TAX_RATES") if (!ossRatesRaw) { @@ -1697,35 +1763,46 @@ function parseOfferData( source: string, attributes?: Record ): ParsedOfferData { - const stockRaw = extractFirstElementContent(source, "STOCK") + const scalarSource = stripNestedPricelists(source) + const stockRaw = extractFirstElementContent(scalarSource, "STOCK") const stockAmount = parseInteger(extractFirstText(stockRaw ?? "", "AMOUNT")) const stockWarehouses = parseStockWarehouses(stockRaw) const stockMinSupply = parseInteger( - extractFirstText(source, "STOCK_MIN_SUPPLY") + extractFirstText(scalarSource, "STOCK_MIN_SUPPLY") + ) + const logisticRaw = extractFirstElementContent(scalarSource, "LOGISTIC") + const atypicalRaw = extractFirstElementContent( + scalarSource, + "ATYPICAL_PRODUCT" + ) + const unitOfMeasureRaw = extractFirstElementContent( + scalarSource, + "UNIT_OF_MEASURE" ) - const logisticRaw = extractFirstElementContent(source, "LOGISTIC") - const atypicalRaw = extractFirstElementContent(source, "ATYPICAL_PRODUCT") - const unitOfMeasureRaw = extractFirstElementContent(source, "UNIT_OF_MEASURE") return { variantId: attributes?.id, - code: extractFirstText(source, "CODE"), - ean: extractFirstText(source, "EAN"), - partNumber: extractFirstText(source, "PART_NUMBER"), - productNumber: extractFirstText(source, "PRODUCT_NUMBER"), - plu: extractFirstText(source, "PLU"), - unit: extractFirstText(source, "UNIT"), - currency: extractFirstText(source, "CURRENCY"), - vat: parseNumber(extractFirstText(source, "VAT")), - priceVat: parseNumber(extractFirstText(source, "PRICE_VAT")), - standardPrice: parseNumber(extractFirstText(source, "STANDARD_PRICE")), - actionPrice: parseNumber(extractFirstText(source, "ACTION_PRICE")), - actionPriceFrom: extractFirstText(source, "ACTION_PRICE_FROM"), - actionPriceUntil: extractFirstText(source, "ACTION_PRICE_UNTIL"), - purchasePrice: parseNumber(extractFirstText(source, "PURCHASE_PRICE")), - purchaseVat: parseNumber(extractFirstText(source, "PURCHASE_VAT")), + code: extractFirstText(scalarSource, "CODE"), + ean: extractFirstText(scalarSource, "EAN"), + partNumber: extractFirstText(scalarSource, "PART_NUMBER"), + productNumber: extractFirstText(scalarSource, "PRODUCT_NUMBER"), + plu: extractFirstText(scalarSource, "PLU"), + unit: extractFirstText(scalarSource, "UNIT"), + currency: extractFirstText(scalarSource, "CURRENCY"), + vat: parseNumber(extractFirstText(scalarSource, "VAT")), + priceVat: parseNumber(extractFirstText(scalarSource, "PRICE_VAT")), + standardPrice: parseNumber( + extractFirstText(scalarSource, "STANDARD_PRICE") + ), + actionPrice: parseNumber(extractFirstText(scalarSource, "ACTION_PRICE")), + actionPriceFrom: extractFirstText(scalarSource, "ACTION_PRICE_FROM"), + actionPriceUntil: extractFirstText(scalarSource, "ACTION_PRICE_UNTIL"), + purchasePrice: parseNumber( + extractFirstText(scalarSource, "PURCHASE_PRICE") + ), + purchaseVat: parseNumber(extractFirstText(scalarSource, "PURCHASE_VAT")), purchasePriceInclVat: parseBoolean( - extractFirstText(source, "PURCHASE_PRICE_INCL_VAT") + extractFirstText(scalarSource, "PURCHASE_PRICE_INCL_VAT") ), stockAmount, stockAmountRaw: stockAmount, @@ -1739,32 +1816,39 @@ function parseOfferData( stockMinSupply, stockWarehouses, availabilityOutOfStock: extractFirstText( - source, + scalarSource, "AVAILABILITY_OUT_OF_STOCK" ), - availabilityInStock: extractFirstText(source, "AVAILABILITY_IN_STOCK"), - imageRef: extractFirstText(source, "IMAGE_REF"), - visible: parseBoolean(extractFirstText(source, "VISIBLE"), true), - freeShipping: parseBoolean(extractFirstText(source, "FREE_SHIPPING")), - freeBilling: parseBoolean(extractFirstText(source, "FREE_BILLING")), - decimalCount: parseInteger(extractFirstText(source, "DECIMAL_COUNT")), - negativeAmount: parseBoolean(extractFirstText(source, "NEGATIVE_AMOUNT")), - priceRatio: parseNumber(extractFirstText(source, "PRICE_RATIO")), - minPriceRatio: parseNumber(extractFirstText(source, "MIN_PRICE_RATIO")), + availabilityInStock: extractFirstText( + scalarSource, + "AVAILABILITY_IN_STOCK" + ), + imageRef: extractFirstText(scalarSource, "IMAGE_REF"), + visible: parseBoolean(extractFirstText(scalarSource, "VISIBLE"), true), + freeShipping: parseBoolean(extractFirstText(scalarSource, "FREE_SHIPPING")), + freeBilling: parseBoolean(extractFirstText(scalarSource, "FREE_BILLING")), + decimalCount: parseInteger(extractFirstText(scalarSource, "DECIMAL_COUNT")), + negativeAmount: parseBoolean( + extractFirstText(scalarSource, "NEGATIVE_AMOUNT") + ), + priceRatio: parseNumber(extractFirstText(scalarSource, "PRICE_RATIO")), + minPriceRatio: parseNumber( + extractFirstText(scalarSource, "MIN_PRICE_RATIO") + ), applyLoyaltyDiscount: parseBoolean( - extractFirstText(source, "APPLY_LOYALTY_DISCOUNT"), + extractFirstText(scalarSource, "APPLY_LOYALTY_DISCOUNT"), true ), applyVolumeDiscount: parseBoolean( - extractFirstText(source, "APPLY_VOLUME_DISCOUNT"), + extractFirstText(scalarSource, "APPLY_VOLUME_DISCOUNT"), true ), applyQuantityDiscount: parseBoolean( - extractFirstText(source, "APPLY_QUANTITY_DISCOUNT"), + extractFirstText(scalarSource, "APPLY_QUANTITY_DISCOUNT"), true ), applyDiscountCoupon: parseBoolean( - extractFirstText(source, "APPLY_DISCOUNT_COUPON"), + extractFirstText(scalarSource, "APPLY_DISCOUNT_COUPON"), true ), weightKg: parseNumber(extractFirstText(logisticRaw ?? "", "WEIGHT")), @@ -2522,7 +2606,7 @@ function buildVariantsForProduct({ if (ean) { usedEans.add(ean) } - const amount = resolveOfferCurrentPrice(topOffer, undefined, referenceDate) + const amount = resolveOfferDefaultPrice(topOffer) const currencyCode = (topOffer.currency ?? "EUR").toLowerCase() const quantities = buildOfferInventoryQuantities(topOffer) const thumbnail = topOffer.imageRef @@ -2630,11 +2714,7 @@ function buildVariantsForProduct({ item.topOffer.currency ?? "EUR" ).toLowerCase() - const amount = resolveOfferCurrentPrice( - variant, - item.topOffer, - referenceDate - ) + const amount = resolveOfferDefaultPrice(variant, item.topOffer) const quantities = buildOfferInventoryQuantities(variant) const thumbnail = variant.imageRef const rawEan = normalizeInlineText(variant.ean) @@ -2772,6 +2852,311 @@ function buildProducts( }) } +function getVariantBasePrice( + variant: VariantSeedInput +): PriceListPriceSeedInput | undefined { + const price = variant.prices?.[0] + if (!price) { + return + } + + return { + productHandle: "", + variantSku: variant.sku, + amount: price.amount, + currencyCode: price.currency_code, + } +} + +function addPriceListPrice( + prices: PriceListPriceSeedInput[], + price: PriceListPriceSeedInput +) { + const existingIndex = prices.findIndex( + (existing) => + existing.productHandle === price.productHandle && + existing.variantSku === price.variantSku && + existing.currencyCode.toLowerCase() === price.currencyCode.toLowerCase() + ) + + if (existingIndex === -1) { + prices.push(price) + return + } + + prices[existingIndex] = price +} + +function addSalePriceListPrice( + salePriceListsByKey: Map, + { + sourceTitle, + customerGroupName, + startsAtRaw, + endsAtRaw, + price, + }: { + sourceTitle: string + customerGroupName?: string + startsAtRaw?: string + endsAtRaw?: string + price: PriceListPriceSeedInput + } +) { + const key = [ + sourceTitle, + customerGroupName ?? "", + startsAtRaw ?? "", + endsAtRaw ?? "", + ].join("|") + const startsAt = serializePriceListDate(startsAtRaw) + const endsAt = serializePriceListDate(endsAtRaw, true) + const salePriceList = + salePriceListsByKey.get(key) ?? + ({ + title: buildSalePriceListTitle(sourceTitle, startsAtRaw, endsAtRaw), + sourceTitle, + ...(customerGroupName ? { customerGroupName } : {}), + startsAt, + endsAt, + prices: [], + } satisfies PriceListsSeedInput["sales"][number]) + + addPriceListPrice(salePriceList.prices, price) + salePriceListsByKey.set(key, salePriceList) +} + +function getVariantMetadata( + variant: VariantSeedInput +): Record | undefined { + return variant.metadata as Record | undefined +} + +function getMetadataString( + metadata: Record | undefined, + key: string +): string | undefined { + return typeof metadata?.[key] === "string" ? metadata[key] : undefined +} + +function getMetadataNumber( + metadata: Record | undefined, + key: string +): number | undefined { + return typeof metadata?.[key] === "number" ? metadata[key] : undefined +} + +function getMetadataPricelists( + metadata: Record | undefined +): ParsedPricelist[] { + return Array.isArray(metadata?.pricelists) + ? (metadata.pricelists as ParsedPricelist[]) + : [] +} + +function addDefaultSalePriceFromMetadata({ + basePrice, + metadata, + referenceDate, + salePriceListsByKey, +}: { + basePrice: PriceListPriceSeedInput + metadata: Record | undefined + referenceDate: Date + salePriceListsByKey: Map +}) { + const actionPrice = normalizePriceAmount( + getMetadataNumber(metadata, "action_price") + ) + const actionPriceFrom = getMetadataString(metadata, "action_price_from") + const actionPriceUntil = getMetadataString(metadata, "action_price_until") + + if ( + !shouldImportActionPrice(actionPrice, actionPriceUntil, referenceDate) || + priceAmountsEqual(actionPrice, basePrice.amount) + ) { + return + } + + addSalePriceListPrice(salePriceListsByKey, { + sourceTitle: DEFAULT_PRICELIST_LABEL, + startsAtRaw: actionPriceFrom, + endsAtRaw: actionPriceUntil, + price: { + ...basePrice, + amount: actionPrice, + }, + }) +} + +function ensureOverridePriceList( + overridePriceListsByTitle: Map< + string, + PriceListsSeedInput["overrides"][number] + >, + title: string +): PriceListsSeedInput["overrides"][number] { + const existing = overridePriceListsByTitle.get(title) + if (existing) { + return existing + } + + const created = { + title, + customerGroupName: title, + prices: [], + } satisfies PriceListsSeedInput["overrides"][number] + overridePriceListsByTitle.set(title, created) + return created +} + +function addRegularPricelistPrice( + overridePriceList: PriceListsSeedInput["overrides"][number], + basePrice: PriceListPriceSeedInput, + regularPrice?: number +) { + if ( + regularPrice === undefined || + priceAmountsEqual(regularPrice, basePrice.amount) + ) { + return + } + + addPriceListPrice(overridePriceList.prices, { + ...basePrice, + amount: regularPrice, + }) +} + +function addPricelistSalePrice({ + basePrice, + pricelist, + referenceDate, + regularPrice, + salePriceListsByKey, + title, +}: { + basePrice: PriceListPriceSeedInput + pricelist: ParsedPricelist + referenceDate: Date + regularPrice?: number + salePriceListsByKey: Map + title: string +}) { + const actionPrice = normalizePriceAmount(pricelist.actionPrice) + const comparisonPrice = regularPrice ?? basePrice.amount + + if ( + !shouldImportActionPrice( + actionPrice, + pricelist.actionPriceUntil, + referenceDate + ) || + priceAmountsEqual(actionPrice, comparisonPrice) + ) { + return + } + + addSalePriceListPrice(salePriceListsByKey, { + sourceTitle: title, + customerGroupName: title, + startsAtRaw: pricelist.actionPriceFrom, + endsAtRaw: pricelist.actionPriceUntil, + price: { + ...basePrice, + amount: actionPrice, + }, + }) +} + +function addVariantPriceListEntries({ + basePrice, + metadata, + overridePriceListsByTitle, + referenceDate, + salePriceListsByKey, +}: { + basePrice: PriceListPriceSeedInput + metadata: Record | undefined + overridePriceListsByTitle: Map< + string, + PriceListsSeedInput["overrides"][number] + > + referenceDate: Date + salePriceListsByKey: Map +}) { + addDefaultSalePriceFromMetadata({ + basePrice, + metadata, + referenceDate, + salePriceListsByKey, + }) + + for (const pricelist of getMetadataPricelists(metadata)) { + const title = normalizeInlineText(pricelist.title) + if (!title || isDefaultPricelistTitle(title)) { + continue + } + + const overridePriceList = ensureOverridePriceList( + overridePriceListsByTitle, + title + ) + const regularPrice = normalizePriceAmount( + pricelist.priceVat ?? pricelist.standardPrice + ) + + addRegularPricelistPrice(overridePriceList, basePrice, regularPrice) + addPricelistSalePrice({ + basePrice, + pricelist, + referenceDate, + regularPrice, + salePriceListsByKey, + title, + }) + } +} + +function buildPriceListsFromProducts( + products: ProductSeedInput[], + referenceDate = new Date() +): PriceListsSeedInput { + const overridePriceListsByTitle = new Map< + string, + PriceListsSeedInput["overrides"][number] + >() + const salePriceListsByKey = new Map< + string, + PriceListsSeedInput["sales"][number] + >() + + for (const product of products) { + for (const variant of product.variants ?? []) { + const basePrice = getVariantBasePrice(variant) + if (!basePrice) { + continue + } + + addVariantPriceListEntries({ + basePrice: { + ...basePrice, + productHandle: product.handle, + }, + metadata: getVariantMetadata(variant), + overridePriceListsByTitle, + referenceDate, + salePriceListsByKey, + }) + } + } + + return { + overrides: [...overridePriceListsByTitle.values()], + sales: [...salePriceListsByKey.values()], + } +} + function getItemOffers(item: ParsedShopItem): ParsedOfferData[] { return item.variants.length > 0 ? item.variants : [item.topOffer] } @@ -2903,6 +3288,10 @@ export function buildSeedInputFromXml( categoryIdToHandle, buildOptions ) + const priceLists = buildPriceListsFromProducts( + products, + buildOptions.referenceDate + ) const { locations: stockLocations, warnings } = buildStockLocationsFromItems(items) enforceUniqueVariantSkus(products) @@ -2913,10 +3302,20 @@ export function buildSeedInputFromXml( (acc, product) => acc + (product.variants?.length ?? 0), 0 ) + const priceListPrices = + priceLists.overrides.reduce( + (acc, priceList) => acc + priceList.prices.length, + 0 + ) + + priceLists.sales.reduce( + (acc, priceList) => acc + priceList.prices.length, + 0 + ) return { categories, products, + priceLists, stockLocations, warnings, stats: { @@ -2925,6 +3324,9 @@ export function buildSeedInputFromXml( products: products.length, variants, hiddenProducts, + overridePriceLists: priceLists.overrides.length, + salePriceLists: priceLists.sales.length, + priceListPrices, stockLocations: stockLocations.length, warnings: warnings.length, }, @@ -3014,6 +3416,9 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { logger.info( `Parsed ${parsed.stats.stockLocations} stock locations from stock data` ) + logger.info( + `Parsed ${parsed.stats.overridePriceLists} override price lists, ${parsed.stats.salePriceLists} sale price lists, ${parsed.stats.priceListPrices} price-list prices` + ) for (const warning of parsed.warnings) { logger.warn(warning) } @@ -3206,6 +3611,7 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { }, productCategories: parsed.categories, products: parsed.products, + priceLists: parsed.priceLists, } logger.info("Running Herbatica seed workflow...") diff --git a/apps/medusa-be/src/workflows/seed/steps/index.ts b/apps/medusa-be/src/workflows/seed/steps/index.ts index ede1226d7..65ff2c629 100644 --- a/apps/medusa-be/src/workflows/seed/steps/index.ts +++ b/apps/medusa-be/src/workflows/seed/steps/index.ts @@ -1,5 +1,4 @@ export * from "./create-fulfillment-set" -export * from "./ensure-price-preferences" export * from "./create-inventory-levels" export * from "./create-product-categories" export * from "./create-products" @@ -9,10 +8,12 @@ export * from "./create-sales-channels" export * from "./create-shipping-options" export * from "./create-shipping-profile" export * from "./create-stock-location" -export * from "./create-tax-regions" export * from "./create-tax-rates" +export * from "./create-tax-regions" +export * from "./ensure-price-preferences" export * from "./link-sales-channels-api-key" export * from "./link-sales-channels-stock-location" export * from "./link-stock-location-fulfillment-provider" export * from "./link-stock-location-fulfillment-set" +export * from "./sync-price-lists" export * from "./update-store-currencies" diff --git a/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts b/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts new file mode 100644 index 000000000..65f2725ff --- /dev/null +++ b/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts @@ -0,0 +1,550 @@ +import type { + CustomerGroupDTO, + ICustomerModuleService, + IPricingModuleService, + IProductModuleService, + Logger, + PriceDTO, + PriceListDTO, + ProductDTO, + RemoteQueryFunction, +} from "@medusajs/framework/types" +import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { + batchPriceListPricesWorkflow, + createCustomerGroupsWorkflow, + createPriceListsWorkflow, + updateCustomerGroupsWorkflow, + updatePriceListsWorkflow, +} from "@medusajs/medusa/core-flows" + +type PriceListPriceInput = { + productHandle: string + variantSku: string + amount: number + currencyCode: string +} + +type OverridePriceListInput = { + title: string + customerGroupName: string + prices: PriceListPriceInput[] +} + +type SalePriceListInput = { + title: string + sourceTitle: string + customerGroupName?: string + startsAt?: string + endsAt?: string + prices: PriceListPriceInput[] +} + +export type SyncPriceListsStepInput = { + productIds: string[] + priceLists?: { + overrides: OverridePriceListInput[] + sales: SalePriceListInput[] + } +} + +type PriceListSyncEntry = { + title: string + description: string + type: "override" | "sale" + startsAt?: string + endsAt?: string + customerGroupName?: string + prices: PriceListPriceInput[] + metadata: Record +} + +type VariantLookup = { + id: string + sku: string +} + +type VariantPriceSetLink = { + variant_id: string + price_set_id: string +} + +type PriceListWithPrices = PriceListDTO & { + prices?: PriceDTO[] +} + +const SyncPriceListsStepId = "sync-price-lists-seed-step" +const CUSTOMER_GROUP_RULE_ATTRIBUTE = "customer.groups.id" + +function normalizeCurrencyCode(value: string): string { + return value.toLowerCase() +} + +function amountsEqual(left: unknown, right: number): boolean { + let parsed = Number.NaN + + if (typeof left === "number") { + parsed = left + } else if (typeof left === "string") { + parsed = Number(left) + } else if ( + left && + typeof left === "object" && + "value" in left && + (typeof left.value === "number" || typeof left.value === "string") + ) { + parsed = Number(left.value) + } + + return Number.isFinite(parsed) && Math.abs(parsed - right) < 0.000_001 +} + +function buildPriceListEntries( + priceLists?: SyncPriceListsStepInput["priceLists"] +): PriceListSyncEntry[] { + if (!priceLists) { + return [] + } + + return [ + ...priceLists.overrides.map((priceList) => ({ + title: priceList.title, + description: `Herbatica Shoptet price list: ${priceList.title}`, + type: "override" as const, + customerGroupName: priceList.customerGroupName, + prices: priceList.prices, + metadata: { + source: "herbatica-products-complete-xml", + source_type: "shoptet_pricelist", + shoptet_pricelist_title: priceList.title, + }, + })), + ...priceLists.sales.map((priceList) => ({ + title: priceList.title, + description: `Herbatica sale prices for ${priceList.sourceTitle}`, + type: "sale" as const, + startsAt: priceList.startsAt, + endsAt: priceList.endsAt, + customerGroupName: priceList.customerGroupName, + prices: priceList.prices, + metadata: { + source: "herbatica-products-complete-xml", + source_type: "shoptet_sale", + shoptet_pricelist_title: priceList.sourceTitle, + starts_at: priceList.startsAt, + ends_at: priceList.endsAt, + }, + })), + ] +} + +function buildVariantLookup( + products: ProductDTO[] +): Map { + const variants = new Map() + + for (const product of products) { + for (const variant of product.variants ?? []) { + if (!variant.sku) { + continue + } + + variants.set(`${product.handle}:${variant.sku}`, { + id: variant.id, + sku: variant.sku, + }) + } + } + + return variants +} + +async function ensureCustomerGroups( + entries: PriceListSyncEntry[], + customerService: ICustomerModuleService, + container: Parameters[0] +): Promise> { + const names = [ + ...new Set( + entries + .map((entry) => entry.customerGroupName) + .filter((name): name is string => !!name) + ), + ] + const result = new Map() + + for (const name of names) { + const existing = await customerService.listCustomerGroups( + { name }, + { take: 1 } + ) + const metadata = { + source: "herbatica-products-complete-xml", + source_type: "shoptet_pricelist_customer_group", + shoptet_pricelist_title: name, + } + + if (existing[0]) { + const { result: updated } = await updateCustomerGroupsWorkflow( + container + ).run({ + input: { + selector: { id: existing[0].id }, + update: { metadata }, + }, + }) + result.set(name, updated[0] ?? existing[0]) + continue + } + + const { result: created } = await createCustomerGroupsWorkflow( + container + ).run({ + input: { + customersData: [ + { + name, + metadata, + }, + ], + }, + }) + if (created[0]) { + result.set(name, created[0]) + } + } + + return result +} + +async function findPriceListByTitle( + pricingService: IPricingModuleService, + title: string +): Promise { + const priceLists = (await pricingService.listPriceLists( + { q: title }, + { + relations: ["prices", "price_list_rules"], + take: 100, + } + )) as PriceListWithPrices[] + + return priceLists.find((priceList) => priceList.title === title) +} + +function buildRules( + entry: PriceListSyncEntry, + customerGroups: Map +): Record | undefined { + if (!entry.customerGroupName) { + return + } + + const customerGroup = customerGroups.get(entry.customerGroupName) + if (!customerGroup) { + return + } + + return { + [CUSTOMER_GROUP_RULE_ATTRIBUTE]: [customerGroup.id], + } +} + +async function ensurePriceLists( + entries: PriceListSyncEntry[], + pricingService: IPricingModuleService, + customerGroups: Map, + container: Parameters[0] +): Promise> { + const result = new Map() + + for (const entry of entries) { + const rules = buildRules(entry, customerGroups) + const existing = await findPriceListByTitle(pricingService, entry.title) + const data = { + title: entry.title, + description: entry.description, + type: entry.type, + status: "active" as const, + starts_at: entry.startsAt ?? null, + ends_at: entry.endsAt ?? null, + rules, + metadata: entry.metadata, + } + + if (existing) { + await updatePriceListsWorkflow(container).run({ + input: { + price_lists_data: [ + { + id: existing.id, + ...data, + }, + ], + }, + }) + result.set(entry.title, existing) + continue + } + + const { result: created } = await createPriceListsWorkflow(container).run({ + input: { + price_lists_data: [ + { + ...data, + prices: [], + }, + ], + }, + }) + if (created[0]) { + result.set(entry.title, created[0] as PriceListWithPrices) + } + } + + return result +} + +function existingPriceForVariant( + priceList: PriceListWithPrices, + variantId: string, + currencyCode: string, + variantPriceSetMap: Map +): PriceDTO | undefined { + const priceSetId = variantPriceSetMap.get(variantId) + + return priceList.prices?.find( + (price) => + price.price_set_id === priceSetId && + price.currency_code?.toLowerCase() === currencyCode && + (price.min_quantity === null || price.min_quantity === undefined) && + (price.max_quantity === null || price.max_quantity === undefined) + ) +} + +async function syncPriceListPrices({ + entries, + priceListsByTitle, + variantLookup, + variantPriceSetMap, + container, + logger, +}: { + entries: PriceListSyncEntry[] + priceListsByTitle: Map + variantLookup: Map + variantPriceSetMap: Map + container: Parameters[0] + logger: Logger +}): Promise<{ created: number; updated: number; skipped: number }> { + let created = 0 + let updated = 0 + let skipped = 0 + + for (const entry of entries) { + const priceList = priceListsByTitle.get(entry.title) + if (!priceList) { + logger.warn(`Skipping prices for missing price list "${entry.title}"`) + continue + } + + const changes = buildPriceListPriceChanges({ + entry, + logger, + priceList, + variantLookup, + variantPriceSetMap, + }) + skipped += changes.skipped + + if (changes.create.length === 0 && changes.update.length === 0) { + continue + } + + const { result } = await batchPriceListPricesWorkflow(container).run({ + input: { + data: { + id: priceList.id, + create: changes.create, + update: changes.update, + delete: [], + }, + }, + }) + created += result.created.length + updated += result.updated.length + } + + return { created, updated, skipped } +} + +function buildPriceListPriceChanges({ + entry, + logger, + priceList, + variantLookup, + variantPriceSetMap, +}: { + entry: PriceListSyncEntry + logger: Logger + priceList: PriceListWithPrices + variantLookup: Map + variantPriceSetMap: Map +}): { + create: Array<{ amount: number; currency_code: string; variant_id: string }> + update: Array<{ + id: string + amount: number + currency_code: string + variant_id: string + }> + skipped: number +} { + const create: Array<{ + amount: number + currency_code: string + variant_id: string + }> = [] + const update: Array<{ + id: string + amount: number + currency_code: string + variant_id: string + }> = [] + let skipped = 0 + + for (const price of entry.prices) { + const variant = variantLookup.get( + `${price.productHandle}:${price.variantSku}` + ) + if (!variant) { + skipped += 1 + logger.warn( + `Skipping price-list price for missing variant SKU "${price.variantSku}" on product "${price.productHandle}"` + ) + continue + } + + const currencyCode = normalizeCurrencyCode(price.currencyCode) + const existingPrice = existingPriceForVariant( + priceList, + variant.id, + currencyCode, + variantPriceSetMap + ) + + if (!existingPrice) { + create.push({ + amount: price.amount, + currency_code: currencyCode, + variant_id: variant.id, + }) + continue + } + + if ( + !amountsEqual(existingPrice.amount, price.amount) || + existingPrice.currency_code?.toLowerCase() !== currencyCode + ) { + update.push({ + id: existingPrice.id, + amount: price.amount, + currency_code: currencyCode, + variant_id: variant.id, + }) + } + } + + return { create, update, skipped } +} + +export const syncPriceListsStep = createStep( + SyncPriceListsStepId, + async (input: SyncPriceListsStepInput, { container }) => { + const entries = buildPriceListEntries(input.priceLists) + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + + if (!entries.length) { + return new StepResponse({ + priceLists: 0, + pricesCreated: 0, + pricesUpdated: 0, + }) + } + + const productService = container.resolve( + Modules.PRODUCT + ) + const pricingService = container.resolve( + Modules.PRICING + ) + const customerService = container.resolve( + Modules.CUSTOMER + ) + const remoteQuery = container.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const products = await productService.listProducts( + { id: { $in: input.productIds } }, + { + select: ["id", "handle", "variants.id", "variants.sku"], + relations: ["variants"], + } + ) + const variantLookup = buildVariantLookup(products) + const customerGroups = await ensureCustomerGroups( + entries, + customerService, + container + ) + const priceListsByTitle = await ensurePriceLists( + entries, + pricingService, + customerGroups, + container + ) + const variantIds = [ + ...new Set( + entries.flatMap((entry) => + entry.prices + .map((price) => + variantLookup.get(`${price.productHandle}:${price.variantSku}`) + ) + .filter((variant): variant is VariantLookup => !!variant) + .map((variant) => variant.id) + ) + ), + ] + const variantPriceSetLinks = variantIds.length + ? ((await remoteQuery({ + entryPoint: "product_variant_price_set", + fields: ["variant_id", "price_set_id"], + variables: { variant_id: variantIds }, + })) as VariantPriceSetLink[]) + : [] + const variantPriceSetMap = new Map( + variantPriceSetLinks.map((link) => [link.variant_id, link.price_set_id]) + ) + const priceSyncResult = await syncPriceListPrices({ + entries, + priceListsByTitle, + variantLookup, + variantPriceSetMap, + container, + logger, + }) + + logger.info( + `Synced ${priceListsByTitle.size} Herbatica price lists, created ${priceSyncResult.created} prices, updated ${priceSyncResult.updated} prices` + ) + + return new StepResponse({ + priceLists: priceListsByTitle.size, + pricesCreated: priceSyncResult.created, + pricesUpdated: priceSyncResult.updated, + pricesSkipped: priceSyncResult.skipped, + }) + } +) diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts index 2b1b6e552..96de2e5f2 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts @@ -20,6 +20,7 @@ export type SeedDatabaseWorkflowInput = { publishableKey: Steps.CreatePublishableKeyStepInput productCategories: Steps.CreateProductCategoriesStepInput products: Steps.CreateProductsStepInput + priceLists?: Steps.SyncPriceListsStepInput["priceLists"] } function buildInventoryItemsInput( @@ -282,6 +283,19 @@ const seedDatabaseWorkflow = createWorkflow( const createProductsResult = Steps.createProductsStep(input.products) + const syncPriceListsInput: Steps.SyncPriceListsStepInput = transform( + { + createProductsResult, + input, + }, + (data) => ({ + productIds: data.createProductsResult.result, + priceLists: data.input.priceLists, + }) + ) + + const syncPriceListsResult = Steps.syncPriceListsStep(syncPriceListsInput) + const createTaxRatesStepInput: Steps.CreateTaxRatesStepInput | undefined = input.taxRates ? transform( @@ -335,6 +349,7 @@ const seedDatabaseWorkflow = createWorkflow( linkSalesChannelsApiKeyStepInputResult, createProductCategoriesResult, createProductsResult, + syncPriceListsResult, createTaxRatesResult, createInventoryLevelsResult, }) diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts index 0945e3c04..386a291e6 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts @@ -139,10 +139,26 @@ describe("Herbatica seed promo rebase", () => { expect(variant?.prices).toEqual([ { - amount: 7.99, + amount: 9.99, currency_code: "eur", }, ]) + expect(result.priceLists.sales).toEqual([ + { + title: "Herbatica sale - Default pricelist - 2026-04-23_2026-05-23", + sourceTitle: "Default pricelist", + startsAt: "2026-04-23T00:00:00.000Z", + endsAt: "2026-05-23T23:59:59.999Z", + prices: [ + { + productHandle: "shopitem-42", + variantSku: "SHOPITEM-42-42", + amount: 7.99, + currencyCode: "eur", + }, + ], + }, + ]) expect(product?.metadata).toMatchObject({ top_offer: { action_price_from: "2026-04-23", @@ -162,6 +178,150 @@ describe("Herbatica seed promo rebase", () => { }) }) +describe("Herbatica seed price-list parsing", () => { + it("maps non-default Shoptet pricelists to dynamic override price lists", () => { + const xml = ` + + + Price list product + Popis produktu + 10 + 10 + EUR + 1 + + 3 + + + Doplnky výživy + + + + Partnerský cenník + 8.50 + + + Hlavný cenník + 9.00 + + + VIP cenník + 10.00 + + + + + ` + + const result = buildSeedInputFromXml(xml) + + expect(result.products[0]?.variants?.[0]?.prices).toEqual([ + { + amount: 10, + currency_code: "eur", + }, + ]) + expect(result.priceLists.overrides).toEqual([ + { + title: "Partnerský cenník", + customerGroupName: "Partnerský cenník", + prices: [ + { + productHandle: "shopitem-pricelist-override", + variantSku: "SHOPITEM-PRICELIST-OVERRIDE-PRICELIST-OVERRIDE", + amount: 8.5, + currencyCode: "eur", + }, + ], + }, + { + title: "VIP cenník", + customerGroupName: "VIP cenník", + prices: [], + }, + ]) + }) + + it("groups pricelist action prices by source title and date window", () => { + const xml = ` + + + Sale A + Popis produktu + 10 + EUR + 1 + + 3 + + + Doplnky výživy + + + + Partneri + 8.50 + 7.25 + 2026-06-01 + 2026-06-30 + + + + + Sale B + Popis produktu + 12 + EUR + 1 + + 3 + + + Doplnky výživy + + + + Partneri + 9.50 + 8.25 + 2026-06-01 + 2026-06-30 + + + + + ` + + const result = buildSeedInputFromXml(xml, undefined, { + referenceDate: new Date("2026-05-26T12:00:00.000Z"), + }) + + expect(result.priceLists.sales).toEqual([ + { + title: "Herbatica sale - Partneri - 2026-06-01_2026-06-30", + sourceTitle: "Partneri", + customerGroupName: "Partneri", + startsAt: "2026-06-01T00:00:00.000Z", + endsAt: "2026-06-30T23:59:59.999Z", + prices: [ + { + productHandle: "shopitem-sale-a", + variantSku: "SHOPITEM-SALE-A-SALE-A", + amount: 7.25, + currencyCode: "eur", + }, + { + productHandle: "shopitem-sale-b", + variantSku: "SHOPITEM-SALE-B-SALE-B", + amount: 8.25, + currencyCode: "eur", + }, + ], + }, + ]) + }) +}) + describe("Herbatica seed stock parsing", () => { it("preserves simple STOCK/AMOUNT inventory on the default stock location", () => { const xml = ` @@ -615,6 +775,9 @@ describe("Herbatica committed feed fixtures", () => { products: 4, variants: 5, hiddenProducts: 1, + overridePriceLists: 0, + salePriceLists: 1, + priceListPrices: 1, stockLocations: 1, warnings: 0, }) From 2edfe45cc32f2c7f07f9255f823f9a797a2fdec3 Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 16:44:09 +0200 Subject: [PATCH 03/10] feat(seed): align Herbatica tax rates --- apps/medusa-be/src/scripts/herbatica-seed.ts | 2 +- .../workflows/seed/steps/create-tax-rates.ts | 219 +++++++++--------- .../herbatica-tax-rates.unit.spec.ts | 120 ++++++++++ 3 files changed, 225 insertions(+), 116 deletions(-) create mode 100644 apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index 227f7e2b6..4c015b604 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -3514,7 +3514,7 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { }, taxRates: { fallbackCountryCode: "sk", - countries: [...DEFAULT_COUNTRIES], + countries: ["sk", "cz"], }, stockLocations: { locations: parsed.stockLocations, diff --git a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts index 7c013033f..070d8354f 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts @@ -1,13 +1,16 @@ import type { - IProductModuleService, - ITaxModuleService, - Logger, - TaxRateDTO, - TaxRegionDTO, + IProductModuleService, + ITaxModuleService, + Logger, + TaxRateDTO, + TaxRegionDTO, } from "@medusajs/framework/types" -import {ContainerRegistrationKeys, Modules} from "@medusajs/framework/utils" -import {createStep, StepResponse} from "@medusajs/framework/workflows-sdk" -import {createTaxRatesWorkflow, updateTaxRatesWorkflow,} from "@medusajs/medusa/core-flows" +import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { + createTaxRatesWorkflow, + updateTaxRatesWorkflow, +} from "@medusajs/medusa/core-flows" type TaxRateMetadata = Record @@ -38,6 +41,16 @@ type CreateTaxRatesStepOutput = { updated: TaxRateDTO[] } +type ProductTaxSource = { + id: string + metadata?: Record | null +} + +export type TaxRateSeedTargets = { + defaultRatesByCountry: Map + productRatesByCountry: Map> +} + export type CreateTaxRatesStepInput = { productIds: string[] fallbackCountryCode?: string @@ -47,6 +60,11 @@ export type CreateTaxRatesStepInput = { const CreateTaxRatesStepId = "create-tax-rates-seed-step" const TAX_METADATA_SOURCE = "herbatica-seed-tax-rates" const RATE_EPSILON = 0.0001 +export const HERBATICA_DEFAULT_TAX_RATES = new Map([ + ["sk", 23], + ["cz", 19], +]) +const PRODUCT_OVERRIDE_COUNTRY_CODE = "sk" function asObject(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -95,27 +113,6 @@ function parseRate(value: unknown): number | undefined { return Number(parsed.toFixed(4)) } -function parseOssRate( - value: unknown, - fallbackRate: number | undefined -): number | undefined { - const parsed = parseRate(value) - if (parsed !== undefined) { - return parsed - } - - if (typeof value !== "string") { - return - } - - const normalized = value.trim().toLowerCase() - if ((normalized === "high" || normalized === "low") && fallbackRate !== undefined) { - return fallbackRate - } - - return -} - function isSameRate(left: number | null | undefined, right: number): boolean { if (left === null || left === undefined) { return false @@ -124,38 +121,6 @@ function isSameRate(left: number | null | undefined, right: number): boolean { return Math.abs(left - right) < RATE_EPSILON } -function pickDefaultRate(values: number[]): number { - const counters = new Map() - - for (const value of values) { - const key = value.toFixed(4) - const existing = counters.get(key) - if (!existing) { - counters.set(key, { count: 1, value }) - continue - } - - existing.count += 1 - } - - let picked = values[0] ?? 0 - let maxCount = -1 - - for (const entry of counters.values()) { - if (entry.count > maxCount) { - picked = entry.value - maxCount = entry.count - continue - } - - if (entry.count === maxCount && entry.value > picked) { - picked = entry.value - } - } - - return picked -} - function getMetadataString(metadata: TaxRateMetadata | null, key: string) { const value = metadata?.[key] if (typeof value !== "string") { @@ -186,43 +151,66 @@ function buildProductRateMetadata( } } -function extractProductTaxRates( - metadata: Record | undefined, - fallbackCountryCode: string, - targetCountries: string[] -): Map { - const result = new Map() - +function extractProductVat( + metadata: Record | undefined +): number | undefined { const topOffer = asObject(metadata?.top_offer) if (!topOffer) { - return result + return } - const fallbackVat = parseRate(topOffer.vat) - if (fallbackVat !== undefined) { - const seedCountries = targetCountries.length > 0 ? targetCountries : [fallbackCountryCode] - for (const countryCode of seedCountries) { - result.set(countryCode, fallbackVat) + return parseRate(topOffer.vat) +} + +export function buildTaxRateSeedTargets( + products: ProductTaxSource[], + requestedCountries: string[] = [] +): TaxRateSeedTargets { + const requestedCountrySet = new Set( + requestedCountries + .map((countryCode) => normalizeCountryCode(countryCode)) + .filter((countryCode): countryCode is string => Boolean(countryCode)) + ) + + const defaultRatesByCountry = new Map( + [...HERBATICA_DEFAULT_TAX_RATES.entries()].filter( + ([countryCode]) => + requestedCountrySet.size === 0 || requestedCountrySet.has(countryCode) + ) + ) + const productRatesByCountry = new Map>() + const defaultOverrideRate = defaultRatesByCountry.get( + PRODUCT_OVERRIDE_COUNTRY_CODE + ) + + if (defaultOverrideRate === undefined) { + return { + defaultRatesByCountry, + productRatesByCountry, } } - const ossTaxRates = Array.isArray(topOffer.oss_tax_rates) - ? topOffer.oss_tax_rates - : [] - - for (const ossTaxRate of ossTaxRates) { - const entry = asObject(ossTaxRate) - const countryCode = normalizeCountryCode(entry?.country) - const rate = parseOssRate(entry?.level, fallbackVat) - - if (!countryCode || rate === undefined) { + const slovakiaProductRates = new Map() + for (const product of products) { + const vat = extractProductVat(asObject(product.metadata)) + if (vat === undefined || isSameRate(vat, defaultOverrideRate)) { continue } - result.set(countryCode, rate) + slovakiaProductRates.set(product.id, vat) + } + + if (slovakiaProductRates.size > 0) { + productRatesByCountry.set( + PRODUCT_OVERRIDE_COUNTRY_CODE, + slovakiaProductRates + ) } - return result + return { + defaultRatesByCountry, + productRatesByCountry, + } } function mapCountryToRegion(taxRegions: TaxRegionDTO[]) { @@ -271,8 +259,6 @@ export const createTaxRatesStep = createStep( }) } - const fallbackCountryCode = - normalizeCountryCode(input.fallbackCountryCode) ?? "sk" const normalizedSeedCountries = [ ...new Set( (input.countries ?? []) @@ -290,27 +276,15 @@ export const createTaxRatesStep = createStep( } ) - const countryToProductRates = new Map>() + const taxRateTargets = buildTaxRateSeedTargets( + products, + normalizedSeedCountries + ) - for (const product of products) { - const metadata = asObject(product.metadata) - const productTaxRates = extractProductTaxRates( - metadata, - fallbackCountryCode, - normalizedSeedCountries + if (taxRateTargets.defaultRatesByCountry.size === 0) { + logger.warn( + "No approved tax-rate countries configured, skipping tax rate seed" ) - - for (const [countryCode, rate] of productTaxRates.entries()) { - if (!countryToProductRates.has(countryCode)) { - countryToProductRates.set(countryCode, new Map()) - } - - countryToProductRates.get(countryCode)?.set(product.id, rate) - } - } - - if (countryToProductRates.size === 0) { - logger.warn("No VAT information found in product metadata, skipping tax rate seed") return new StepResponse({ result: { created, @@ -319,7 +293,7 @@ export const createTaxRatesStep = createStep( }) } - const countries = [...countryToProductRates.keys()] + const countries = [...taxRateTargets.defaultRatesByCountry.keys()] const taxRegions = await taxService.listTaxRegions({ country_code: { $in: countries }, }) @@ -335,7 +309,9 @@ export const createTaxRatesStep = createStep( ) } - const regionIds = [...countryToRegion.values()].map((taxRegion) => taxRegion.id) + const regionIds = [...countryToRegion.values()].map( + (taxRegion) => taxRegion.id + ) if (regionIds.length === 0) { return new StepResponse({ @@ -371,10 +347,15 @@ export const createTaxRatesStep = createStep( continue } - existingProductByKey.set(buildProductRateKey(countryCode, productId), taxRate) + existingProductByKey.set( + buildProductRateKey(countryCode, productId), + taxRate + ) } - const nonDefaultRates = existingRates.filter((taxRate) => !taxRate.is_default) + const nonDefaultRates = existingRates.filter( + (taxRate) => !taxRate.is_default + ) if (nonDefaultRates.length > 0) { const productRules = await taxService.listTaxRateRules({ tax_rate_id: nonDefaultRates.map((taxRate) => taxRate.id), @@ -423,13 +404,15 @@ export const createTaxRatesStep = createStep( const createPayloads: CreateTaxRatePayload[] = [] const updatePayloads: UpdateTaxRatePayload[] = [] - for (const [countryCode, productRates] of countryToProductRates.entries()) { + for (const [ + countryCode, + defaultRate, + ] of taxRateTargets.defaultRatesByCountry) { const taxRegion = countryToRegion.get(countryCode) if (!taxRegion) { continue } - const defaultRate = pickDefaultRate([...productRates.values()]) const defaultName = `VAT ${countryCode.toUpperCase()}` const defaultCode = `vat_${countryCode}` const defaultMetadata = buildDefaultRateMetadata(countryCode) @@ -462,6 +445,8 @@ export const createTaxRatesStep = createStep( }) } + const productRates = + taxRateTargets.productRatesByCountry.get(countryCode) ?? new Map() for (const [productId, rate] of productRates.entries()) { if (isSameRate(defaultRate, rate)) { continue @@ -510,14 +495,18 @@ export const createTaxRatesStep = createStep( for (let i = 0; i < createPayloads.length; i += CHUNK_SIZE) { const chunk = createPayloads.slice(i, i + CHUNK_SIZE) - const { result: createdChunk } = await createTaxRatesWorkflow(container).run({ + const { result: createdChunk } = await createTaxRatesWorkflow( + container + ).run({ input: chunk, }) created.push(...createdChunk) } for (const updatePayload of updatePayloads) { - const { result: updatedChunk } = await updateTaxRatesWorkflow(container).run({ + const { result: updatedChunk } = await updateTaxRatesWorkflow( + container + ).run({ input: updatePayload, }) updated.push(...updatedChunk) diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts new file mode 100644 index 000000000..e35c40ebc --- /dev/null +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest" +import { + buildTaxRateSeedTargets, + HERBATICA_DEFAULT_TAX_RATES, +} from "../../../src/workflows/seed/steps/create-tax-rates" + +function mapEntries(map: Map) { + return Array.from(map.entries()) +} + +describe("Herbatica tax-rate seed policy", () => { + it("uses explicit approved default rates only", () => { + const targets = buildTaxRateSeedTargets( + [ + { + id: "prod_23", + metadata: { + top_offer: { + vat: 23, + oss_tax_rates: [ + { + country: "hu", + level: "high", + }, + ], + }, + }, + }, + ], + ["sk", "cz", "hu"] + ) + + expect(mapEntries(HERBATICA_DEFAULT_TAX_RATES)).toEqual([ + ["sk", 23], + ["cz", 19], + ]) + expect(mapEntries(targets.defaultRatesByCountry)).toEqual([ + ["sk", 23], + ["cz", 19], + ]) + expect(mapEntries(targets.productRatesByCountry)).toEqual([]) + }) + + it("creates Slovakia product overrides only when product VAT differs from Slovak default", () => { + const targets = buildTaxRateSeedTargets( + [ + { + id: "prod_default", + metadata: { + top_offer: { + vat: "23", + }, + }, + }, + { + id: "prod_lower", + metadata: { + top_offer: { + vat: "19", + }, + }, + }, + { + id: "prod_second_lower", + metadata: { + top_offer: { + vat: 5, + }, + }, + }, + { + id: "prod_zero", + metadata: { + top_offer: { + vat: 0, + }, + }, + }, + { + id: "prod_missing", + metadata: { + top_offer: {}, + }, + }, + ], + ["sk", "cz"] + ) + + expect(mapEntries(targets.productRatesByCountry)).toEqual([ + [ + "sk", + new Map([ + ["prod_lower", 19], + ["prod_second_lower", 5], + ["prod_zero", 0], + ]), + ], + ]) + expect(targets.productRatesByCountry.has("cz")).toBe(false) + }) + + it("does not emit Slovakia product overrides when Slovakia is not an approved target country", () => { + const targets = buildTaxRateSeedTargets( + [ + { + id: "prod_lower", + metadata: { + top_offer: { + vat: 19, + }, + }, + }, + ], + ["cz"] + ) + + expect(mapEntries(targets.defaultRatesByCountry)).toEqual([["cz", 19]]) + expect(mapEntries(targets.productRatesByCountry)).toEqual([]) + }) +}) From 1f397ebc39e0fbdbdda75ee106a25a027da39599 Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 17:14:45 +0200 Subject: [PATCH 04/10] fix(seed): sequential workflow --- .../seed/steps/ensure-price-preferences.ts | 52 ++++++++++--------- .../workflows/seed/workflows/seed-database.ts | 16 +++++- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts b/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts index ea610f83a..618945202 100644 --- a/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts +++ b/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts @@ -1,6 +1,6 @@ -import type {IPricingModuleService, Logger} from "@medusajs/framework/types" -import {ContainerRegistrationKeys, Modules} from "@medusajs/framework/utils" -import {createStep, StepResponse} from "@medusajs/framework/workflows-sdk" +import type { IPricingModuleService, Logger } from "@medusajs/framework/types" +import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" type PricePreferenceAttribute = "region_id" | "currency_code" @@ -20,7 +20,7 @@ const EnsurePricePreferencesStepId = "ensure-price-preferences-seed-step" function normalizeId(value: unknown): string | undefined { if (typeof value !== "string") { - return undefined + return } const normalized = value.trim() @@ -29,12 +29,12 @@ function normalizeId(value: unknown): string | undefined { function normalizeCurrencyCode(value: unknown): string | undefined { if (typeof value !== "string") { - return undefined + return } const normalized = value.trim().toLowerCase() if (!normalized) { - return undefined + return } return normalized @@ -58,7 +58,9 @@ export const ensurePricePreferencesStep = createStep( const isTaxInclusive = input.isTaxInclusive ?? true const regionIds = [ - ...new Set((input.regionIds ?? []).map(normalizeId).filter(isDefinedString)), + ...new Set( + (input.regionIds ?? []).map(normalizeId).filter(isDefinedString) + ), ] const currencyCodes = [ ...new Set( @@ -78,23 +80,25 @@ export const ensurePricePreferencesStep = createStep( return new StepResponse({ result: output }) } - const [existingRegionPreferences, existingCurrencyPreferences] = - await Promise.all([ - regionIds.length > 0 - ? pricingService.listPricePreferences({ - attribute: "region_id", - value: regionIds, - }) - : Promise.resolve([]), - currencyCodes.length > 0 - ? pricingService.listPricePreferences({ - attribute: "currency_code", - value: currencyCodes, - }) - : Promise.resolve([]), - ]) - - const existingByKey = new Map() + const existingRegionPreferences = + regionIds.length > 0 + ? await pricingService.listPricePreferences({ + attribute: "region_id", + value: regionIds, + }) + : [] + const existingCurrencyPreferences = + currencyCodes.length > 0 + ? await pricingService.listPricePreferences({ + attribute: "currency_code", + value: currencyCodes, + }) + : [] + + const existingByKey = new Map< + string, + { id: string; isTaxInclusive: boolean } + >() for (const preference of [ ...existingRegionPreferences, diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts index 96de2e5f2..288ca8c53 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts @@ -281,7 +281,19 @@ const seedDatabaseWorkflow = createWorkflow( // create products - const createProductsResult = Steps.createProductsStep(input.products) + const createProductsStepInput: Steps.CreateProductsStepInput = transform( + { + input, + createProductCategoriesResult, + salesChannelsResult, + createDefaultShippingProfileResult, + }, + (data) => data.input.products + ) + + const createProductsResult = Steps.createProductsStep( + createProductsStepInput + ) const syncPriceListsInput: Steps.SyncPriceListsStepInput = transform( { @@ -301,6 +313,7 @@ const seedDatabaseWorkflow = createWorkflow( ? transform( { createProductsResult, + createTaxRegionsResult, input, }, (data) => ({ @@ -320,6 +333,7 @@ const seedDatabaseWorkflow = createWorkflow( transform( { createStockLocationResult, + createProductsResult, input, }, (data) => ({ From 101e0c7ba5b45a68cdb6c5d039bd553768df3086 Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 17:18:45 +0200 Subject: [PATCH 05/10] fix(seed): group Herbatica tax overrides by rate --- .../workflows/seed/steps/create-tax-rates.ts | 158 +++++++++++++----- .../herbatica-tax-rates.unit.spec.ts | 36 +++- 2 files changed, 142 insertions(+), 52 deletions(-) diff --git a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts index 070d8354f..22a848408 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts @@ -48,7 +48,7 @@ type ProductTaxSource = { export type TaxRateSeedTargets = { defaultRatesByCountry: Map - productRatesByCountry: Map> + productRateGroupsByCountry: Map> } export type CreateTaxRatesStepInput = { @@ -141,13 +141,13 @@ function buildDefaultRateMetadata(countryCode: string): TaxRateMetadata { function buildProductRateMetadata( countryCode: string, - productId: string + rate: number ): TaxRateMetadata { return { seed_source: TAX_METADATA_SOURCE, - seed_scope: "product", + seed_scope: "product_rate", seed_country_code: countryCode, - seed_product_id: productId, + seed_rate: formatRateValue(rate), } } @@ -178,7 +178,7 @@ export function buildTaxRateSeedTargets( requestedCountrySet.size === 0 || requestedCountrySet.has(countryCode) ) ) - const productRatesByCountry = new Map>() + const productRateGroupsByCountry = new Map>() const defaultOverrideRate = defaultRatesByCountry.get( PRODUCT_OVERRIDE_COUNTRY_CODE ) @@ -186,30 +186,32 @@ export function buildTaxRateSeedTargets( if (defaultOverrideRate === undefined) { return { defaultRatesByCountry, - productRatesByCountry, + productRateGroupsByCountry, } } - const slovakiaProductRates = new Map() + const slovakiaProductRateGroups = new Map() for (const product of products) { const vat = extractProductVat(asObject(product.metadata)) if (vat === undefined || isSameRate(vat, defaultOverrideRate)) { continue } - slovakiaProductRates.set(product.id, vat) + const existing = slovakiaProductRateGroups.get(vat) ?? [] + existing.push(product.id) + slovakiaProductRateGroups.set(vat, existing) } - if (slovakiaProductRates.size > 0) { - productRatesByCountry.set( + if (slovakiaProductRateGroups.size > 0) { + productRateGroupsByCountry.set( PRODUCT_OVERRIDE_COUNTRY_CODE, - slovakiaProductRates + slovakiaProductRateGroups ) } return { defaultRatesByCountry, - productRatesByCountry, + productRateGroupsByCountry, } } @@ -228,13 +230,52 @@ function mapCountryToRegion(taxRegions: TaxRegionDTO[]) { return countryToRegion } -function buildProductRateKey(countryCode: string, productId: string): string { - return `${countryCode}:${productId}` +function buildProductRateKey(countryCode: string, rate: number): string { + return `${countryCode}:${rate.toFixed(4)}` +} + +function formatRateValue(rate: number): string { + return Number(rate.toFixed(4)).toString() +} + +function buildProductRateCode(countryCode: string, rate: number): string { + const normalizedRate = formatRateValue(rate).replace(/[^0-9]+/g, "_") + return `vat_${countryCode}_product_${normalizedRate}` +} + +function buildProductRateName(countryCode: string, rate: number): string { + return `VAT ${countryCode.toUpperCase()} Product ${formatRateValue(rate)}%` } -function buildProductRateCode(countryCode: string, productId: string): string { - const normalizedProductId = productId.replace(/[^a-zA-Z0-9]/g, "").slice(-18) - return `vat_${countryCode}_${normalizedProductId}` +export function buildProductTaxRateIdentity(countryCode: string, rate: number) { + return { + code: buildProductRateCode(countryCode, rate), + name: buildProductRateName(countryCode, rate), + } +} + +function buildProductRules(productIds: string[]) { + return [...new Set(productIds)].sort().map((productId) => ({ + reference: "product", + reference_id: productId, + })) +} + +function areProductRulesEqual( + left: { reference: string; reference_id: string }[], + right: { reference: string; reference_id: string }[] +): boolean { + if (left.length !== right.length) { + return false + } + + return left.every((rule, index) => { + const rightRule = right[index] + return ( + rightRule?.reference === rule.reference && + rightRule.reference_id === rule.reference_id + ) + }) } export const createTaxRatesStep = createStep( @@ -338,37 +379,50 @@ export const createTaxRatesStep = createStep( } const seedSource = getMetadataString(taxRate.metadata, "seed_source") - const productId = getMetadataString(taxRate.metadata, "seed_product_id") const countryCode = normalizeCountryCode( getMetadataString(taxRate.metadata, "seed_country_code") ) + const seedScope = getMetadataString(taxRate.metadata, "seed_scope") + const seedRate = parseRate( + getMetadataString(taxRate.metadata, "seed_rate") + ) - if (seedSource !== TAX_METADATA_SOURCE || !productId || !countryCode) { + if ( + seedSource !== TAX_METADATA_SOURCE || + seedScope !== "product_rate" || + !countryCode || + seedRate === undefined + ) { continue } existingProductByKey.set( - buildProductRateKey(countryCode, productId), + buildProductRateKey(countryCode, seedRate), taxRate ) } + const rulesByRateId = new Map< + string, + { reference: string; reference_id: string }[] + >() const nonDefaultRates = existingRates.filter( (taxRate) => !taxRate.is_default ) if (nonDefaultRates.length > 0) { - const productRules = await taxService.listTaxRateRules({ + const taxRateRules = await taxService.listTaxRateRules({ tax_rate_id: nonDefaultRates.map((taxRate) => taxRate.id), - reference: "product", }) - const rulesByRateId = new Map() - for (const rule of productRules) { + for (const rule of taxRateRules) { if (!rulesByRateId.has(rule.tax_rate_id)) { rulesByRateId.set(rule.tax_rate_id, []) } - rulesByRateId.get(rule.tax_rate_id)?.push(rule.reference_id) + rulesByRateId.get(rule.tax_rate_id)?.push({ + reference: rule.reference, + reference_id: rule.reference_id, + }) } const countryByRegionId = new Map( @@ -379,25 +433,35 @@ export const createTaxRatesStep = createStep( ) for (const taxRate of nonDefaultRates) { - const references = rulesByRateId.get(taxRate.id) ?? [] - if (references.length !== 1) { + const countryCode = countryByRegionId.get(taxRate.tax_region_id) + const rate = parseRate(taxRate.rate) + if (!countryCode || rate === undefined) { continue } - const countryCode = countryByRegionId.get(taxRate.tax_region_id) - if (!countryCode) { + const key = buildProductRateKey(countryCode, rate) + if (existingProductByKey.has(key)) { continue } - const referenceId = references[0] - if (!referenceId) { + const seedSource = getMetadataString(taxRate.metadata, "seed_source") + const seedScope = getMetadataString(taxRate.metadata, "seed_scope") + const rules = rulesByRateId.get(taxRate.id) ?? [] + const hasOnlyProductRules = + rules.length > 0 && + rules.every( + (rule) => rule.reference === "product" && rule.reference_id + ) + + if ( + seedSource !== TAX_METADATA_SOURCE && + !hasOnlyProductRules && + seedScope !== "product" + ) { continue } - const key = buildProductRateKey(countryCode, referenceId) - if (!existingProductByKey.has(key)) { - existingProductByKey.set(key, taxRate) - } + existingProductByKey.set(key, taxRate) } } @@ -445,20 +509,25 @@ export const createTaxRatesStep = createStep( }) } - const productRates = - taxRateTargets.productRatesByCountry.get(countryCode) ?? new Map() - for (const [productId, rate] of productRates.entries()) { + const productRateGroups = + taxRateTargets.productRateGroupsByCountry.get(countryCode) ?? new Map() + for (const [rate, productIds] of productRateGroups.entries()) { if (isSameRate(defaultRate, rate)) { continue } - const key = buildProductRateKey(countryCode, productId) - const code = buildProductRateCode(countryCode, productId) - const name = `VAT ${countryCode.toUpperCase()} product` - const metadata = buildProductRateMetadata(countryCode, productId) - const rules = [{ reference: "product", reference_id: productId }] + const key = buildProductRateKey(countryCode, rate) + const { code, name } = buildProductTaxRateIdentity(countryCode, rate) + const metadata = buildProductRateMetadata(countryCode, rate) const existingProductRate = existingProductByKey.get(key) + const existingRules = existingProductRate + ? (rulesByRateId.get(existingProductRate.id) ?? []) + : [] + const existingProductIds = existingRules + .filter((rule) => rule.reference === "product" && rule.reference_id) + .map((rule) => rule.reference_id) + const rules = buildProductRules([...existingProductIds, ...productIds]) if (!existingProductRate) { createPayloads.push({ @@ -475,7 +544,8 @@ export const createTaxRatesStep = createStep( if ( !isSameRate(existingProductRate.rate, rate) || existingProductRate.code !== code || - existingProductRate.name !== name + existingProductRate.name !== name || + !areProductRulesEqual(buildProductRules(existingProductIds), rules) ) { updatePayloads.push({ selector: { id: existingProductRate.id }, diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts index e35c40ebc..a8e502d89 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { + buildProductTaxRateIdentity, buildTaxRateSeedTargets, HERBATICA_DEFAULT_TAX_RATES, } from "../../../src/workflows/seed/steps/create-tax-rates" @@ -38,10 +39,10 @@ describe("Herbatica tax-rate seed policy", () => { ["sk", 23], ["cz", 19], ]) - expect(mapEntries(targets.productRatesByCountry)).toEqual([]) + expect(mapEntries(targets.productRateGroupsByCountry)).toEqual([]) }) - it("creates Slovakia product overrides only when product VAT differs from Slovak default", () => { + it("groups Slovakia product overrides by VAT rate when product VAT differs from Slovak default", () => { const targets = buildTaxRateSeedTargets( [ { @@ -60,6 +61,14 @@ describe("Herbatica tax-rate seed policy", () => { }, }, }, + { + id: "prod_lower_other", + metadata: { + top_offer: { + vat: 19, + }, + }, + }, { id: "prod_second_lower", metadata: { @@ -86,17 +95,28 @@ describe("Herbatica tax-rate seed policy", () => { ["sk", "cz"] ) - expect(mapEntries(targets.productRatesByCountry)).toEqual([ + expect(mapEntries(targets.productRateGroupsByCountry)).toEqual([ [ "sk", new Map([ - ["prod_lower", 19], - ["prod_second_lower", 5], - ["prod_zero", 0], + [19, ["prod_lower", "prod_lower_other"]], + [5, ["prod_second_lower"]], + [0, ["prod_zero"]], ]), ], ]) - expect(targets.productRatesByCountry.has("cz")).toBe(false) + expect(targets.productRateGroupsByCountry.has("cz")).toBe(false) + }) + + it("names grouped product override rates by country and VAT rate", () => { + expect(buildProductTaxRateIdentity("sk", 19)).toEqual({ + code: "vat_sk_product_19", + name: "VAT SK Product 19%", + }) + expect(buildProductTaxRateIdentity("sk", 5)).toEqual({ + code: "vat_sk_product_5", + name: "VAT SK Product 5%", + }) }) it("does not emit Slovakia product overrides when Slovakia is not an approved target country", () => { @@ -115,6 +135,6 @@ describe("Herbatica tax-rate seed policy", () => { ) expect(mapEntries(targets.defaultRatesByCountry)).toEqual([["cz", 19]]) - expect(mapEntries(targets.productRatesByCountry)).toEqual([]) + expect(mapEntries(targets.productRateGroupsByCountry)).toEqual([]) }) }) From 13872a7d2618e3adba1a88e66459af288031387f Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 19:01:01 +0200 Subject: [PATCH 06/10] refactor(seed): isolate Herbatica Shoptet config --- .../src/scripts/herbatica-seed-config.ts | 226 ++++++++ apps/medusa-be/src/scripts/herbatica-seed.ts | 305 ++++------ apps/medusa-be/src/scripts/seed-dev-data.ts | 4 + apps/medusa-be/src/scripts/seed-n1.ts | 4 + .../seed/steps/create-shipping-options.ts | 2 +- .../workflows/seed/steps/create-tax-rates.ts | 199 +++++-- ...ink-stock-location-fulfillment-provider.ts | 14 +- .../workflows/seed/steps/sync-price-lists.ts | 198 +++++-- .../src/workflows/seed/workflows/index.ts | 1 + .../seed/workflows/seed-categories.ts | 48 +- .../workflows/seed/workflows/seed-database.ts | 539 +++++++++--------- .../src/workflows/seed/workflows/seed-n1.ts | 392 ++++++------- .../seed/workflows/seed-paykit-regions.ts | 112 ++-- .../seed/workflows/seed-shoptet-import.ts | 26 + .../herbatica/herbatica-seed.unit.spec.ts | 60 +- .../herbatica-tax-rates.unit.spec.ts | 29 +- 16 files changed, 1340 insertions(+), 819 deletions(-) create mode 100644 apps/medusa-be/src/scripts/herbatica-seed-config.ts create mode 100644 apps/medusa-be/src/workflows/seed/workflows/seed-shoptet-import.ts diff --git a/apps/medusa-be/src/scripts/herbatica-seed-config.ts b/apps/medusa-be/src/scripts/herbatica-seed-config.ts new file mode 100644 index 000000000..0c8c25440 --- /dev/null +++ b/apps/medusa-be/src/scripts/herbatica-seed-config.ts @@ -0,0 +1,226 @@ +import { resolve } from "node:path" +import type { TaxRateSeedConfig } from "../workflows/seed/steps/create-tax-rates" +import type { SyncPriceListsStepConfig } from "../workflows/seed/steps/sync-price-lists" +import type { SeedDatabaseWorkflowInput } from "../workflows/seed/workflows/seed-database" + +export const HERBATICA_PRODUCTS_XML_ENV = "HERBATICA_XML_PATH" +export const HERBATICA_CATEGORIES_XML_ENV = "HERBATICA_CATEGORIES_XML_PATH" +export const HERBATICA_PROMO_REBASE_DAYS_ENV = "HERBATICA_PROMO_REBASE_DAYS" + +export const HERBATICA_PRODUCTS_XML_PATHS = [ + resolve(__dirname, "seed-files/productsComplete.xml"), +] as const + +export const HERBATICA_CATEGORIES_XML_PATHS = [ + resolve(__dirname, "seed-files/categories.xml"), +] as const + +export const HERBATICA_COUNTRIES = [ + "cz", + "gb", + "de", + "dk", + "se", + "fr", + "es", + "it", + "pl", + "at", + "sk", +] as const + +export const HERBATICA_DEFAULT_STOCK_LOCATION = { + name: "European Warehouse", + address: { + city: "Copenhagen", + country_code: "DK", + address_1: "", + }, +} satisfies SeedDatabaseWorkflowInput["stockLocations"]["locations"][number] + +export const HERBATICA_FALLBACK_SHOPTET_WAREHOUSE = { + name: "Shoptet Warehouse", + address: { + address_1: "Shoptet Warehouse", + city: "Unknown", + country_code: "SK", + }, +} as const + +export const HERBATICA_DEFAULT_PRICELIST_LABEL = "Default pricelist" +export const HERBATICA_SALE_PRICE_LIST_TITLE_TEMPLATE = + "Herbatica sale - {sourceTitle} - {windowLabel}" + +export const HERBATICA_DEFAULT_SHOPTET_PRICELIST_TITLES = [ + "hlavny cennik", + "default pricelist", +] as const + +export const HERBATICA_PRICE_LIST_SYNC_CONFIG = { + metadataSource: "herbatica-products-complete-xml", + logLabel: "Herbatica price lists", + descriptions: { + override: "Herbatica Shoptet price list: {title}", + sale: "Herbatica sale prices for {sourceTitle}", + }, + sourceTypes: { + override: "shoptet_pricelist", + sale: "shoptet_sale", + customerGroup: "shoptet_pricelist_customer_group", + }, + metadataKeys: { + priceListTitle: "shoptet_pricelist_title", + startsAt: "starts_at", + endsAt: "ends_at", + }, +} satisfies SyncPriceListsStepConfig + +export const HERBATICA_DEFAULT_TAX_RATES = [ + { countryCode: "sk", rate: 23 }, + { countryCode: "cz", rate: 19 }, +] as const + +export const HERBATICA_TAX_RATE_CONFIG = { + metadataSource: "herbatica-seed-tax-rates", + defaultRates: [...HERBATICA_DEFAULT_TAX_RATES], + productOverrides: { + countryCode: "sk", + metadataPath: ["top_offer", "vat"], + groupByRate: true, + }, + defaultRateNameTemplate: "VAT {COUNTRY}", + defaultRateCodeTemplate: "vat_{country}", + productRateNameTemplate: "VAT {COUNTRY} Product {rate}%", + productRateCodeTemplate: "vat_{country}_product_{rate_code}", +} satisfies TaxRateSeedConfig + +export const HERBATICA_WORKFLOW_DEFAULTS = { + fulfillmentProviderId: "manual_manual", + shippingOptionPriceAmount: 10, +} satisfies NonNullable + +export const HERBATICA_CURRENCIES = [ + { + code: "czk", + default: true, + }, + { + code: "eur", + default: false, + }, + { + code: "usd", + default: false, + }, +] satisfies SeedDatabaseWorkflowInput["currencies"] + +export const HERBATICA_SALES_CHANNELS = [ + { + name: "Default Sales Channel", + default: true, + }, +] satisfies SeedDatabaseWorkflowInput["salesChannels"] + +export const HERBATICA_DEFAULT_REGIONS = [ + { + name: "Czechia", + currencyCode: "czk", + countries: ["cz"], + paymentProviders: undefined, + isTaxInclusive: true, + }, + { + name: "Europe", + currencyCode: "eur", + countries: HERBATICA_COUNTRIES.filter((country) => country !== "cz"), + paymentProviders: undefined, + isTaxInclusive: true, + }, +] satisfies SeedDatabaseWorkflowInput["regions"] + +export const HERBATICA_DEFAULT_SHIPPING_PROFILE = { + name: "Default Shipping Profile", +} satisfies SeedDatabaseWorkflowInput["defaultShippingProfile"] + +export const HERBATICA_DEFAULT_FULFILLMENT_SET = { + name: "European Warehouse delivery", + type: "shipping", + serviceZoneName: "Europe", +} as const + +export const HERBATICA_SHIPPING_OPTIONS = [ + { + name: "Standard Shipping", + providerId: HERBATICA_WORKFLOW_DEFAULTS.fulfillmentProviderId, + type: { + label: "Standard", + description: "Ship in 2-3 days.", + code: "standard", + }, + prices: [ + { + currencyCode: "usd", + amount: 10, + }, + { + currencyCode: "eur", + amount: 10, + }, + { + currencyCode: "czk", + amount: 250, + }, + ], + rules: [ + { + attribute: "enabled_in_store", + value: "true", + operator: "eq", + }, + { + attribute: "is_return", + value: "false", + operator: "eq", + }, + ], + }, + { + name: "Express Shipping", + providerId: HERBATICA_WORKFLOW_DEFAULTS.fulfillmentProviderId, + type: { + label: "Express", + description: "Ship in 24 hours.", + code: "express", + }, + prices: [ + { + currencyCode: "usd", + amount: 10, + }, + { + currencyCode: "eur", + amount: 10, + }, + { + currencyCode: "czk", + amount: 250, + }, + ], + rules: [ + { + attribute: "enabled_in_store", + value: "true", + operator: "eq", + }, + { + attribute: "is_return", + value: "false", + operator: "eq", + }, + ], + }, +] satisfies SeedDatabaseWorkflowInput["shippingOptions"] + +export const HERBATICA_PUBLISHABLE_KEY = { + title: "Webshop", +} satisfies SeedDatabaseWorkflowInput["publishableKey"] diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index 4c015b604..c17f8207c 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto" import { existsSync } from "node:fs" -import { resolve } from "node:path" import type { ExecArgs, IFulfillmentModuleService, @@ -12,15 +11,37 @@ import { Modules, ProductStatus, } from "@medusajs/framework/utils" -import seedDatabaseWorkflow, { - type SeedDatabaseWorkflowInput, -} from "../workflows/seed/workflows/seed-database" +import type { SeedDatabaseWorkflowInput } from "../workflows/seed/workflows/seed-database" +import seedShoptetImportWorkflow from "../workflows/seed/workflows/seed-shoptet-import" import { excerptPlainText, type HerbaticaCategoryExport, parseHerbaticaCategoriesXmlSource, readXmlSource, } from "./herbatica-category-export" +import { + HERBATICA_CATEGORIES_XML_ENV, + HERBATICA_CATEGORIES_XML_PATHS, + HERBATICA_COUNTRIES, + HERBATICA_CURRENCIES, + HERBATICA_DEFAULT_FULFILLMENT_SET, + HERBATICA_DEFAULT_PRICELIST_LABEL, + HERBATICA_DEFAULT_REGIONS, + HERBATICA_DEFAULT_SHIPPING_PROFILE, + HERBATICA_DEFAULT_SHOPTET_PRICELIST_TITLES, + HERBATICA_DEFAULT_STOCK_LOCATION, + HERBATICA_FALLBACK_SHOPTET_WAREHOUSE, + HERBATICA_PRICE_LIST_SYNC_CONFIG, + HERBATICA_PRODUCTS_XML_ENV, + HERBATICA_PRODUCTS_XML_PATHS, + HERBATICA_PROMO_REBASE_DAYS_ENV, + HERBATICA_PUBLISHABLE_KEY, + HERBATICA_SALE_PRICE_LIST_TITLE_TEMPLATE, + HERBATICA_SALES_CHANNELS, + HERBATICA_SHIPPING_OPTIONS, + HERBATICA_TAX_RATE_CONFIG, + HERBATICA_WORKFLOW_DEFAULTS, +} from "./herbatica-seed-config" type ProductSeedInput = SeedDatabaseWorkflowInput["products"][number] type VariantSeedInput = NonNullable[number] @@ -254,6 +275,13 @@ type ResolvedFeedPaths = { categoriesXmlPath?: string } +type HerbaticaWorkflowInputOptions = { + regionsInput: SeedDatabaseWorkflowInput["regions"] + fulfillmentSetName: string + fulfillmentSetType: string + serviceZoneName: string +} + type SeedBuildOptions = { referenceDate?: Date promoRebaseDays?: number @@ -281,44 +309,19 @@ type BuildVariantsForProductOptions = { referenceDate?: Date } -const DEFAULT_STOCK_LOCATION_NAME = "European Warehouse" -const FALLBACK_SHOPTET_WAREHOUSE_NAME = "Shoptet Warehouse" -const FALLBACK_SHOPTET_WAREHOUSE_ADDRESS = { - address_1: "Shoptet Warehouse", - city: "Unknown", - country_code: "SK", -} - -const DEFAULT_PRODUCTS_XML_PATHS = [ - resolve(__dirname, "seed-files/productsComplete.xml"), -] as const - -const DEFAULT_CATEGORIES_XML_PATHS = [ - resolve(__dirname, "seed-files/categories.xml"), -] as const - -const DEFAULT_COUNTRIES = [ - "cz", - "gb", - "de", - "dk", - "se", - "fr", - "es", - "it", - "pl", - "at", - "sk", -] as const - +const DEFAULT_STOCK_LOCATION_NAME = HERBATICA_DEFAULT_STOCK_LOCATION.name +const FALLBACK_SHOPTET_WAREHOUSE_NAME = + HERBATICA_FALLBACK_SHOPTET_WAREHOUSE.name +const FALLBACK_SHOPTET_WAREHOUSE_ADDRESS = + HERBATICA_FALLBACK_SHOPTET_WAREHOUSE.address +const DEFAULT_COUNTRIES = HERBATICA_COUNTRIES const MAX_HANDLE_LENGTH = 180 const DEFAULT_OPTION_TITLE = "Variant" const DEFAULT_OPTION_VALUE = "Default" -const DEFAULT_PRICELIST_LABEL = "Default pricelist" -const DEFAULT_SHOPTET_PRICELIST_TITLES = new Set([ - "hlavny cennik", - "default pricelist", -]) +const DEFAULT_PRICELIST_LABEL = HERBATICA_DEFAULT_PRICELIST_LABEL +const DEFAULT_SHOPTET_PRICELIST_TITLES: ReadonlySet = new Set( + HERBATICA_DEFAULT_SHOPTET_PRICELIST_TITLES +) const PRODUCT_CONTENT_SECTION_ORDER: ProductContentSectionKey[] = [ "description", "usage", @@ -1394,7 +1397,10 @@ function buildSalePriceListTitle( ): string { const windowLabel = startsAt || endsAt ? `${startsAt ?? "open"}_${endsAt ?? "open"}` : "undated" - return `Herbatica sale - ${sourceTitle} - ${windowLabel}` + return HERBATICA_SALE_PRICE_LIST_TITLE_TEMPLATE.replace( + "{sourceTitle}", + sourceTitle + ).replace("{windowLabel}", windowLabel) } function rebaseOfferPromotion( @@ -3333,23 +3339,70 @@ export function buildSeedInputFromXml( } } +export function buildHerbaticaSeedWorkflowInput( + parsed: BuildResult, + { + regionsInput, + fulfillmentSetName, + fulfillmentSetType, + serviceZoneName, + }: HerbaticaWorkflowInputOptions +): SeedDatabaseWorkflowInput { + return { + workflowDefaults: HERBATICA_WORKFLOW_DEFAULTS, + salesChannels: HERBATICA_SALES_CHANNELS, + currencies: HERBATICA_CURRENCIES, + regions: regionsInput, + taxRegions: { + countries: [...DEFAULT_COUNTRIES], + taxProviderId: undefined, + }, + taxRates: { + countries: ["sk", "cz"], + config: HERBATICA_TAX_RATE_CONFIG, + }, + stockLocations: { + locations: parsed.stockLocations, + }, + defaultShippingProfile: HERBATICA_DEFAULT_SHIPPING_PROFILE, + fulfillmentSets: { + name: fulfillmentSetName, + type: fulfillmentSetType, + serviceZones: [ + { + name: serviceZoneName, + geoZones: [...DEFAULT_COUNTRIES].map((country) => ({ + countryCode: country, + })), + }, + ], + }, + shippingOptions: HERBATICA_SHIPPING_OPTIONS, + publishableKey: HERBATICA_PUBLISHABLE_KEY, + productCategories: parsed.categories, + products: parsed.products, + priceLists: parsed.priceLists, + priceListSync: HERBATICA_PRICE_LIST_SYNC_CONFIG, + } +} + function resolveProductsXmlPath(args?: string[]): string { const argPath = normalizeInlineText(args?.[0]) if (argPath) { return argPath } - const envPath = normalizeInlineText(process.env.HERBATICA_XML_PATH) + const envPath = normalizeInlineText(process.env[HERBATICA_PRODUCTS_XML_ENV]) if (envPath) { return envPath } - const detectedPath = DEFAULT_PRODUCTS_XML_PATHS.find((path) => + const detectedPath = HERBATICA_PRODUCTS_XML_PATHS.find((path) => existsSync(path) ) if (!detectedPath) { throw new Error( - `Could not find productsComplete.xml. Checked: ${DEFAULT_PRODUCTS_XML_PATHS.join(", ")}` + `Could not find productsComplete.xml. Checked: ${HERBATICA_PRODUCTS_XML_PATHS.join(", ")}` ) } @@ -3362,12 +3415,12 @@ function resolveCategoriesXmlPath(args?: string[]): string | undefined { return argPath } - const envPath = normalizeInlineText(process.env.HERBATICA_CATEGORIES_XML_PATH) + const envPath = normalizeInlineText(process.env[HERBATICA_CATEGORIES_XML_ENV]) if (envPath) { return envPath } - return DEFAULT_CATEGORIES_XML_PATHS.find((path) => existsSync(path)) + return HERBATICA_CATEGORIES_XML_PATHS.find((path) => existsSync(path)) } function resolveFeedPaths(args?: string[]): ResolvedFeedPaths { @@ -3396,7 +3449,7 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { ? await parseHerbaticaCategoriesXmlSource(feedPaths.categoriesXmlPath) : undefined const buildOptions = resolveSeedBuildOptions({ - promoRebaseDays: parsePositiveIntegerEnv("HERBATICA_PROMO_REBASE_DAYS"), + promoRebaseDays: parsePositiveIntegerEnv(HERBATICA_PROMO_REBASE_DAYS_ENV), }) if (buildOptions.promoRebaseDays !== undefined) { @@ -3425,22 +3478,8 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { const regionService = container.resolve(Modules.REGION) const existingRegions = await regionService.listRegions({}) - const defaultRegions: SeedDatabaseWorkflowInput["regions"] = [ - { - name: "Czechia", - currencyCode: "czk", - countries: ["cz"], - paymentProviders: undefined, - isTaxInclusive: true, - }, - { - name: "Europe", - currencyCode: "eur", - countries: DEFAULT_COUNTRIES.filter((country) => country !== "cz"), - paymentProviders: undefined, - isTaxInclusive: true, - }, - ] + const defaultRegions: SeedDatabaseWorkflowInput["regions"] = + HERBATICA_DEFAULT_REGIONS const regionsInput: SeedDatabaseWorkflowInput["regions"] = existingRegions.length === 0 @@ -3473,12 +3512,13 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { existingFulfillmentSetWithEurope ?? existingFulfillmentSets[0] const fulfillmentSetName = - selectedFulfillmentSet?.name ?? "European Warehouse delivery" - const fulfillmentSetType = selectedFulfillmentSet?.type ?? "shipping" + selectedFulfillmentSet?.name ?? HERBATICA_DEFAULT_FULFILLMENT_SET.name + const fulfillmentSetType = + selectedFulfillmentSet?.type ?? HERBATICA_DEFAULT_FULFILLMENT_SET.type const serviceZoneName = selectedFulfillmentSet?.service_zones?.find((zone) => zone.name)?.name ?? selectedFulfillmentSet?.service_zones?.[0]?.name ?? - "Europe" + HERBATICA_DEFAULT_FULFILLMENT_SET.serviceZoneName if (selectedFulfillmentSet) { logger.info( @@ -3486,136 +3526,15 @@ export default async function herbaticaSeed({ container, args }: ExecArgs) { ) } - const input: SeedDatabaseWorkflowInput = { - salesChannels: [ - { - name: "Default Sales Channel", - default: true, - }, - ], - currencies: [ - { - code: "czk", - default: true, - }, - { - code: "eur", - default: false, - }, - { - code: "usd", - default: false, - }, - ], - regions: regionsInput, - taxRegions: { - countries: [...DEFAULT_COUNTRIES], - taxProviderId: undefined, - }, - taxRates: { - fallbackCountryCode: "sk", - countries: ["sk", "cz"], - }, - stockLocations: { - locations: parsed.stockLocations, - }, - defaultShippingProfile: { - name: "Default Shipping Profile", - }, - fulfillmentSets: { - name: fulfillmentSetName, - type: fulfillmentSetType, - serviceZones: [ - { - name: serviceZoneName, - geoZones: [...DEFAULT_COUNTRIES].map((country) => ({ - countryCode: country, - })), - }, - ], - }, - shippingOptions: [ - { - name: "Standard Shipping", - providerId: "manual_manual", - type: { - label: "Standard", - description: "Ship in 2-3 days.", - code: "standard", - }, - prices: [ - { - currencyCode: "usd", - amount: 10, - }, - { - currencyCode: "eur", - amount: 10, - }, - { - currencyCode: "czk", - amount: 250, - }, - ], - rules: [ - { - attribute: "enabled_in_store", - value: "true", - operator: "eq", - }, - { - attribute: "is_return", - value: "false", - operator: "eq", - }, - ], - }, - { - name: "Express Shipping", - providerId: "manual_manual", - type: { - label: "Express", - description: "Ship in 24 hours.", - code: "express", - }, - prices: [ - { - currencyCode: "usd", - amount: 10, - }, - { - currencyCode: "eur", - amount: 10, - }, - { - currencyCode: "czk", - amount: 250, - }, - ], - rules: [ - { - attribute: "enabled_in_store", - value: "true", - operator: "eq", - }, - { - attribute: "is_return", - value: "false", - operator: "eq", - }, - ], - }, - ], - publishableKey: { - title: "Webshop", - }, - productCategories: parsed.categories, - products: parsed.products, - priceLists: parsed.priceLists, - } + const input = buildHerbaticaSeedWorkflowInput(parsed, { + regionsInput, + fulfillmentSetName, + fulfillmentSetType, + serviceZoneName, + }) logger.info("Running Herbatica seed workflow...") - const { result } = await seedDatabaseWorkflow(container).run({ + const { result } = await seedShoptetImportWorkflow(container).run({ input, }) diff --git a/apps/medusa-be/src/scripts/seed-dev-data.ts b/apps/medusa-be/src/scripts/seed-dev-data.ts index 495c82ffb..0fcd5a876 100644 --- a/apps/medusa-be/src/scripts/seed-dev-data.ts +++ b/apps/medusa-be/src/scripts/seed-dev-data.ts @@ -23,6 +23,10 @@ export default async function seedDevData({ container }: ExecArgs) { "sk", ] const input: SeedDatabaseWorkflowInput = { + workflowDefaults: { + fulfillmentProviderId: "manual_manual", + shippingOptionPriceAmount: 10, + }, salesChannels: [ { name: "Default Sales Channel", diff --git a/apps/medusa-be/src/scripts/seed-n1.ts b/apps/medusa-be/src/scripts/seed-n1.ts index c3f8e271a..e43ff4384 100644 --- a/apps/medusa-be/src/scripts/seed-n1.ts +++ b/apps/medusa-be/src/scripts/seed-n1.ts @@ -48,6 +48,10 @@ export default async function seedN1({ container }: ExecArgs) { "sk", ] const input: Omit = { + workflowDefaults: { + fulfillmentProviderId: "manual_manual", + shippingOptionPriceAmount: 10, + }, salesChannels: [ { name: "Default Sales Channel", diff --git a/apps/medusa-be/src/workflows/seed/steps/create-shipping-options.ts b/apps/medusa-be/src/workflows/seed/steps/create-shipping-options.ts index 1557b0e84..99e964c74 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-shipping-options.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-shipping-options.ts @@ -44,7 +44,7 @@ export type CreateShippingOptionsStepSeedInput = Array< CreateShippingOptionsStepInput[0], "serviceZoneId" | "shippingProfileId" | "regions" > & { - providerId?: string // Optional per-option provider, defaults to manual_manual in workflow + providerId?: string } > diff --git a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts index 22a848408..1ac20042c 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts @@ -46,6 +46,20 @@ type ProductTaxSource = { metadata?: Record | null } +export type TaxRateSeedConfig = { + metadataSource: string + defaultRates: { countryCode: string; rate: number }[] + productOverrides?: { + countryCode: string + metadataPath?: string[] + groupByRate?: boolean + } + defaultRateNameTemplate?: string + defaultRateCodeTemplate?: string + productRateNameTemplate?: string + productRateCodeTemplate?: string +} + export type TaxRateSeedTargets = { defaultRatesByCountry: Map productRateGroupsByCountry: Map> @@ -53,18 +67,26 @@ export type TaxRateSeedTargets = { export type CreateTaxRatesStepInput = { productIds: string[] - fallbackCountryCode?: string + enabled?: boolean countries?: string[] + config?: TaxRateSeedConfig } const CreateTaxRatesStepId = "create-tax-rates-seed-step" -const TAX_METADATA_SOURCE = "herbatica-seed-tax-rates" const RATE_EPSILON = 0.0001 -export const HERBATICA_DEFAULT_TAX_RATES = new Map([ - ["sk", 23], - ["cz", 19], -]) -const PRODUCT_OVERRIDE_COUNTRY_CODE = "sk" +const DEFAULT_TAX_RATE_NAME_TEMPLATE = "VAT {COUNTRY}" +const DEFAULT_TAX_RATE_CODE_TEMPLATE = "vat_{country}" +const DEFAULT_PRODUCT_TAX_RATE_NAME_TEMPLATE = "VAT {COUNTRY} Product {rate}%" +const DEFAULT_PRODUCT_TAX_RATE_CODE_TEMPLATE = + "vat_{country}_product_{rate_code}" +const DEFAULT_TAX_RATE_SEED_CONFIG: TaxRateSeedConfig = { + metadataSource: "seed-tax-rates", + defaultRates: [], + defaultRateNameTemplate: DEFAULT_TAX_RATE_NAME_TEMPLATE, + defaultRateCodeTemplate: DEFAULT_TAX_RATE_CODE_TEMPLATE, + productRateNameTemplate: DEFAULT_PRODUCT_TAX_RATE_NAME_TEMPLATE, + productRateCodeTemplate: DEFAULT_PRODUCT_TAX_RATE_CODE_TEMPLATE, +} function asObject(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -131,9 +153,12 @@ function getMetadataString(metadata: TaxRateMetadata | null, key: string) { return normalized || undefined } -function buildDefaultRateMetadata(countryCode: string): TaxRateMetadata { +function buildDefaultRateMetadata( + countryCode: string, + config: TaxRateSeedConfig +): TaxRateMetadata { return { - seed_source: TAX_METADATA_SOURCE, + seed_source: config.metadataSource, seed_scope: "default", seed_country_code: countryCode, } @@ -141,10 +166,11 @@ function buildDefaultRateMetadata(countryCode: string): TaxRateMetadata { function buildProductRateMetadata( countryCode: string, - rate: number + rate: number, + config: TaxRateSeedConfig ): TaxRateMetadata { return { - seed_source: TAX_METADATA_SOURCE, + seed_source: config.metadataSource, seed_scope: "product_rate", seed_country_code: countryCode, seed_rate: formatRateValue(rate), @@ -152,19 +178,25 @@ function buildProductRateMetadata( } function extractProductVat( - metadata: Record | undefined + metadata: Record | undefined, + metadataPath: string[] = ["top_offer", "vat"] ): number | undefined { - const topOffer = asObject(metadata?.top_offer) - if (!topOffer) { - return + let current: unknown = metadata + for (const segment of metadataPath) { + const currentObject = asObject(current) + if (!currentObject) { + return + } + current = currentObject[segment] } - return parseRate(topOffer.vat) + return parseRate(current) } export function buildTaxRateSeedTargets( products: ProductTaxSource[], - requestedCountries: string[] = [] + requestedCountries: string[] = [], + config: TaxRateSeedConfig = DEFAULT_TAX_RATE_SEED_CONFIG ): TaxRateSeedTargets { const requestedCountrySet = new Set( requestedCountries @@ -172,15 +204,35 @@ export function buildTaxRateSeedTargets( .filter((countryCode): countryCode is string => Boolean(countryCode)) ) - const defaultRatesByCountry = new Map( - [...HERBATICA_DEFAULT_TAX_RATES.entries()].filter( - ([countryCode]) => - requestedCountrySet.size === 0 || requestedCountrySet.has(countryCode) - ) + const defaultRatesByCountry = new Map( + config.defaultRates.flatMap(({ countryCode, rate }) => { + const normalizedCountryCode = normalizeCountryCode(countryCode) + const normalizedRate = parseRate(rate) + if (!normalizedCountryCode || normalizedRate === undefined) { + return [] + } + if ( + requestedCountrySet.size > 0 && + !requestedCountrySet.has(normalizedCountryCode) + ) { + return [] + } + return [[normalizedCountryCode, normalizedRate]] + }) ) const productRateGroupsByCountry = new Map>() + const productOverridesCountryCode = normalizeCountryCode( + config.productOverrides?.countryCode + ) + if (!productOverridesCountryCode) { + return { + defaultRatesByCountry, + productRateGroupsByCountry, + } + } + const defaultOverrideRate = defaultRatesByCountry.get( - PRODUCT_OVERRIDE_COUNTRY_CODE + productOverridesCountryCode ) if (defaultOverrideRate === undefined) { @@ -192,7 +244,10 @@ export function buildTaxRateSeedTargets( const slovakiaProductRateGroups = new Map() for (const product of products) { - const vat = extractProductVat(asObject(product.metadata)) + const vat = extractProductVat( + asObject(product.metadata), + config.productOverrides?.metadataPath + ) if (vat === undefined || isSameRate(vat, defaultOverrideRate)) { continue } @@ -204,7 +259,7 @@ export function buildTaxRateSeedTargets( if (slovakiaProductRateGroups.size > 0) { productRateGroupsByCountry.set( - PRODUCT_OVERRIDE_COUNTRY_CODE, + productOverridesCountryCode, slovakiaProductRateGroups ) } @@ -238,19 +293,66 @@ function formatRateValue(rate: number): string { return Number(rate.toFixed(4)).toString() } -function buildProductRateCode(countryCode: string, rate: number): string { - const normalizedRate = formatRateValue(rate).replace(/[^0-9]+/g, "_") - return `vat_${countryCode}_product_${normalizedRate}` +function formatTemplate( + template: string, + countryCode: string, + rate?: number +): string { + const rateValue = rate === undefined ? "" : formatRateValue(rate) + const rateCode = rateValue.replace(/[^0-9]+/g, "_") + return template + .replace(/\{country\}/g, countryCode.toLowerCase()) + .replace(/\{COUNTRY\}/g, countryCode.toUpperCase()) + .replace(/\{rate\}/g, rateValue) + .replace(/\{rate_code\}/g, rateCode) +} + +function buildDefaultRateCode(countryCode: string, config: TaxRateSeedConfig) { + return formatTemplate( + config.defaultRateCodeTemplate ?? DEFAULT_TAX_RATE_CODE_TEMPLATE, + countryCode + ) +} + +function buildDefaultRateName(countryCode: string, config: TaxRateSeedConfig) { + return formatTemplate( + config.defaultRateNameTemplate ?? DEFAULT_TAX_RATE_NAME_TEMPLATE, + countryCode + ) } -function buildProductRateName(countryCode: string, rate: number): string { - return `VAT ${countryCode.toUpperCase()} Product ${formatRateValue(rate)}%` +function buildProductRateCode( + countryCode: string, + rate: number, + config: TaxRateSeedConfig +): string { + return formatTemplate( + config.productRateCodeTemplate ?? DEFAULT_PRODUCT_TAX_RATE_CODE_TEMPLATE, + countryCode, + rate + ) } -export function buildProductTaxRateIdentity(countryCode: string, rate: number) { +function buildProductRateName( + countryCode: string, + rate: number, + config: TaxRateSeedConfig +): string { + return formatTemplate( + config.productRateNameTemplate ?? DEFAULT_PRODUCT_TAX_RATE_NAME_TEMPLATE, + countryCode, + rate + ) +} + +export function buildProductTaxRateIdentity( + countryCode: string, + rate: number, + config: TaxRateSeedConfig = DEFAULT_TAX_RATE_SEED_CONFIG +) { return { - code: buildProductRateCode(countryCode, rate), - name: buildProductRateName(countryCode, rate), + code: buildProductRateCode(countryCode, rate, config), + name: buildProductRateName(countryCode, rate, config), } } @@ -290,6 +392,16 @@ export const createTaxRatesStep = createStep( const created: TaxRateDTO[] = [] const updated: TaxRateDTO[] = [] + if (input.enabled === false) { + return new StepResponse({ + result: { + created, + updated, + }, + }) + } + + const config = input.config ?? DEFAULT_TAX_RATE_SEED_CONFIG const uniqueProductIds = [...new Set(input.productIds)] if (uniqueProductIds.length === 0) { return new StepResponse({ @@ -319,7 +431,8 @@ export const createTaxRatesStep = createStep( const taxRateTargets = buildTaxRateSeedTargets( products, - normalizedSeedCountries + normalizedSeedCountries, + config ) if (taxRateTargets.defaultRatesByCountry.size === 0) { @@ -388,7 +501,7 @@ export const createTaxRatesStep = createStep( ) if ( - seedSource !== TAX_METADATA_SOURCE || + seedSource !== config.metadataSource || seedScope !== "product_rate" || !countryCode || seedRate === undefined @@ -454,7 +567,7 @@ export const createTaxRatesStep = createStep( ) if ( - seedSource !== TAX_METADATA_SOURCE && + seedSource !== config.metadataSource && !hasOnlyProductRules && seedScope !== "product" ) { @@ -477,9 +590,9 @@ export const createTaxRatesStep = createStep( continue } - const defaultName = `VAT ${countryCode.toUpperCase()}` - const defaultCode = `vat_${countryCode}` - const defaultMetadata = buildDefaultRateMetadata(countryCode) + const defaultName = buildDefaultRateName(countryCode, config) + const defaultCode = buildDefaultRateCode(countryCode, config) + const defaultMetadata = buildDefaultRateMetadata(countryCode, config) const existingDefault = existingDefaultByRegionId.get(taxRegion.id) @@ -517,8 +630,12 @@ export const createTaxRatesStep = createStep( } const key = buildProductRateKey(countryCode, rate) - const { code, name } = buildProductTaxRateIdentity(countryCode, rate) - const metadata = buildProductRateMetadata(countryCode, rate) + const { code, name } = buildProductTaxRateIdentity( + countryCode, + rate, + config + ) + const metadata = buildProductRateMetadata(countryCode, rate, config) const existingProductRate = existingProductByKey.get(key) const existingRules = existingProductRate diff --git a/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts b/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts index 9a16593f4..615f0e920 100644 --- a/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts +++ b/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts @@ -5,7 +5,7 @@ import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" export type LinkStockLocationFulfillmentProviderStepInput = { stockLocations: StockLocationDTO[] - fulfillmentProviderIds?: string[] + fulfillmentProviderIds: string[] } const LinkStockLocationFulfillmentProviderStepId = @@ -22,9 +22,15 @@ export const linkStockLocationFulfillmentProviderSeedStep = createStep( logger.info("Linking stock locations to fulfillment providers...") const result: unknown[] = [] - const providerIds = input.fulfillmentProviderIds?.length - ? input.fulfillmentProviderIds - : ["manual_manual"] + const providerIds = [...new Set(input.fulfillmentProviderIds)] + if (providerIds.length === 0) { + logger.warn( + "No fulfillment provider IDs supplied, skipping stock-location fulfillment-provider links." + ) + return new StepResponse({ + result, + }) + } for (const stockLocation of input.stockLocations) { for (const providerId of providerIds) { diff --git a/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts b/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts index 65f2725ff..83583eb0b 100644 --- a/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts +++ b/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts @@ -41,12 +41,53 @@ type SalePriceListInput = { prices: PriceListPriceInput[] } +export type SyncPriceListsStepConfig = { + metadataSource?: string + logLabel?: string + customerGroupRuleAttribute?: string + descriptions?: { + override?: string + sale?: string + } + sourceTypes?: { + override?: string + sale?: string + customerGroup?: string + } + metadataKeys?: { + priceListTitle?: string + startsAt?: string + endsAt?: string + } +} + +type ResolvedSyncPriceListsStepConfig = { + metadataSource: string + logLabel: string + customerGroupRuleAttribute: string + descriptions: { + override: string + sale: string + } + sourceTypes: { + override: string + sale: string + customerGroup: string + } + metadataKeys: { + priceListTitle: string + startsAt: string + endsAt: string + } +} + export type SyncPriceListsStepInput = { productIds: string[] priceLists?: { overrides: OverridePriceListInput[] sales: SalePriceListInput[] } + config?: SyncPriceListsStepConfig } type PriceListSyncEntry = { @@ -75,7 +116,75 @@ type PriceListWithPrices = PriceListDTO & { } const SyncPriceListsStepId = "sync-price-lists-seed-step" -const CUSTOMER_GROUP_RULE_ATTRIBUTE = "customer.groups.id" +const DEFAULT_SYNC_PRICE_LISTS_CONFIG = { + metadataSource: "seed-price-lists", + logLabel: "price lists", + customerGroupRuleAttribute: "customer.groups.id", + descriptions: { + override: "Seed price list: {title}", + sale: "Seed sale prices for {sourceTitle}", + }, + sourceTypes: { + override: "price_list", + sale: "sale", + customerGroup: "price_list_customer_group", + }, + metadataKeys: { + priceListTitle: "source_price_list_title", + startsAt: "starts_at", + endsAt: "ends_at", + }, +} satisfies ResolvedSyncPriceListsStepConfig + +function resolveSyncPriceListsConfig( + config?: SyncPriceListsStepConfig +): ResolvedSyncPriceListsStepConfig { + return { + ...DEFAULT_SYNC_PRICE_LISTS_CONFIG, + ...config, + descriptions: { + ...DEFAULT_SYNC_PRICE_LISTS_CONFIG.descriptions, + ...config?.descriptions, + }, + sourceTypes: { + ...DEFAULT_SYNC_PRICE_LISTS_CONFIG.sourceTypes, + ...config?.sourceTypes, + }, + metadataKeys: { + ...DEFAULT_SYNC_PRICE_LISTS_CONFIG.metadataKeys, + ...config?.metadataKeys, + }, + } +} + +function formatTemplate( + template: string, + values: Record +): string { + return template.replace( + /\{([a-zA-Z0-9_]+)\}/g, + (_match, key: string) => values[key] ?? "" + ) +} + +function buildPriceListMetadata( + config: ResolvedSyncPriceListsStepConfig, + sourceType: string, + priceListTitle: string, + dates?: { startsAt?: string; endsAt?: string } +): Record { + return { + source: config.metadataSource, + source_type: sourceType, + [config.metadataKeys.priceListTitle]: priceListTitle, + ...(dates + ? { + [config.metadataKeys.startsAt]: dates.startsAt, + [config.metadataKeys.endsAt]: dates.endsAt, + } + : {}), + } +} function normalizeCurrencyCode(value: string): string { return value.toLowerCase() @@ -101,7 +210,8 @@ function amountsEqual(left: unknown, right: number): boolean { } function buildPriceListEntries( - priceLists?: SyncPriceListsStepInput["priceLists"] + priceLists?: SyncPriceListsStepInput["priceLists"], + config: ResolvedSyncPriceListsStepConfig = resolveSyncPriceListsConfig() ): PriceListSyncEntry[] { if (!priceLists) { return [] @@ -110,31 +220,39 @@ function buildPriceListEntries( return [ ...priceLists.overrides.map((priceList) => ({ title: priceList.title, - description: `Herbatica Shoptet price list: ${priceList.title}`, + description: formatTemplate(config.descriptions.override, { + title: priceList.title, + sourceTitle: priceList.title, + }), type: "override" as const, customerGroupName: priceList.customerGroupName, prices: priceList.prices, - metadata: { - source: "herbatica-products-complete-xml", - source_type: "shoptet_pricelist", - shoptet_pricelist_title: priceList.title, - }, + metadata: buildPriceListMetadata( + config, + config.sourceTypes.override, + priceList.title + ), })), ...priceLists.sales.map((priceList) => ({ title: priceList.title, - description: `Herbatica sale prices for ${priceList.sourceTitle}`, + description: formatTemplate(config.descriptions.sale, { + title: priceList.title, + sourceTitle: priceList.sourceTitle, + }), type: "sale" as const, startsAt: priceList.startsAt, endsAt: priceList.endsAt, customerGroupName: priceList.customerGroupName, prices: priceList.prices, - metadata: { - source: "herbatica-products-complete-xml", - source_type: "shoptet_sale", - shoptet_pricelist_title: priceList.sourceTitle, - starts_at: priceList.startsAt, - ends_at: priceList.endsAt, - }, + metadata: buildPriceListMetadata( + config, + config.sourceTypes.sale, + priceList.sourceTitle, + { + startsAt: priceList.startsAt, + endsAt: priceList.endsAt, + } + ), })), ] } @@ -163,7 +281,8 @@ function buildVariantLookup( async function ensureCustomerGroups( entries: PriceListSyncEntry[], customerService: ICustomerModuleService, - container: Parameters[0] + container: Parameters[0], + config: ResolvedSyncPriceListsStepConfig ): Promise> { const names = [ ...new Set( @@ -180,9 +299,9 @@ async function ensureCustomerGroups( { take: 1 } ) const metadata = { - source: "herbatica-products-complete-xml", - source_type: "shoptet_pricelist_customer_group", - shoptet_pricelist_title: name, + source: config.metadataSource, + source_type: config.sourceTypes.customerGroup, + [config.metadataKeys.priceListTitle]: name, } if (existing[0]) { @@ -235,7 +354,8 @@ async function findPriceListByTitle( function buildRules( entry: PriceListSyncEntry, - customerGroups: Map + customerGroups: Map, + config: ResolvedSyncPriceListsStepConfig ): Record | undefined { if (!entry.customerGroupName) { return @@ -247,20 +367,27 @@ function buildRules( } return { - [CUSTOMER_GROUP_RULE_ATTRIBUTE]: [customerGroup.id], + [config.customerGroupRuleAttribute]: [customerGroup.id], } } -async function ensurePriceLists( - entries: PriceListSyncEntry[], - pricingService: IPricingModuleService, - customerGroups: Map, +async function ensurePriceLists({ + entries, + pricingService, + customerGroups, + container, + config, +}: { + entries: PriceListSyncEntry[] + pricingService: IPricingModuleService + customerGroups: Map container: Parameters[0] -): Promise> { + config: ResolvedSyncPriceListsStepConfig +}): Promise> { const result = new Map() for (const entry of entries) { - const rules = buildRules(entry, customerGroups) + const rules = buildRules(entry, customerGroups, config) const existing = await findPriceListByTitle(pricingService, entry.title) const data = { title: entry.title, @@ -462,7 +589,8 @@ function buildPriceListPriceChanges({ export const syncPriceListsStep = createStep( SyncPriceListsStepId, async (input: SyncPriceListsStepInput, { container }) => { - const entries = buildPriceListEntries(input.priceLists) + const config = resolveSyncPriceListsConfig(input.config) + const entries = buildPriceListEntries(input.priceLists, config) const logger = container.resolve(ContainerRegistrationKeys.LOGGER) if (!entries.length) { @@ -497,14 +625,16 @@ export const syncPriceListsStep = createStep( const customerGroups = await ensureCustomerGroups( entries, customerService, - container + container, + config ) - const priceListsByTitle = await ensurePriceLists( + const priceListsByTitle = await ensurePriceLists({ entries, pricingService, customerGroups, - container - ) + container, + config, + }) const variantIds = [ ...new Set( entries.flatMap((entry) => @@ -537,7 +667,7 @@ export const syncPriceListsStep = createStep( }) logger.info( - `Synced ${priceListsByTitle.size} Herbatica price lists, created ${priceSyncResult.created} prices, updated ${priceSyncResult.updated} prices` + `Synced ${priceListsByTitle.size} ${config.logLabel}, created ${priceSyncResult.created} prices, updated ${priceSyncResult.updated} prices` ) return new StepResponse({ diff --git a/apps/medusa-be/src/workflows/seed/workflows/index.ts b/apps/medusa-be/src/workflows/seed/workflows/index.ts index 24ab5f6c8..08c159d49 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/index.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/index.ts @@ -1 +1,2 @@ export * from "./seed-database" +export * from "./seed-shoptet-import" diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-categories.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-categories.ts index 41e8509b9..4f7ca54a9 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-categories.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-categories.ts @@ -14,31 +14,33 @@ export type CategoryRaw = { } const seedCategoriesWorkflowId = "seed-categories-workflow" -const seedCategoriesWorkflow = createWorkflow( - seedCategoriesWorkflowId, - (input: CategoryRaw[]) => { - const productCategories: Steps.CreateProductCategoriesStepInput = transform( - { - input, - }, - (data) => - data.input.map((i) => ({ - name: i.title, - description: i.description, - handle: i.handle, - isActive: Boolean(Number(i.isActive)), - parentHandle: i.parentHandle, - })) - ) +function seedCategoriesWorkflowComposer(input: CategoryRaw[]) { + const productCategories: Steps.CreateProductCategoriesStepInput = transform( + { + input, + }, + (data) => + data.input.map((i) => ({ + name: i.title, + description: i.description, + handle: i.handle, + isActive: Boolean(Number(i.isActive)), + parentHandle: i.parentHandle, + })) + ) + + Steps.createProductCategoriesStep(productCategories) - Steps.createProductCategoriesStep(productCategories) + return new WorkflowResponse({ + result: { + message: "Categories seeded successfully", + }, + }) +} - return new WorkflowResponse({ - result: { - message: "Categories seeded successfully", - }, - }) - } +const seedCategoriesWorkflow = createWorkflow( + seedCategoriesWorkflowId, + seedCategoriesWorkflowComposer ) export default seedCategoriesWorkflow diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts index 288ca8c53..173d74b37 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts @@ -8,11 +8,15 @@ import * as Steps from "../steps" const SeedDatabaseWorkflowId = "seed-database-workflow" export type SeedDatabaseWorkflowInput = { + workflowDefaults: { + fulfillmentProviderId: string + shippingOptionPriceAmount: number + } salesChannels: Steps.CreateSalesChannelsStepInput currencies: Steps.UpdateStoreCurrenciesStepCurrenciesInput regions: Steps.CreateRegionsStepInput taxRegions: Steps.CreateTaxRegionsStepInput - taxRates?: Omit + taxRates?: Omit stockLocations: Steps.CreateStockLocationStepInput defaultShippingProfile: Steps.CreateDefaultShippingProfileStepInput fulfillmentSets: Steps.CreateFulfillmentSetStepInput @@ -21,6 +25,7 @@ export type SeedDatabaseWorkflowInput = { productCategories: Steps.CreateProductCategoriesStepInput products: Steps.CreateProductsStepInput priceLists?: Steps.SyncPriceListsStepInput["priceLists"] + priceListSync?: Steps.SyncPriceListsStepInput["config"] } function buildInventoryItemsInput( @@ -55,319 +60,319 @@ function buildInventoryItemsInput( return inventoryItems } -const seedDatabaseWorkflow = createWorkflow( - SeedDatabaseWorkflowId, - (input: SeedDatabaseWorkflowInput) => { - // create sales channels - const salesChannelsResult = Steps.createSalesChannelsStep( - input.salesChannels - ) +function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { + // create sales channels + const salesChannelsResult = Steps.createSalesChannelsStep(input.salesChannels) + + // update store currencies + const updateStoreCurrenciesStepInput = transform( + { + input, + salesChannelsResult, + }, + (data) => { + const defaultSalesChannel = data.salesChannelsResult.result.find( + (i) => i.isDefault + ) + if (!defaultSalesChannel) { + throw new Error("No default sales channel found") + } + return { + currencies: data.input.currencies, + defaultSalesChannelId: defaultSalesChannel.id, + } + } + ) + const updateStoreCurrenciesResult = Steps.updateStoreCurrenciesStep( + updateStoreCurrenciesStepInput + ) + + // create regions + const createRegionsResult = Steps.createRegionsStep(input.regions) - // update store currencies - const updateStoreCurrenciesStepInput = transform( + const ensurePricePreferencesStepInput: Steps.EnsurePricePreferencesStepInput = + transform( { + createRegionsResult, input, - salesChannelsResult, }, - (data) => { - const defaultSalesChannel = data.salesChannelsResult.result.find( - (i) => i.isDefault - ) - if (!defaultSalesChannel) { - throw new Error("No default sales channel found") - } - return { - currencies: data.input.currencies, - defaultSalesChannelId: defaultSalesChannel.id, - } - } - ) - const updateStoreCurrenciesResult = Steps.updateStoreCurrenciesStep( - updateStoreCurrenciesStepInput + (data) => ({ + regionIds: data.createRegionsResult.result.map((region) => region.id), + currencyCodes: data.input.currencies.map((currency) => currency.code), + isTaxInclusive: true, + }) ) - // create regions - const createRegionsResult = Steps.createRegionsStep(input.regions) - - const ensurePricePreferencesStepInput: Steps.EnsurePricePreferencesStepInput = - transform( - { - createRegionsResult, - input, - }, - (data) => ({ - regionIds: data.createRegionsResult.result.map((region) => region.id), - currencyCodes: data.input.currencies.map((currency) => currency.code), - isTaxInclusive: true, - }) - ) + const ensurePricePreferencesResult = Steps.ensurePricePreferencesStep( + ensurePricePreferencesStepInput + ) - const ensurePricePreferencesResult = Steps.ensurePricePreferencesStep( - ensurePricePreferencesStepInput - ) + // create tax regions + const createTaxRegionsResult = Steps.createTaxRegionsStep(input.taxRegions) - // create tax regions - const createTaxRegionsResult = Steps.createTaxRegionsStep(input.taxRegions) + // create stock locations + const createStockLocationResult = Steps.createStockLocationSeedStep( + input.stockLocations + ) - // create stock locations - const createStockLocationResult = Steps.createStockLocationSeedStep( - input.stockLocations + // link stock locations to fulfillment providers (derived from shipping options) + const linkStockLocationsFulfillmentProviderInput: Steps.LinkStockLocationFulfillmentProviderStepInput = + transform( + { + createStockLocationResult, + input, + }, + (data) => ({ + stockLocations: data.createStockLocationResult.result, + fulfillmentProviderIds: [ + ...new Set( + data.input.shippingOptions.map( + (opt) => + opt.providerId ?? + data.input.workflowDefaults.fulfillmentProviderId + ) + ), + ], + }) ) - // link stock locations to fulfillment providers (derived from shipping options) - const linkStockLocationsFulfillmentProviderInput: Steps.LinkStockLocationFulfillmentProviderStepInput = - transform( - { - createStockLocationResult, - input, - }, - (data) => ({ - stockLocations: data.createStockLocationResult.result, - fulfillmentProviderIds: [ - ...new Set( - data.input.shippingOptions.map( - (opt) => opt.providerId || "manual_manual" - ) - ), - ], - }) - ) + const linkStockLocationsFulfillmentProviderResult = + Steps.linkStockLocationFulfillmentProviderSeedStep( + linkStockLocationsFulfillmentProviderInput + ) - const linkStockLocationsFulfillmentProviderResult = - Steps.linkStockLocationFulfillmentProviderSeedStep( - linkStockLocationsFulfillmentProviderInput - ) + // create a shipping profile + const createDefaultShippingProfileResult = + Steps.createDefaultShippingProfileStep(input.defaultShippingProfile) - // create a shipping profile - const createDefaultShippingProfileResult = - Steps.createDefaultShippingProfileStep(input.defaultShippingProfile) + // create fulfillment sets + const createFulfillmentSetsResult = Steps.createFulfillmentSetStep( + input.fulfillmentSets + ) - // create fulfillment sets - const createFulfillmentSetsResult = Steps.createFulfillmentSetStep( - input.fulfillmentSets - ) + // link stock locations to fulfillment set + const linkStockLocationsFulfillmentSetInput: Steps.LinkStockLocationFulfillmentSetStepInput = + transform( + { + createStockLocationResult, + input, + createFulfillmentSetsResult, + }, + (data) => { + const fulfillmentSet = data.createFulfillmentSetsResult.result[0] - // link stock locations to fulfillment set - const linkStockLocationsFulfillmentSetInput: Steps.LinkStockLocationFulfillmentSetStepInput = - transform( - { - createStockLocationResult, - input, - createFulfillmentSetsResult, - }, - (data) => { - const fulfillmentSet = data.createFulfillmentSetsResult.result[0] - - if (!fulfillmentSet) { - throw new Error("No fulfillment set found") - } - - return { - stockLocations: data.createStockLocationResult.result, - fulfillmentSet, - } + if (!fulfillmentSet) { + throw new Error("No fulfillment set found") } - ) - const linkStockLocationsFulfillmentSetResult = - Steps.linkStockLocationFulfillmentSetStep( - linkStockLocationsFulfillmentSetInput - ) - - // create shipping options - - const createShippingOptionsInput: Steps.CreateShippingOptionsStepInput = - transform( - { - input, - createFulfillmentSetsResult, - createDefaultShippingProfileResult, - createRegionsResult, - }, - (data) => { - const fulfillmentSet = data.createFulfillmentSetsResult.result[0] - const shippingProfile = - data.createDefaultShippingProfileResult.result[0] - const serviceZone = fulfillmentSet?.service_zones?.[0] - - if (!serviceZone?.id) { - throw new Error("No service zone found in fulfillment set") - } - - if (!shippingProfile?.id) { - throw new Error("No shipping profile found") - } - - return data.input.shippingOptions.map((option) => ({ - name: option.name, - providerId: option.providerId || "manual_manual", - serviceZoneId: serviceZone.id, - shippingProfileId: shippingProfile.id, - regions: data.createRegionsResult.result.map((region) => ({ - ...region, - amount: - option.prices.find( - (p) => - p.currencyCode?.toLowerCase() === - region.currency_code?.toLowerCase() - )?.amount ?? 10, - })), - type: option.type, - prices: option.prices, - rules: option.rules, - data: option.data, - })) + return { + stockLocations: data.createStockLocationResult.result, + fulfillmentSet, } - ) - - const createShippingOptionsResult = Steps.createShippingOptionsStep( - createShippingOptionsInput + } ) - // link sales channels to stock location - const linkSalesChannelsToStockLocationInput: Steps.LinkSalesChannelsStockLocationStepInput = - transform( - { - createStockLocationResult, - input, - salesChannelsResult, - }, - (data) => ({ - stockLocations: data.createStockLocationResult.result, - salesChannels: data.salesChannelsResult.result, - }) - ) - - const linkSalesChannelsToStockLocationResult = - Steps.linkSalesChannelsStockLocationStep( - linkSalesChannelsToStockLocationInput - ) + const linkStockLocationsFulfillmentSetResult = + Steps.linkStockLocationFulfillmentSetStep( + linkStockLocationsFulfillmentSetInput + ) - // create publishable key + // create shipping options - const createPublishableKeyResult = Steps.createPublishableKeyStep( - input.publishableKey - ) + const createShippingOptionsInput: Steps.CreateShippingOptionsStepInput = + transform( + { + input, + createFulfillmentSetsResult, + createDefaultShippingProfileResult, + createRegionsResult, + }, + (data) => { + const fulfillmentSet = data.createFulfillmentSetsResult.result[0] + const shippingProfile = + data.createDefaultShippingProfileResult.result[0] + const serviceZone = fulfillmentSet?.service_zones?.[0] - // link publishable key to salesChannels - const linkSalesChannelsApiKeyStepInput: Steps.LinkSalesChannelsApiKeyStepInput = - transform( - { - createPublishableKeyResult, - salesChannelsResult, - }, - (data) => { - const publishableApiKey = data.createPublishableKeyResult.result[0] - - if (!publishableApiKey) { - throw new Error("No publishable API key found") - } - - return { - salesChannels: data.salesChannelsResult.result, - publishableApiKey, - } + if (!serviceZone?.id) { + throw new Error("No service zone found in fulfillment set") } - ) - const linkSalesChannelsApiKeyStepInputResult = - Steps.linkSalesChannelsApiKeyStep(linkSalesChannelsApiKeyStepInput) - - // create product categories + if (!shippingProfile?.id) { + throw new Error("No shipping profile found") + } - const createProductCategoriesResult = Steps.createProductCategoriesStep( - input.productCategories + return data.input.shippingOptions.map((option) => ({ + name: option.name, + providerId: + option.providerId ?? + data.input.workflowDefaults.fulfillmentProviderId, + serviceZoneId: serviceZone.id, + shippingProfileId: shippingProfile.id, + regions: data.createRegionsResult.result.map((region) => ({ + ...region, + amount: + option.prices.find( + (p) => + p.currencyCode?.toLowerCase() === + region.currency_code?.toLowerCase() + )?.amount ?? + data.input.workflowDefaults.shippingOptionPriceAmount, + })), + type: option.type, + prices: option.prices, + rules: option.rules, + data: option.data, + })) + } ) - // create products + const createShippingOptionsResult = Steps.createShippingOptionsStep( + createShippingOptionsInput + ) - const createProductsStepInput: Steps.CreateProductsStepInput = transform( + // link sales channels to stock location + const linkSalesChannelsToStockLocationInput: Steps.LinkSalesChannelsStockLocationStepInput = + transform( { + createStockLocationResult, input, - createProductCategoriesResult, salesChannelsResult, - createDefaultShippingProfileResult, }, - (data) => data.input.products + (data) => ({ + stockLocations: data.createStockLocationResult.result, + salesChannels: data.salesChannelsResult.result, + }) ) - const createProductsResult = Steps.createProductsStep( - createProductsStepInput + const linkSalesChannelsToStockLocationResult = + Steps.linkSalesChannelsStockLocationStep( + linkSalesChannelsToStockLocationInput ) - const syncPriceListsInput: Steps.SyncPriceListsStepInput = transform( + // create publishable key + + const createPublishableKeyResult = Steps.createPublishableKeyStep( + input.publishableKey + ) + + // link publishable key to salesChannels + const linkSalesChannelsApiKeyStepInput: Steps.LinkSalesChannelsApiKeyStepInput = + transform( { - createProductsResult, - input, + createPublishableKeyResult, + salesChannelsResult, }, - (data) => ({ - productIds: data.createProductsResult.result, - priceLists: data.input.priceLists, - }) - ) + (data) => { + const publishableApiKey = data.createPublishableKeyResult.result[0] - const syncPriceListsResult = Steps.syncPriceListsStep(syncPriceListsInput) - - const createTaxRatesStepInput: Steps.CreateTaxRatesStepInput | undefined = - input.taxRates - ? transform( - { - createProductsResult, - createTaxRegionsResult, - input, - }, - (data) => ({ - fallbackCountryCode: data.input.taxRates?.fallbackCountryCode, - countries: data.input.taxRates?.countries, - productIds: data.createProductsResult.result, - }) - ) - : undefined - - const createTaxRatesResult = createTaxRatesStepInput - ? Steps.createTaxRatesStep(createTaxRatesStepInput) - : undefined - - // create inventory levels - const createInventoryLevelsInput: Steps.CreateInventoryLevelsStepInput = - transform( - { - createStockLocationResult, - createProductsResult, - input, - }, - (data) => ({ - stockLocations: data.createStockLocationResult.result, - inventoryItems: buildInventoryItemsInput(data.input.products), - }) - ) + if (!publishableApiKey) { + throw new Error("No publishable API key found") + } - const createInventoryLevelsResult = Steps.createInventoryLevelsStep( - createInventoryLevelsInput + return { + salesChannels: data.salesChannelsResult.result, + publishableApiKey, + } + } ) - return new WorkflowResponse({ + const linkSalesChannelsApiKeyStepInputResult = + Steps.linkSalesChannelsApiKeyStep(linkSalesChannelsApiKeyStepInput) + + // create product categories + + const createProductCategoriesResult = Steps.createProductCategoriesStep( + input.productCategories + ) + + // create products + + const createProductsStepInput: Steps.CreateProductsStepInput = transform( + { + input, + createProductCategoriesResult, salesChannelsResult, - updateStoreCurrenciesResult, - createRegionsResult, - ensurePricePreferencesResult, - createTaxRegionsResult, - createStockLocationResult, - linkStockLocationsFulfillmentProviderResult, createDefaultShippingProfileResult, - createFulfillmentSetsResult, - linkStockLocationsFulfillmentSetResult, - createShippingOptionsResult, - linkSalesChannelsToStockLocationResult, - createPublishableKeyResult, - linkSalesChannelsApiKeyStepInputResult, - createProductCategoriesResult, + }, + (data) => data.input.products + ) + + const createProductsResult = Steps.createProductsStep(createProductsStepInput) + + const syncPriceListsInput: Steps.SyncPriceListsStepInput = transform( + { createProductsResult, - syncPriceListsResult, - createTaxRatesResult, - createInventoryLevelsResult, + input, + }, + (data) => ({ + productIds: data.createProductsResult.result, + priceLists: data.input.priceLists, + config: data.input.priceListSync, }) - } + ) + + const syncPriceListsResult = Steps.syncPriceListsStep(syncPriceListsInput) + + const createTaxRatesStepInput: Steps.CreateTaxRatesStepInput = transform( + { + createProductsResult, + createTaxRegionsResult, + input, + }, + (data) => ({ + enabled: Boolean(data.input.taxRates), + countries: data.input.taxRates?.countries, + config: data.input.taxRates?.config, + productIds: data.input.taxRates ? data.createProductsResult.result : [], + }) + ) + + const createTaxRatesResult = Steps.createTaxRatesStep(createTaxRatesStepInput) + + // create inventory levels + const createInventoryLevelsInput: Steps.CreateInventoryLevelsStepInput = + transform( + { + createStockLocationResult, + createProductsResult, + input, + }, + (data) => ({ + stockLocations: data.createStockLocationResult.result, + inventoryItems: buildInventoryItemsInput(data.input.products), + }) + ) + + const createInventoryLevelsResult = Steps.createInventoryLevelsStep( + createInventoryLevelsInput + ) + + return new WorkflowResponse({ + salesChannelsResult, + updateStoreCurrenciesResult, + createRegionsResult, + ensurePricePreferencesResult, + createTaxRegionsResult, + createStockLocationResult, + linkStockLocationsFulfillmentProviderResult, + createDefaultShippingProfileResult, + createFulfillmentSetsResult, + linkStockLocationsFulfillmentSetResult, + createShippingOptionsResult, + linkSalesChannelsToStockLocationResult, + createPublishableKeyResult, + linkSalesChannelsApiKeyStepInputResult, + createProductCategoriesResult, + createProductsResult, + syncPriceListsResult, + createTaxRatesResult, + createInventoryLevelsResult, + }) +} + +const seedDatabaseWorkflow = createWorkflow( + SeedDatabaseWorkflowId, + seedDatabaseWorkflowComposer ) export default seedDatabaseWorkflow diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts index 0380e5ba4..e5f5a9be2 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts @@ -23,6 +23,10 @@ type RawProductRecord = { } export type SeedN1WorkflowInput = { + workflowDefaults: { + fulfillmentProviderId: string + shippingOptionPriceAmount: number + } categories: CategoryRaw[] products: RawProductRecord[] salesChannels: Steps.CreateSalesChannelsStepInput @@ -68,235 +72,235 @@ function buildInventoryItemsInput( return inventoryItems } -const seedN1Workflow = createWorkflow( - seedN1WorkflowId, - (input: SeedN1WorkflowInput) => { - // create sales channels - const salesChannelsResult = Steps.createSalesChannelsStep( - input.salesChannels +function seedN1WorkflowComposer(input: SeedN1WorkflowInput) { + // create sales channels + const salesChannelsResult = Steps.createSalesChannelsStep(input.salesChannels) + + // update store currencies + const updateStoreCurrenciesStepInput = transform( + { + input, + salesChannelsResult, + }, + (data) => { + const defaultSalesChannel = data.salesChannelsResult.result.find( + (i) => i.isDefault + ) + if (!defaultSalesChannel) { + throw new Error("No default sales channel found") + } + return { + currencies: data.input.currencies, + defaultSalesChannelId: defaultSalesChannel.id, + } + } + ) + Steps.updateStoreCurrenciesStep(updateStoreCurrenciesStepInput) + + // create regions + const createRegionsResult = Steps.createRegionsStep(input.regions) + + // create tax regions + Steps.createTaxRegionsStep(input.taxRegions) + + // create stock locations + const createStockLocationResult = Steps.createStockLocationSeedStep( + input.stockLocations + ) + + // link stock locations to fulfillment providers (derived from shipping options) + const linkStockLocationsFulfillmentProviderInput: Steps.LinkStockLocationFulfillmentProviderStepInput = + transform( + { + createStockLocationResult, + input, + }, + (data) => ({ + stockLocations: data.createStockLocationResult.result, + fulfillmentProviderIds: [ + ...new Set( + data.input.shippingOptions.map( + (opt) => + opt.providerId ?? + data.input.workflowDefaults.fulfillmentProviderId + ) + ), + ], + }) ) - // update store currencies - const updateStoreCurrenciesStepInput = transform( + Steps.linkStockLocationFulfillmentProviderSeedStep( + linkStockLocationsFulfillmentProviderInput + ) + + // create a shipping profile + const createDefaultShippingProfileResult = + Steps.createDefaultShippingProfileStep(input.defaultShippingProfile) + + // create fulfillment sets + const createFulfillmentSetsResult = Steps.createFulfillmentSetStep( + input.fulfillmentSets + ) + + // link stock locations to fulfillment set + const linkStockLocationsFulfillmentSetInput: Steps.LinkStockLocationFulfillmentSetStepInput = + transform( { + createStockLocationResult, input, - salesChannelsResult, + createFulfillmentSetsResult, }, (data) => { - const defaultSalesChannel = data.salesChannelsResult.result.find( - (i) => i.isDefault - ) - if (!defaultSalesChannel) { - throw new Error("No default sales channel found") + const fulfillmentSet = data.createFulfillmentSetsResult.result[0] + if (!fulfillmentSet) { + throw new Error( + "No fulfillment sets created - cannot link stock locations" + ) } return { - currencies: data.input.currencies, - defaultSalesChannelId: defaultSalesChannel.id, + stockLocations: data.createStockLocationResult.result, + fulfillmentSet, } } ) - Steps.updateStoreCurrenciesStep(updateStoreCurrenciesStepInput) - - // create regions - const createRegionsResult = Steps.createRegionsStep(input.regions) - // create tax regions - Steps.createTaxRegionsStep(input.taxRegions) + Steps.linkStockLocationFulfillmentSetStep( + linkStockLocationsFulfillmentSetInput + ) - // create stock locations - const createStockLocationResult = Steps.createStockLocationSeedStep( - input.stockLocations - ) - - // link stock locations to fulfillment providers (derived from shipping options) - const linkStockLocationsFulfillmentProviderInput: Steps.LinkStockLocationFulfillmentProviderStepInput = - transform( - { - createStockLocationResult, - input, - }, - (data) => ({ - stockLocations: data.createStockLocationResult.result, - fulfillmentProviderIds: [ - ...new Set( - data.input.shippingOptions.map( - (opt) => opt.providerId || "manual_manual" - ) - ), - ], - }) - ) + // create shipping options - Steps.linkStockLocationFulfillmentProviderSeedStep( - linkStockLocationsFulfillmentProviderInput - ) + const createShippingOptionsInput: Steps.CreateShippingOptionsStepInput = + transform( + { + input, + createFulfillmentSetsResult, + createDefaultShippingProfileResult, + createRegionsResult, + }, + (data) => { + const serviceZoneId = + data.createFulfillmentSetsResult.result[0]?.service_zones[0]?.id + if (!serviceZoneId) { + throw new Error( + "No service zone found - cannot create shipping options" + ) + } - // create a shipping profile - const createDefaultShippingProfileResult = - Steps.createDefaultShippingProfileStep(input.defaultShippingProfile) + const shippingProfileId = + data.createDefaultShippingProfileResult.result[0]?.id + if (!shippingProfileId) { + throw new Error( + "No shipping profile found - cannot create shipping options" + ) + } - // create fulfillment sets - const createFulfillmentSetsResult = Steps.createFulfillmentSetStep( - input.fulfillmentSets + return data.input.shippingOptions.map((option) => ({ + name: option.name, + providerId: + option.providerId ?? + data.input.workflowDefaults.fulfillmentProviderId, + serviceZoneId, + shippingProfileId, + regions: data.createRegionsResult.result.map((region) => ({ + ...region, + amount: + option.prices.find( + (p) => + p.currencyCode?.toLowerCase() === + region.currency_code?.toLowerCase() + )?.amount ?? + data.input.workflowDefaults.shippingOptionPriceAmount, + })), + type: option.type, + prices: option.prices, + rules: option.rules, + data: option.data, + })) + } ) - // link stock locations to fulfillment set - const linkStockLocationsFulfillmentSetInput: Steps.LinkStockLocationFulfillmentSetStepInput = - transform( - { - createStockLocationResult, - input, - createFulfillmentSetsResult, - }, - (data) => { - const fulfillmentSet = data.createFulfillmentSetsResult.result[0] - if (!fulfillmentSet) { - throw new Error( - "No fulfillment sets created - cannot link stock locations" - ) - } - return { - stockLocations: data.createStockLocationResult.result, - fulfillmentSet, - } - } - ) + Steps.createShippingOptionsStep(createShippingOptionsInput) - Steps.linkStockLocationFulfillmentSetStep( - linkStockLocationsFulfillmentSetInput + // link sales channels to stock location + const linkSalesChannelsToStockLocationInput: Steps.LinkSalesChannelsStockLocationStepInput = + transform( + { + createStockLocationResult, + input, + salesChannelsResult, + }, + (data) => ({ + stockLocations: data.createStockLocationResult.result, + salesChannels: data.salesChannelsResult.result, + }) ) - // create shipping options - - const createShippingOptionsInput: Steps.CreateShippingOptionsStepInput = - transform( - { - input, - createFulfillmentSetsResult, - createDefaultShippingProfileResult, - createRegionsResult, - }, - (data) => { - const serviceZoneId = - data.createFulfillmentSetsResult.result[0]?.service_zones[0]?.id - if (!serviceZoneId) { - throw new Error( - "No service zone found - cannot create shipping options" - ) - } + Steps.linkSalesChannelsStockLocationStep( + linkSalesChannelsToStockLocationInput + ) - const shippingProfileId = - data.createDefaultShippingProfileResult.result[0]?.id - if (!shippingProfileId) { - throw new Error( - "No shipping profile found - cannot create shipping options" - ) - } - - return data.input.shippingOptions.map((option) => ({ - name: option.name, - providerId: option.providerId || "manual_manual", - serviceZoneId, - shippingProfileId, - regions: data.createRegionsResult.result.map((region) => ({ - ...region, - amount: - option.prices.find( - (p) => - p.currencyCode?.toLowerCase() === - region.currency_code?.toLowerCase() - )?.amount ?? 10, - })), - type: option.type, - prices: option.prices, - rules: option.rules, - data: option.data, - })) - } - ) + // create publishable key - Steps.createShippingOptionsStep(createShippingOptionsInput) - - // link sales channels to stock location - const linkSalesChannelsToStockLocationInput: Steps.LinkSalesChannelsStockLocationStepInput = - transform( - { - createStockLocationResult, - input, - salesChannelsResult, - }, - (data) => ({ - stockLocations: data.createStockLocationResult.result, - salesChannels: data.salesChannelsResult.result, - }) - ) + const createPublishableKeyResult = Steps.createPublishableKeyStep( + input.publishableKey + ) - Steps.linkSalesChannelsStockLocationStep( - linkSalesChannelsToStockLocationInput + // link publishable key to salesChannels + const linkSalesChannelsApiKeyStepInput: Steps.LinkSalesChannelsApiKeyStepInput = + transform( + { + createPublishableKeyResult, + salesChannelsResult, + }, + (data) => ({ + salesChannels: data.salesChannelsResult.result, + publishableApiKey: data.createPublishableKeyResult + .result[0] as ApiKeyDTO, + }) ) - // create publishable key - - const createPublishableKeyResult = Steps.createPublishableKeyStep( - input.publishableKey - ) + Steps.linkSalesChannelsApiKeyStep(linkSalesChannelsApiKeyStepInput) - // link publishable key to salesChannels - const linkSalesChannelsApiKeyStepInput: Steps.LinkSalesChannelsApiKeyStepInput = - transform( - { - createPublishableKeyResult, - salesChannelsResult, - }, - (data) => ({ - salesChannels: data.salesChannelsResult.result, - publishableApiKey: data.createPublishableKeyResult - .result[0] as ApiKeyDTO, - }) - ) + // create categories + seedCategoriesWorkflow.runAsStep({ + input: input.categories, + }) - Steps.linkSalesChannelsApiKeyStep(linkSalesChannelsApiKeyStepInput) + // create products + const createProductsStepInput: Steps.CreateProductsStepInput = transform( + { + input, + }, + (data) => toCreateProductsStepInput(data.input.products) + ) - // create categories - seedCategoriesWorkflow.runAsStep({ - input: input.categories, - }) + const createProductsStepResult = Steps.createProductsStep( + createProductsStepInput + ) - // create products - const createProductsStepInput: Steps.CreateProductsStepInput = transform( + // create inventory levels + const createInventoryLevelsInput: Steps.CreateInventoryLevelsStepInput = + transform( { - input, + createStockLocationResult, + createProductsStepInput, }, - (data) => toCreateProductsStepInput(data.input.products) + (data) => ({ + stockLocations: data.createStockLocationResult.result, + inventoryItems: buildInventoryItemsInput(data.createProductsStepInput), + }) ) - const createProductsStepResult = Steps.createProductsStep( - createProductsStepInput - ) + Steps.createInventoryLevelsStep(createInventoryLevelsInput) - // create inventory levels - const createInventoryLevelsInput: Steps.CreateInventoryLevelsStepInput = - transform( - { - createStockLocationResult, - createProductsStepInput, - }, - (data) => ({ - stockLocations: data.createStockLocationResult.result, - inventoryItems: buildInventoryItemsInput( - data.createProductsStepInput - ), - }) - ) - - Steps.createInventoryLevelsStep(createInventoryLevelsInput) + return new WorkflowResponse({ + publishableKey: createPublishableKeyResult.result, + products: createProductsStepResult.result, + result: "N1 seed done", + }) +} - return new WorkflowResponse({ - publishableKey: createPublishableKeyResult.result, - products: createProductsStepResult.result, - result: "N1 seed done", - }) - } -) +const seedN1Workflow = createWorkflow(seedN1WorkflowId, seedN1WorkflowComposer) export default seedN1Workflow diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-paykit-regions.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-paykit-regions.ts index 25b5d22e5..9663ab18d 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-paykit-regions.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-paykit-regions.ts @@ -32,69 +32,73 @@ export type SeedPaykitRegionsWorkflowInput = { paymentProviderIds: string[] } -const seedPaykitRegionsWorkflow = createWorkflow( - SeedPaykitRegionsWorkflowId, - (input: SeedPaykitRegionsWorkflowInput) => { - const regionsWithPaykitProviders = transform({ input }, (data) => - withPaykitPaymentProviders( - data.input.regions, - data.input.paymentProviderIds - ) +function seedPaykitRegionsWorkflowComposer( + input: SeedPaykitRegionsWorkflowInput +) { + const regionsWithPaykitProviders = transform({ input }, (data) => + withPaykitPaymentProviders( + data.input.regions, + data.input.paymentProviderIds ) + ) - const missingRegions = transform({ regionsWithPaykitProviders }, (data) => - data.regionsWithPaykitProviders.filter( - (region): region is CreateMissingPaykitRegionsStepInput[number] => - !region.id - ) + const missingRegions = transform({ regionsWithPaykitProviders }, (data) => + data.regionsWithPaykitProviders.filter( + (region): region is CreateMissingPaykitRegionsStepInput[number] => + !region.id ) + ) - const existingRegionPaymentProvidersInput = transform( - { regionsWithPaykitProviders }, - (data): SetRegionsPaymentProvidersStepInput => ({ - input: data.regionsWithPaykitProviders.flatMap((region) => - region.id - ? [ - { - id: region.id, - payment_providers: region.paymentProviders, - }, - ] - : [] - ), - }) - ) + const existingRegionPaymentProvidersInput = transform( + { regionsWithPaykitProviders }, + (data): SetRegionsPaymentProvidersStepInput => ({ + input: data.regionsWithPaykitProviders.flatMap((region) => + region.id + ? [ + { + id: region.id, + payment_providers: region.paymentProviders, + }, + ] + : [] + ), + }) + ) - const existingRegionsInput = transform( - { regionsWithPaykitProviders }, - (data): SyncExistingPaykitRegionsStepInput => - data.regionsWithPaykitProviders.flatMap((region) => - region.id - ? [ - { - id: region.id, - currencyCode: region.currencyCode, - }, - ] - : [] - ) - ) + const existingRegionsInput = transform( + { regionsWithPaykitProviders }, + (data): SyncExistingPaykitRegionsStepInput => + data.regionsWithPaykitProviders.flatMap((region) => + region.id + ? [ + { + id: region.id, + currencyCode: region.currencyCode, + }, + ] + : [] + ) + ) - const createMissingPaykitRegionsResult = - createMissingPaykitRegionsStep(missingRegions) + const createMissingPaykitRegionsResult = + createMissingPaykitRegionsStep(missingRegions) - const syncExistingPaykitRegionsResult = - syncExistingPaykitRegionsStep(existingRegionsInput) + const syncExistingPaykitRegionsResult = + syncExistingPaykitRegionsStep(existingRegionsInput) - const setExistingRegionPaymentProvidersResult = - setRegionsPaymentProvidersStep(existingRegionPaymentProvidersInput) + const setExistingRegionPaymentProvidersResult = + setRegionsPaymentProvidersStep(existingRegionPaymentProvidersInput) - return new WorkflowResponse({ - createMissingPaykitRegionsResult, - syncExistingPaykitRegionsResult, - setExistingRegionPaymentProvidersResult, - }) - } + return new WorkflowResponse({ + createMissingPaykitRegionsResult, + syncExistingPaykitRegionsResult, + setExistingRegionPaymentProvidersResult, + }) +} + +const seedPaykitRegionsWorkflow = createWorkflow( + SeedPaykitRegionsWorkflowId, + seedPaykitRegionsWorkflowComposer ) export default seedPaykitRegionsWorkflow diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-shoptet-import.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-shoptet-import.ts new file mode 100644 index 000000000..1ef789139 --- /dev/null +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-shoptet-import.ts @@ -0,0 +1,26 @@ +import { + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import seedDatabaseWorkflow, { + type SeedDatabaseWorkflowInput, +} from "./seed-database" + +const SeedShoptetImportWorkflowId = "seed-shoptet-import-workflow" + +export type SeedShoptetImportWorkflowInput = SeedDatabaseWorkflowInput + +function seedShoptetImportWorkflowComposer( + input: SeedShoptetImportWorkflowInput +) { + const seedResult = seedDatabaseWorkflow.runAsStep({ input }) + + return new WorkflowResponse(seedResult) +} + +export const seedShoptetImportWorkflow = createWorkflow( + SeedShoptetImportWorkflowId, + seedShoptetImportWorkflowComposer +) + +export default seedShoptetImportWorkflow diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts index 386a291e6..69130b944 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts @@ -6,7 +6,15 @@ import { type HerbaticaCategoryExport, parseHerbaticaCategoriesXmlFile, } from "../../../src/scripts/herbatica-category-export" -import { buildSeedInputFromXml } from "../../../src/scripts/herbatica-seed" +import { + buildHerbaticaSeedWorkflowInput, + buildSeedInputFromXml, +} from "../../../src/scripts/herbatica-seed" +import { + HERBATICA_PRICE_LIST_SYNC_CONFIG, + HERBATICA_TAX_RATE_CONFIG, + HERBATICA_WORKFLOW_DEFAULTS, +} from "../../../src/scripts/herbatica-seed-config" const DIRTY_FEED_MARKUP_PATTERN = /data-turn-id|data-message-author-role|data-testid|ChatGPT|markdown prose|webpage-citation-pill|_ngcontent-ng|markdown-main-panel/i @@ -742,6 +750,56 @@ describe("Herbatica seed product content sections", () => { }) }) +describe("Herbatica Shoptet workflow input", () => { + it("passes Herbatica policy config into generic seed inputs", () => { + const parsed = buildSeedInputFromXml(` + + + Policy product + 10 + 10 + 8 + EUR + 1 + + 2 + + + Policy + + + + Wholesale + 9 + + + + + `) + + const input = buildHerbaticaSeedWorkflowInput(parsed, { + regionsInput: [ + { + name: "Europe", + currencyCode: "eur", + countries: ["sk"], + paymentProviders: undefined, + isTaxInclusive: true, + }, + ], + fulfillmentSetName: "European Warehouse delivery", + fulfillmentSetType: "shipping", + serviceZoneName: "Europe", + }) + + expect(input.workflowDefaults).toBe(HERBATICA_WORKFLOW_DEFAULTS) + expect(input.priceLists).toBe(parsed.priceLists) + expect(input.priceListSync).toBe(HERBATICA_PRICE_LIST_SYNC_CONFIG) + expect(input.taxRates?.config).toBe(HERBATICA_TAX_RATE_CONFIG) + expect(input.taxRates?.countries).toEqual(["sk", "cz"]) + }) +}) + describe("Herbatica committed feed fixtures", () => { const productsXmlPath = resolve( process.cwd(), diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts index a8e502d89..b10c8a86d 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-tax-rates.unit.spec.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest" +import { + HERBATICA_DEFAULT_TAX_RATES, + HERBATICA_TAX_RATE_CONFIG, +} from "../../../src/scripts/herbatica-seed-config" import { buildProductTaxRateIdentity, buildTaxRateSeedTargets, - HERBATICA_DEFAULT_TAX_RATES, } from "../../../src/workflows/seed/steps/create-tax-rates" function mapEntries(map: Map) { @@ -28,10 +31,16 @@ describe("Herbatica tax-rate seed policy", () => { }, }, ], - ["sk", "cz", "hu"] + ["sk", "cz", "hu"], + HERBATICA_TAX_RATE_CONFIG ) - expect(mapEntries(HERBATICA_DEFAULT_TAX_RATES)).toEqual([ + expect( + HERBATICA_DEFAULT_TAX_RATES.map(({ countryCode, rate }) => [ + countryCode, + rate, + ]) + ).toEqual([ ["sk", 23], ["cz", 19], ]) @@ -92,7 +101,8 @@ describe("Herbatica tax-rate seed policy", () => { }, }, ], - ["sk", "cz"] + ["sk", "cz"], + HERBATICA_TAX_RATE_CONFIG ) expect(mapEntries(targets.productRateGroupsByCountry)).toEqual([ @@ -109,11 +119,15 @@ describe("Herbatica tax-rate seed policy", () => { }) it("names grouped product override rates by country and VAT rate", () => { - expect(buildProductTaxRateIdentity("sk", 19)).toEqual({ + expect( + buildProductTaxRateIdentity("sk", 19, HERBATICA_TAX_RATE_CONFIG) + ).toEqual({ code: "vat_sk_product_19", name: "VAT SK Product 19%", }) - expect(buildProductTaxRateIdentity("sk", 5)).toEqual({ + expect( + buildProductTaxRateIdentity("sk", 5, HERBATICA_TAX_RATE_CONFIG) + ).toEqual({ code: "vat_sk_product_5", name: "VAT SK Product 5%", }) @@ -131,7 +145,8 @@ describe("Herbatica tax-rate seed policy", () => { }, }, ], - ["cz"] + ["cz"], + HERBATICA_TAX_RATE_CONFIG ) expect(mapEntries(targets.defaultRatesByCountry)).toEqual([["cz", 19]]) From 126535ac378bb619f24a89f1b01228faa5718db6 Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 19:11:28 +0200 Subject: [PATCH 07/10] refactor(seed): tighten Herbatica tax config ownership --- apps/medusa-be/src/scripts/herbatica-seed-config.ts | 4 ++++ apps/medusa-be/src/scripts/herbatica-seed.ts | 3 ++- .../src/workflows/seed/steps/create-tax-rates.ts | 10 +++++----- .../tests/unit/herbatica/herbatica-seed.unit.spec.ts | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/medusa-be/src/scripts/herbatica-seed-config.ts b/apps/medusa-be/src/scripts/herbatica-seed-config.ts index 0c8c25440..37c1bcdf7 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed-config.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed-config.ts @@ -80,6 +80,10 @@ export const HERBATICA_DEFAULT_TAX_RATES = [ { countryCode: "cz", rate: 19 }, ] as const +export const HERBATICA_TAX_RATE_COUNTRIES = HERBATICA_DEFAULT_TAX_RATES.map( + ({ countryCode }) => countryCode +) + export const HERBATICA_TAX_RATE_CONFIG = { metadataSource: "herbatica-seed-tax-rates", defaultRates: [...HERBATICA_DEFAULT_TAX_RATES], diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index c17f8207c..d24603684 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -40,6 +40,7 @@ import { HERBATICA_SALES_CHANNELS, HERBATICA_SHIPPING_OPTIONS, HERBATICA_TAX_RATE_CONFIG, + HERBATICA_TAX_RATE_COUNTRIES, HERBATICA_WORKFLOW_DEFAULTS, } from "./herbatica-seed-config" @@ -3358,7 +3359,7 @@ export function buildHerbaticaSeedWorkflowInput( taxProviderId: undefined, }, taxRates: { - countries: ["sk", "cz"], + countries: HERBATICA_TAX_RATE_COUNTRIES, config: HERBATICA_TAX_RATE_CONFIG, }, stockLocations: { diff --git a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts index 1ac20042c..13b7fdf7b 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-tax-rates.ts @@ -242,7 +242,7 @@ export function buildTaxRateSeedTargets( } } - const slovakiaProductRateGroups = new Map() + const overrideProductRateGroups = new Map() for (const product of products) { const vat = extractProductVat( asObject(product.metadata), @@ -252,15 +252,15 @@ export function buildTaxRateSeedTargets( continue } - const existing = slovakiaProductRateGroups.get(vat) ?? [] + const existing = overrideProductRateGroups.get(vat) ?? [] existing.push(product.id) - slovakiaProductRateGroups.set(vat, existing) + overrideProductRateGroups.set(vat, existing) } - if (slovakiaProductRateGroups.size > 0) { + if (overrideProductRateGroups.size > 0) { productRateGroupsByCountry.set( productOverridesCountryCode, - slovakiaProductRateGroups + overrideProductRateGroups ) } diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts index 69130b944..a9bfa0459 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts @@ -13,6 +13,7 @@ import { import { HERBATICA_PRICE_LIST_SYNC_CONFIG, HERBATICA_TAX_RATE_CONFIG, + HERBATICA_TAX_RATE_COUNTRIES, HERBATICA_WORKFLOW_DEFAULTS, } from "../../../src/scripts/herbatica-seed-config" @@ -796,7 +797,7 @@ describe("Herbatica Shoptet workflow input", () => { expect(input.priceLists).toBe(parsed.priceLists) expect(input.priceListSync).toBe(HERBATICA_PRICE_LIST_SYNC_CONFIG) expect(input.taxRates?.config).toBe(HERBATICA_TAX_RATE_CONFIG) - expect(input.taxRates?.countries).toEqual(["sk", "cz"]) + expect(input.taxRates?.countries).toBe(HERBATICA_TAX_RATE_COUNTRIES) }) }) From f7653d04194afd63f741819d25f86bbb9662acdd Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 20:25:25 +0200 Subject: [PATCH 08/10] fix(seed): default eur --- apps/medusa-be/src/scripts/herbatica-seed-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/medusa-be/src/scripts/herbatica-seed-config.ts b/apps/medusa-be/src/scripts/herbatica-seed-config.ts index 37c1bcdf7..d4aab1a8b 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed-config.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed-config.ts @@ -106,11 +106,11 @@ export const HERBATICA_WORKFLOW_DEFAULTS = { export const HERBATICA_CURRENCIES = [ { code: "czk", - default: true, + default: false, }, { code: "eur", - default: false, + default: true, }, { code: "usd", From 908ff3abcb7b7286f8ad00ac83829617ead3bdbe Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Tue, 26 May 2026 21:18:36 +0200 Subject: [PATCH 09/10] fix(seed): address Herbatica review findings --- apps/medusa-be/src/scripts/herbatica-seed.ts | 15 ++-- .../helpers/build-inventory-items-input.ts | 35 ++++++++ .../seed/steps/create-inventory-levels.ts | 14 ++- .../seed/steps/ensure-price-preferences.ts | 29 +++--- ...ink-stock-location-fulfillment-provider.ts | 19 +++- .../workflows/seed/steps/sync-price-lists.ts | 39 ++++++-- .../workflows/seed/workflows/seed-database.ts | 78 +++++----------- .../src/workflows/seed/workflows/seed-n1.ts | 54 ++++------- .../build-inventory-items-input.unit.spec.ts | 89 +++++++++++++++++++ 9 files changed, 247 insertions(+), 125 deletions(-) create mode 100644 apps/medusa-be/src/workflows/seed/helpers/build-inventory-items-input.ts create mode 100644 apps/medusa-be/tests/unit/src/workflows/seed/build-inventory-items-input.unit.spec.ts diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index d24603684..9a7295e7e 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -2863,7 +2863,7 @@ function getVariantBasePrice( variant: VariantSeedInput ): PriceListPriceSeedInput | undefined { const price = variant.prices?.[0] - if (!price) { + if (!(price && variant.sku)) { return } @@ -2936,7 +2936,12 @@ function addSalePriceListPrice( function getVariantMetadata( variant: VariantSeedInput ): Record | undefined { - return variant.metadata as Record | undefined + const { metadata } = variant + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return + } + + return metadata } function getMetadataString( @@ -3205,11 +3210,9 @@ function addDefaultStockLocation( > ) { locationsByName.set(DEFAULT_STOCK_LOCATION_NAME, { - name: DEFAULT_STOCK_LOCATION_NAME, + ...HERBATICA_DEFAULT_STOCK_LOCATION, address: { - city: "Copenhagen", - country_code: "DK", - address_1: "", + ...HERBATICA_DEFAULT_STOCK_LOCATION.address, }, }) } diff --git a/apps/medusa-be/src/workflows/seed/helpers/build-inventory-items-input.ts b/apps/medusa-be/src/workflows/seed/helpers/build-inventory-items-input.ts new file mode 100644 index 000000000..8036c3f1c --- /dev/null +++ b/apps/medusa-be/src/workflows/seed/helpers/build-inventory-items-input.ts @@ -0,0 +1,35 @@ +import type { + CreateInventoryLevelsStepInput, + CreateProductsStepInput, +} from "../steps" + +export function buildInventoryItemsInput( + products: CreateProductsStepInput +): CreateInventoryLevelsStepInput["inventoryItems"] { + const inventoryItems: CreateInventoryLevelsStepInput["inventoryItems"] = [] + + for (const product of products) { + for (const variant of product.variants ?? []) { + if (!variant.sku) { + continue + } + + if (variant.quantities?.locations?.length) { + inventoryItems.push({ + sku: variant.sku, + locations: variant.quantities.locations, + }) + continue + } + + if (variant.quantities?.quantity !== undefined) { + inventoryItems.push({ + sku: variant.sku, + quantity: variant.quantities.quantity, + }) + } + } + } + + return inventoryItems +} diff --git a/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts b/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts index 011ec43d1..f6387773b 100644 --- a/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts +++ b/apps/medusa-be/src/workflows/seed/steps/create-inventory-levels.ts @@ -6,7 +6,11 @@ import type { Query, StockLocationDTO, } from "@medusajs/framework/types" -import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" +import { + ContainerRegistrationKeys, + MedusaError, + Modules, +} from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" import { createInventoryLevelsWorkflow, @@ -35,7 +39,10 @@ function buildInventoryLevelsForItem( stockLocations: StockLocationDTO[] ): CreateInventoryLevelInput[] { if (inventoryItem.id === undefined) { - throw new Error(`Inventory item with sku ${inventoryItem.sku} not found.`) + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Inventory item with sku ${inventoryItem.sku} not found.` + ) } const inventoryItemId = inventoryItem.id @@ -45,7 +52,8 @@ function buildInventoryLevelsForItem( (location) => location.name === locationQuantity.stockLocationName ) if (!stockLocation) { - throw new Error( + throw new MedusaError( + MedusaError.Types.NOT_FOUND, `Stock location "${locationQuantity.stockLocationName}" not found for SKU ${inventoryItem.sku}.` ) } diff --git a/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts b/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts index 618945202..5a46cd470 100644 --- a/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts +++ b/apps/medusa-be/src/workflows/seed/steps/ensure-price-preferences.ts @@ -80,20 +80,21 @@ export const ensurePricePreferencesStep = createStep( return new StepResponse({ result: output }) } - const existingRegionPreferences = - regionIds.length > 0 - ? await pricingService.listPricePreferences({ - attribute: "region_id", - value: regionIds, - }) - : [] - const existingCurrencyPreferences = - currencyCodes.length > 0 - ? await pricingService.listPricePreferences({ - attribute: "currency_code", - value: currencyCodes, - }) - : [] + const [existingRegionPreferences, existingCurrencyPreferences] = + await Promise.all([ + regionIds.length > 0 + ? pricingService.listPricePreferences({ + attribute: "region_id", + value: regionIds, + }) + : Promise.resolve([]), + currencyCodes.length > 0 + ? pricingService.listPricePreferences({ + attribute: "currency_code", + value: currencyCodes, + }) + : Promise.resolve([]), + ]) const existingByKey = new Map< string, diff --git a/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts b/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts index 615f0e920..d4f28c82c 100644 --- a/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts +++ b/apps/medusa-be/src/workflows/seed/steps/link-stock-location-fulfillment-provider.ts @@ -5,11 +5,24 @@ import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" export type LinkStockLocationFulfillmentProviderStepInput = { stockLocations: StockLocationDTO[] - fulfillmentProviderIds: string[] + fulfillmentProviderIds?: Array } const LinkStockLocationFulfillmentProviderStepId = "link-stock-location-fulfillment-provider-seed-step" + +function normalizeFulfillmentProviderIds( + ids?: Array +): string[] { + return [ + ...new Set( + (ids ?? []) + .map((id) => id?.toString().trim()) + .filter((id): id is string => Boolean(id)) + ), + ] +} + export const linkStockLocationFulfillmentProviderSeedStep = createStep( LinkStockLocationFulfillmentProviderStepId, async ( @@ -22,7 +35,9 @@ export const linkStockLocationFulfillmentProviderSeedStep = createStep( logger.info("Linking stock locations to fulfillment providers...") const result: unknown[] = [] - const providerIds = [...new Set(input.fulfillmentProviderIds)] + const providerIds = normalizeFulfillmentProviderIds( + input.fulfillmentProviderIds + ) if (providerIds.length === 0) { logger.warn( "No fulfillment provider IDs supplied, skipping stock-location fulfillment-provider links." diff --git a/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts b/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts index 83583eb0b..0642977ab 100644 --- a/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts +++ b/apps/medusa-be/src/workflows/seed/steps/sync-price-lists.ts @@ -9,7 +9,11 @@ import type { ProductDTO, RemoteQueryFunction, } from "@medusajs/framework/types" -import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" +import { + ContainerRegistrationKeys, + MedusaError, + Modules, +} from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" import { batchPriceListPricesWorkflow, @@ -278,6 +282,27 @@ function buildVariantLookup( return variants } +function isVariantPriceSetLink(value: unknown): value is VariantPriceSetLink { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as Partial).variant_id === "string" && + typeof (value as Partial).price_set_id === "string" + ) +} + +function toVariantPriceSetLinks(value: unknown): VariantPriceSetLink[] { + if (!(Array.isArray(value) && value.every(isVariantPriceSetLink))) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "Unexpected product variant price-set link response shape." + ) + } + + return value +} + async function ensureCustomerGroups( entries: PriceListSyncEntry[], customerService: ICustomerModuleService, @@ -648,11 +673,13 @@ export const syncPriceListsStep = createStep( ), ] const variantPriceSetLinks = variantIds.length - ? ((await remoteQuery({ - entryPoint: "product_variant_price_set", - fields: ["variant_id", "price_set_id"], - variables: { variant_id: variantIds }, - })) as VariantPriceSetLink[]) + ? toVariantPriceSetLinks( + await remoteQuery({ + entryPoint: "product_variant_price_set", + fields: ["variant_id", "price_set_id"], + variables: { variant_id: variantIds }, + }) + ) : [] const variantPriceSetMap = new Map( variantPriceSetLinks.map((link) => [link.variant_id, link.price_set_id]) diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts index 173d74b37..b7efa249d 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-database.ts @@ -1,8 +1,10 @@ +import { MedusaError } from "@medusajs/framework/utils" import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" +import { buildInventoryItemsInput } from "../helpers/build-inventory-items-input" import * as Steps from "../steps" const SeedDatabaseWorkflowId = "seed-database-workflow" @@ -28,43 +30,9 @@ export type SeedDatabaseWorkflowInput = { priceListSync?: Steps.SyncPriceListsStepInput["config"] } -function buildInventoryItemsInput( - products: SeedDatabaseWorkflowInput["products"] -): Steps.CreateInventoryLevelsStepInput["inventoryItems"] { - const inventoryItems: Steps.CreateInventoryLevelsStepInput["inventoryItems"] = - [] - - for (const product of products) { - for (const variant of product.variants ?? []) { - if (!variant.sku) { - continue - } - - if (variant.quantities?.locations?.length) { - inventoryItems.push({ - sku: variant.sku, - locations: variant.quantities.locations, - }) - continue - } - - if (variant.quantities?.quantity !== undefined) { - inventoryItems.push({ - sku: variant.sku, - quantity: variant.quantities.quantity, - }) - } - } - } - - return inventoryItems -} - function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { - // create sales channels const salesChannelsResult = Steps.createSalesChannelsStep(input.salesChannels) - // update store currencies const updateStoreCurrenciesStepInput = transform( { input, @@ -75,7 +43,10 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { (i) => i.isDefault ) if (!defaultSalesChannel) { - throw new Error("No default sales channel found") + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "No default sales channel found" + ) } return { currencies: data.input.currencies, @@ -87,7 +58,6 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { updateStoreCurrenciesStepInput ) - // create regions const createRegionsResult = Steps.createRegionsStep(input.regions) const ensurePricePreferencesStepInput: Steps.EnsurePricePreferencesStepInput = @@ -107,10 +77,8 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { ensurePricePreferencesStepInput ) - // create tax regions const createTaxRegionsResult = Steps.createTaxRegionsStep(input.taxRegions) - // create stock locations const createStockLocationResult = Steps.createStockLocationSeedStep( input.stockLocations ) @@ -141,16 +109,13 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { linkStockLocationsFulfillmentProviderInput ) - // create a shipping profile const createDefaultShippingProfileResult = Steps.createDefaultShippingProfileStep(input.defaultShippingProfile) - // create fulfillment sets const createFulfillmentSetsResult = Steps.createFulfillmentSetStep( input.fulfillmentSets ) - // link stock locations to fulfillment set const linkStockLocationsFulfillmentSetInput: Steps.LinkStockLocationFulfillmentSetStepInput = transform( { @@ -162,7 +127,10 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { const fulfillmentSet = data.createFulfillmentSetsResult.result[0] if (!fulfillmentSet) { - throw new Error("No fulfillment set found") + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "No fulfillment set found" + ) } return { @@ -177,8 +145,6 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { linkStockLocationsFulfillmentSetInput ) - // create shipping options - const createShippingOptionsInput: Steps.CreateShippingOptionsStepInput = transform( { @@ -194,11 +160,17 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { const serviceZone = fulfillmentSet?.service_zones?.[0] if (!serviceZone?.id) { - throw new Error("No service zone found in fulfillment set") + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "No service zone found in fulfillment set" + ) } if (!shippingProfile?.id) { - throw new Error("No shipping profile found") + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "No shipping profile found" + ) } return data.input.shippingOptions.map((option) => ({ @@ -230,7 +202,6 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { createShippingOptionsInput ) - // link sales channels to stock location const linkSalesChannelsToStockLocationInput: Steps.LinkSalesChannelsStockLocationStepInput = transform( { @@ -249,13 +220,10 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { linkSalesChannelsToStockLocationInput ) - // create publishable key - const createPublishableKeyResult = Steps.createPublishableKeyStep( input.publishableKey ) - // link publishable key to salesChannels const linkSalesChannelsApiKeyStepInput: Steps.LinkSalesChannelsApiKeyStepInput = transform( { @@ -266,7 +234,10 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { const publishableApiKey = data.createPublishableKeyResult.result[0] if (!publishableApiKey) { - throw new Error("No publishable API key found") + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "No publishable API key found" + ) } return { @@ -279,14 +250,10 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { const linkSalesChannelsApiKeyStepInputResult = Steps.linkSalesChannelsApiKeyStep(linkSalesChannelsApiKeyStepInput) - // create product categories - const createProductCategoriesResult = Steps.createProductCategoriesStep( input.productCategories ) - // create products - const createProductsStepInput: Steps.CreateProductsStepInput = transform( { input, @@ -329,7 +296,6 @@ function seedDatabaseWorkflowComposer(input: SeedDatabaseWorkflowInput) { const createTaxRatesResult = Steps.createTaxRatesStep(createTaxRatesStepInput) - // create inventory levels const createInventoryLevelsInput: Steps.CreateInventoryLevelsStepInput = transform( { diff --git a/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts b/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts index e5f5a9be2..8e2c325a2 100644 --- a/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts +++ b/apps/medusa-be/src/workflows/seed/workflows/seed-n1.ts @@ -1,10 +1,11 @@ -import type { ApiKeyDTO } from "@medusajs/framework/types" +import { MedusaError } from "@medusajs/framework/utils" import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { toCreateProductsStepInput } from "../../../utils/products" +import { buildInventoryItemsInput } from "../helpers/build-inventory-items-input" import * as Steps from "../steps" import seedCategoriesWorkflow, { type CategoryRaw } from "./seed-categories" @@ -40,38 +41,6 @@ export type SeedN1WorkflowInput = { publishableKey: Steps.CreatePublishableKeyStepInput } -function buildInventoryItemsInput( - products: Steps.CreateProductsStepInput -): Steps.CreateInventoryLevelsStepInput["inventoryItems"] { - const inventoryItems: Steps.CreateInventoryLevelsStepInput["inventoryItems"] = - [] - - for (const product of products) { - for (const variant of product.variants ?? []) { - if (!variant.sku) { - continue - } - - if (variant.quantities?.locations?.length) { - inventoryItems.push({ - sku: variant.sku, - locations: variant.quantities.locations, - }) - continue - } - - if (variant.quantities?.quantity !== undefined) { - inventoryItems.push({ - sku: variant.sku, - quantity: variant.quantities.quantity, - }) - } - } - } - - return inventoryItems -} - function seedN1WorkflowComposer(input: SeedN1WorkflowInput) { // create sales channels const salesChannelsResult = Steps.createSalesChannelsStep(input.salesChannels) @@ -253,11 +222,20 @@ function seedN1WorkflowComposer(input: SeedN1WorkflowInput) { createPublishableKeyResult, salesChannelsResult, }, - (data) => ({ - salesChannels: data.salesChannelsResult.result, - publishableApiKey: data.createPublishableKeyResult - .result[0] as ApiKeyDTO, - }) + (data) => { + const publishableApiKey = data.createPublishableKeyResult.result[0] + if (!publishableApiKey) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "No publishable API key found" + ) + } + + return { + salesChannels: data.salesChannelsResult.result, + publishableApiKey, + } + } ) Steps.linkSalesChannelsApiKeyStep(linkSalesChannelsApiKeyStepInput) diff --git a/apps/medusa-be/tests/unit/src/workflows/seed/build-inventory-items-input.unit.spec.ts b/apps/medusa-be/tests/unit/src/workflows/seed/build-inventory-items-input.unit.spec.ts new file mode 100644 index 000000000..6ff69e8ed --- /dev/null +++ b/apps/medusa-be/tests/unit/src/workflows/seed/build-inventory-items-input.unit.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" +import { buildInventoryItemsInput } from "../../../../../src/workflows/seed/helpers/build-inventory-items-input" +import type { CreateProductsStepInput } from "../../../../../src/workflows/seed/steps" + +function buildProduct( + variants: NonNullable +): CreateProductsStepInput[number] { + return { + title: "Seed product", + categories: [], + description: "", + handle: "seed-product", + shippingProfileName: "Default Shipping Profile", + images: [], + variants, + salesChannelNames: [], + } +} + +describe("buildInventoryItemsInput", () => { + it("skips variants without a SKU", () => { + expect( + buildInventoryItemsInput([ + buildProduct([ + { + title: "Missing SKU", + sku: "", + quantities: { + quantity: 10, + }, + }, + ]), + ]) + ).toEqual([]) + }) + + it("uses per-location quantities when present", () => { + expect( + buildInventoryItemsInput([ + buildProduct([ + { + title: "Located", + sku: "located-sku", + quantities: { + quantity: 10, + locations: [ + { + stockLocationName: "Main", + quantity: 3, + }, + ], + }, + }, + ]), + ]) + ).toEqual([ + { + sku: "located-sku", + locations: [ + { + stockLocationName: "Main", + quantity: 3, + }, + ], + }, + ]) + }) + + it("uses product-level variant quantity when no locations are present", () => { + expect( + buildInventoryItemsInput([ + buildProduct([ + { + title: "Quantity", + sku: "quantity-sku", + quantities: { + quantity: 7, + }, + }, + ]), + ]) + ).toEqual([ + { + sku: "quantity-sku", + quantity: 7, + }, + ]) + }) +}) From e52b960f71ef9fc697af03852ba5749962666ef9 Mon Sep 17 00:00:00 2001 From: Vojtech Dolezal Date: Thu, 4 Jun 2026 13:10:05 +0200 Subject: [PATCH 10/10] fix(seed): import sku deduplication order --- apps/medusa-be/src/scripts/herbatica-seed.ts | 2 +- .../herbatica/herbatica-seed.unit.spec.ts | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/medusa-be/src/scripts/herbatica-seed.ts b/apps/medusa-be/src/scripts/herbatica-seed.ts index 9a7295e7e..30a3ad56b 100644 --- a/apps/medusa-be/src/scripts/herbatica-seed.ts +++ b/apps/medusa-be/src/scripts/herbatica-seed.ts @@ -3298,13 +3298,13 @@ export function buildSeedInputFromXml( categoryIdToHandle, buildOptions ) + enforceUniqueVariantSkus(products) const priceLists = buildPriceListsFromProducts( products, buildOptions.referenceDate ) const { locations: stockLocations, warnings } = buildStockLocationsFromItems(items) - enforceUniqueVariantSkus(products) const hiddenProducts = products.filter( (product) => product.status === ProductStatus.DRAFT ).length diff --git a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts index a9bfa0459..420b69752 100644 --- a/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts +++ b/apps/medusa-be/tests/unit/herbatica/herbatica-seed.unit.spec.ts @@ -251,6 +251,41 @@ describe("Herbatica seed price-list parsing", () => { ]) }) + it("uses final variant SKUs in price-list prices after SKU enforcement", () => { + const longProductId = `price-list-sku-normalization-${"very-long-segment-".repeat(12)}` + const xml = ` + + + Price list normalized SKU product + Popis produktu + 10 + 10 + EUR + 1 + + 3 + + + Doplnky výživy + + + + Partnerský cenník + 8.50 + + + + + ` + + const result = buildSeedInputFromXml(xml) + const variantSku = result.products[0]?.variants?.[0]?.sku + const priceListSku = result.priceLists.overrides[0]?.prices[0]?.variantSku + + expect(variantSku).toBeDefined() + expect(priceListSku).toBe(variantSku) + }) + it("groups pricelist action prices by source title and date window", () => { const xml = `