diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 7d78f0d..49ea945 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -340,6 +340,14 @@ const ProductForm = forwardRef(({ )} + + {({ push: pushAttr, remove: removeAttr }) => ( diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index eb74ffa..60745fc 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -94,6 +94,8 @@ export { addProductToGroup, removeProductFromGroup, updateProductPrices, + // Merge duplicate products into multi-SKU products + mergeDuplicateProducts, // Admin - Slots getActiveSlotsWithProducts, getActiveSlots, diff --git a/apps/backend/worker.ts b/apps/backend/worker.ts index d8ea2ec..77c2f38 100644 --- a/apps/backend/worker.ts +++ b/apps/backend/worker.ts @@ -7,6 +7,7 @@ import { CacheCreator } from './src/jobs/cache-creator' import { createApp } from './src/app' import { ensureWorkerInit } from './src/lib/worker-init' import { runFlashDeliveryToggleCron } from './src/lib/flash-delivery-cron' +import { mergeDuplicateProducts } from './src/sqliteImporter' import { handleNotifQueue, handleOrderPlacedQueue, @@ -17,6 +18,7 @@ export { CacheCreator } let app: ReturnType | null = null + export default { async fetch( request: Request, @@ -39,6 +41,7 @@ export default { if (!app) { app = createApp() } + // await mergeDuplicateProducts() return app.fetch(request, env, ctx) }, async queue( diff --git a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx index 04979cc..4cae8fd 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx @@ -62,7 +62,7 @@ const OffersSection = ({ ) : ( <> - + {visible.map((item: any) => ( These are products that represent the **same underlying item** but were entered as separate +> `product_info` rows (each with its own SKU) โ€” only the pack size, weight, cut or naming differs. +> In a proper multi-SKU setup, each group below should be **one product with multiple SKUs**. + +--- + +## ๐Ÿ”ด Clear duplicates (same product, different pack/weight) + +### 1. Tomato +Exact same name โ€” only the pack quantity differs. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 17 | `Tomato` | 1.0 Kg | Vegetables | +| 45 | `Tomato` | 0.5 Kg | Vegetables | + +### 2. Mutton Curry Cut (Regular) +Same product โ€” two weights (500 g vs 750 g). + +| product_info id | name | quantity | store | +|---|---|---|---| +| 98 | `Mutton Curry Cut (Regular)โ€“ 500 g` | 0.5 Kg | Meat Store | +| 99 | `Mutton Curry Cut (Regular) โ€“ 750 g` | 0.75 Kg | Meat Store | + +### 3. Mutton Boneless +Same cut โ€” regular pack vs mini pack. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 28 | `Mutton Boneless` | 0.5 Kg | Meat Store | +| 34 | `Mutton Boneless โ€“ Mini Pack` | 0.25 Kg | Meat Store | + +### 4. Chicken Breast +Same cut โ€” 500 g pack vs mini pack. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 2 | `Chicken Breast 500 g` | 0.5 Kg | Meat Store | +| 42 | `Chicken Breast Boneless - Mini Pack` | 0.25 Kg | Meat Store | + +### 5. Chicken (regular cut) +Same cut of chicken โ€” small pieces vs large pieces. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 23 | `Chicken ( regular cut )- Small pieces` | 0.5 Kg | Meat Store | +| 40 | `Chicken (regular cut) Large pieces` | 0.5 Kg | Meat Store | + +### 6. Coriander Leaves (Kothimeer) +Same product listed twice โ€” one entry named "small 1". + +| product_info id | name | quantity | store | +|---|---|---|---| +| 76 | `Coriander Leaves (Kothimeer)` | 1.0 Pc | Vegetables | +| 101 | `Coriander Leaves (Kothimeer) small 1` | 1.0 Pc | Vegetables | + +### 7. Pineapple +Same product โ€” regular vs "Large". + +| product_info id | name | quantity | store | +|---|---|---|---| +| 57 | `Pineapple` | 1.0 Pc | The Fruit Store | +| 96 | `Pineapple โ€“ Large` | 1.0 Pc | The Fruit Store | + +### 8. Carrot / Red Carrot +Indian carrots are red/orange โ€” almost certainly the same vegetable at different weights. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 69 | `Carrot` | 0.25 Kg | Vegetables | +| 79 | `Red Carrot` | 0.5 Kg | Vegetables | + +### 9. Mutton / Lamb Mutton +Same meat sold under two names. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 4 | `Mutton` | 1.0 Kg | Meat Store | +| 12 | `Lamb Mutton` | 1.0 Kg | Meat Store | + +### 10. Raisins (kishmish) +Same product โ€” regular vs black raisins. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 131 | `Raisins (kishmish)` | 0.25 Kg | Dry Fruits store | +| 134 | `black raisin ( kishmish)` | 0.25 Kg | Dry Fruits store | + +### 11. Fig (Anjeer) / Dried Figs +Same fruit โ€” fresh vs dried. + +| product_info id | name | quantity | store | +|---|---|---|---| +| 26 | `Fig (Anjeer)` | 0.25 Kg | The Fruit Store | +| 132 | `Dried figs (Anjeer)` | 0.25 Kg | Dry Fruits store | + +### 12. Country Chicken +Same bird sold as "Desi live" vs "Farm Raised". + +| product_info id | name | quantity | store | +|---|---|---|---| +| 80 | `Country Chicken (Desi / Natu Kodi) live .` | 1.0 Kg | Meat Store | +| 128 | `Country Chicken (Farm Raised)` | 1.0 Kg | Meat Store | + +--- + +## ๐ŸŸก Borderline โ€” same family, but likely intentional (different varieties/parts) + +These share a base name but are arguably distinct products (different variety, colour or cut). +Listed for completeness in case any should be merged. + +| Group | product_info ids | +|---|---| +| Mango (generic) vs Kesari Mango vs Mallika Mango | 39, 125, 126 | +| Apple (pack of 3) vs Green Apple (pack 2) vs Irani Apple (pack of 4) | 6, 47, 100 | +| Grapes: Muscat / Green / Red Globe / Black Beauty | 55, 60, 61, 62 | +| Dragon Fruit: White vs Pink | 25, 63 | +| Cabbage vs Purple Cabbage | 66, 88 | +| Capsicum: green `Capsicun` vs Red & Yellow | 127, 93 | +| Prawns: Tiger (large) vs Medium (cleaned & deveined) | 81, 82 | +| Chicken parts: Liver, Lollipops, Leg piece, Leg with Thigh, Gizzard, Boneless, Whole | 1, 3, 10, 36, 15, 24, 21 | +| Mutton parts: Head, Brain, Paya, Chops, Soup Bones, Boti, Kheema | 20, 84, 85, 86, 87, 14, 35 | +| Brinjal vs Light Purple Brinjal | 27, 89 | +| Shahtoot (fruit) vs Shahtoot Malai (dessert) | 51, 119 | +| Fig (fruit) vs Fresh Fig Juice | 26, 105 | + +--- + +## Notes + +- The dump currently maps **1 product โ†’ 1 SKU โ†’ 1 `quantity` feature**, so every "duplicate" above + is a separate `product_info` row. Merging these into one product with multiple SKUs would also + require migrating `product_combos`, `product_group_membership`, `orders`/`order_items`, + `home_banners`, `delivery_slot_info.sku_ids`, `vendor_snippets.sku_ids` and `coupons.sku_ids` + references from the old SKU ids to the surviving ones. +- No price data exists in this dump's `product_skus`/`sku_features` (prices live elsewhere in the + current schema), so the comparison is based on name + quantity feature only. diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index dfcaa78..ea3700c 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -110,6 +110,11 @@ export { updateProductPrices, } from './src/admin-apis/product' +export { + // Merge duplicate products into multi-SKU products + mergeDuplicateProducts, +} from './src/admin-apis/merge-duplicate-products' + export { // Slots getActiveSlotsWithProducts, diff --git a/packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts b/packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts new file mode 100644 index 0000000..8fa941c --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts @@ -0,0 +1,494 @@ +/** + * merge-duplicate-products.ts + * + * Merges duplicate products (the same item entered as separate product_info rows) + * into single products with multiple SKUs. + * + * The merge groups are defined by PRODUCT NAME, sourced from + * docs/duplicate-products-local_8_aug.md โ€” not by hardcoded ids. The md's + * "Clear duplicates" section is embedded below (DUPLICATES_MD) and parsed at + * module load; GROUPS_TO_MERGE decides which sections actually get merged. + * + * For every selected group this function: + * 1. Resolves the group's product rows in the DB by normalized-name matching + * 2. Creates ONE new product_info row (clean name = md section header) + * 3. Creates a new product_skus row per old product, copying images + features + * (plus any group-specific extra features) and product_market_stats + * 4. Re-points every reference to the old SKUs / old products: + * product_tags.product_id, product_group_membership.product_id, + * order_items.sku_id, cart_items.sku_id, special_deals.sku_id, + * coupon_applicable_products.sku_id, product_combos.sku_id & combo_sku_id, + * product_reviews.product_id, and the JSON `sku_ids` arrays in + * home_banners, delivery_slot_info, vendor_snippets, coupons, reserved_coupons + * 5. Deletes the old product_market_stats, sku_features, product_skus and + * product_info rows + * + * Call it whenever you wish: + * + * import { mergeDuplicateProducts } from '@packages/db_helper_sqlite' + * const report = await mergeDuplicateProducts() + * + * You can also pass the md content explicitly (e.g. read from disk in a script): + * + * const md = await Bun.file('docs/duplicate-products-local_8_aug.md').text() + * const report = await mergeDuplicateProducts(md) + * + * It is safe to call repeatedly: groups whose rows no longer exist (e.g. from + * a previous run) are skipped and reported in `report.skippedGroups` instead + * of throwing. It only throws on genuinely anomalous data (a matched product + * with more than one SKU) or if any leftover reference to old ids survives. + */ + +import { db } from '../db/db_index' +import { eq, inArray, isNotNull, sql } from 'drizzle-orm' +import { + productInfo, + productSkus, + skuFeatures, + productMarketStats, + productTags, + productGroupMembership, + orderItems, + cartItems, + specialDeals, + couponApplicableProducts, + productCombos, + productReviews, + homeBanners, + deliverySlotInfo, + vendorSnippets, + coupons, + reservedCoupons, +} from '../db/schema' + +// ============================================================ +// SOURCE OF TRUTH โ€” snapshot of docs/duplicate-products-local_8_aug.md +// ("Clear duplicates" section). Keep this in sync with the doc. +// ============================================================ + +const DUPLICATES_MD = `### 1. Tomato +| 17 | \`Tomato\` | 1.0 Kg | Vegetables | +| 45 | \`Tomato\` | 0.5 Kg | Vegetables | + +### 2. Mutton Curry Cut (Regular) +| 98 | \`Mutton Curry Cut (Regular)โ€“ 500 g\` | 0.5 Kg | Meat Store | +| 99 | \`Mutton Curry Cut (Regular) โ€“ 750 g\` | 0.75 Kg | Meat Store | + +### 3. Mutton Boneless +| 28 | \`Mutton Boneless\` | 0.5 Kg | Meat Store | +| 34 | \`Mutton Boneless โ€“ Mini Pack\` | 0.25 Kg | Meat Store | + +### 4. Chicken Breast +| 2 | \`Chicken Breast 500 g\` | 0.5 Kg | Meat Store | +| 42 | \`Chicken Breast Boneless - Mini Pack\` | 0.25 Kg | Meat Store | + +### 5. Chicken (regular cut) +| 23 | \`Chicken ( regular cut )- Small pieces\` | 0.5 Kg | Meat Store | +| 40 | \`Chicken (regular cut) Large pieces\` | 0.5 Kg | Meat Store | + +### 6. Coriander Leaves (Kothimeer) +| 76 | \`Coriander Leaves (Kothimeer)\` | 1.0 Pc | Vegetables | +| 101 | \`Coriander Leaves (Kothimeer) small 1\` | 1.0 Pc | Vegetables | + +### 7. Pineapple +| 57 | \`Pineapple\` | 1.0 Pc | The Fruit Store | +| 96 | \`Pineapple โ€“ Large\` | 1.0 Pc | The Fruit Store | + +### 8. Carrot / Red Carrot +| 69 | \`Carrot\` | 0.25 Kg | Vegetables | +| 79 | \`Red Carrot\` | 0.5 Kg | Vegetables | + +### 9. Mutton / Lamb Mutton +| 4 | \`Mutton\` | 1.0 Kg | Meat Store | +| 12 | \`Lamb Mutton\` | 1.0 Kg | Meat Store | + +### 10. Raisins (kishmish) +| 131 | \`Raisins (kishmish)\` | 0.25 Kg | Dry Fruits store | +| 134 | \`black raisin ( kishmish)\` | 0.25 Kg | Dry Fruits store | + +### 11. Fig (Anjeer) / Dried Figs +| 26 | \`Fig (Anjeer)\` | 0.25 Kg | The Fruit Store | +| 132 | \`Dried figs (Anjeer)\` | 0.25 Kg | Dry Fruits store | + +### 12. Country Chicken +| 80 | \`Country Chicken (Desi / Natu Kodi) live .\` | 1.0 Kg | Meat Store | +| 128 | \`Country Chicken (Farm Raised)\` | 1.0 Kg | Meat Store |` + +/** Sections (by header name) that should actually be merged */ +const GROUPS_TO_MERGE = [ + 'Tomato', + 'Mutton Curry Cut (Regular)', + 'Mutton Boneless', + 'Chicken Breast', + 'Coriander Leaves (Kothimeer)', + 'Pineapple', +] + +/** + * Extra sku_features per OLD product name (keyed exactly as in the md table + * rows). Applied to the SKU created from whichever row matches that name. + */ +const EXTRA_FEATURES: Record = { + 'Coriander Leaves (Kothimeer)': [['bunch size', 'large']], + 'Coriander Leaves (Kothimeer) small 1': [['bunch size', 'small']], +} + +// ============================================================ +// md parsing โ€” build groups from the doc +// ============================================================ + +/** Parses "### N. Section" headers + "| id | `name` | ..." rows from the md. */ +export function parseDuplicateProductsMd(md: string): Map { + const sections = new Map() + let current: string | null = null + for (const line of md.split('\n')) { + const header = line.match(/^###\s+\d+\.\s+(.+)$/) + if (header) { + current = header[1].trim() + sections.set(current, []) + continue + } + if (!current) continue + const row = line.match(/^\|\s*\d+\s*\|\s*`(.+?)`\s*\|/) + if (row) sections.get(current)!.push(row[1]) + } + return sections +} + +interface MergeGroup { + /** Name of the new merged product (md section header) */ + name: string + /** Product names to match (md table rows) */ + productNames: string[] + /** Extra features keyed by the md product name they apply to */ + extraFeatures: Record +} + +const buildMergeGroups = (md: string): MergeGroup[] => { + const sections = parseDuplicateProductsMd(md) + return GROUPS_TO_MERGE + .map((sectionName) => ({ + name: sectionName, + productNames: sections.get(sectionName) ?? [], + extraFeatures: EXTRA_FEATURES, + })) + .filter((g) => g.productNames.length >= 2) +} + +// ============================================================ +// Types +// ============================================================ + +export interface MergedGroup { + name: string + newProductId: number + newSkuIds: number[] + oldProductIds: number[] +} + +export interface MergeReport { + groups: MergedGroup[] + skippedGroups: { name: string; reason: string }[] + updatedRows: number + reviewsUpdated: number + jsonRowsUpdated: Record + finalCounts: { + productInfo: number + productSkus: number + skuFeatures: number + productMarketStats: number + } +} + +// ============================================================ +// Name matching +// ============================================================ + +/** Trim, lowercase, collapse whitespace, unify dash variants (-, โ€“, โ€”, โˆ’). */ +const normalizeName = (s: string): string => + s + .toLowerCase() + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212-]/g, '-') + .replace(/\s+/g, ' ') + .trim() + +/** + * Returns which of the group's md names `dbName` matches (longest name wins, + * so e.g. "โ€ฆ small 1" is matched before the plain "โ€ฆ" name), or null. + */ +const matchProductName = (dbName: string, groupNames: string[]): string | null => { + const norm = normalizeName(dbName) + const byLengthDesc = [...groupNames].sort((a, b) => b.length - a.length) + for (const name of byLengthDesc) { + if (norm.includes(normalizeName(name))) return name + } + return null +} + +// ============================================================ +// Implementation +// ============================================================ + +export async function mergeDuplicateProducts(mdContent?: string): Promise { + const mergeGroups = buildMergeGroups(mdContent ?? DUPLICATES_MD) + + // ---- resolve products by name (no hardcoded ids) ------------------------ + const allProducts = await db.select().from(productInfo) + const assignedTo = new Set() // product ids already claimed by a group + + const resolvedGroups: { group: MergeGroup; productIds: number[]; matchedNames: Map }[] = [] + for (const group of mergeGroups) { + const productIds: number[] = [] + const matchedNames = new Map() + for (const product of allProducts) { + if (assignedTo.has(product.id) || !product.name) continue + const matched = matchProductName(product.name, group.productNames) + if (matched !== null) { + assignedTo.add(product.id) + productIds.push(product.id) + matchedNames.set(product.id, matched) + } + } + resolvedGroups.push({ group, productIds, matchedNames }) + } + + const skippedGroups: { name: string; reason: string }[] = [] + const productMap = new Map() // old product id -> new product id + const skuMap = new Map() // old sku id -> new sku id + const mergedGroups: MergedGroup[] = [] + + // ---- sanity checks per candidate + create new products and SKUs --------- + for (const { group, productIds, matchedNames } of resolvedGroups) { + const usableIds: number[] = [] + for (const pid of productIds) { + const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.productId, pid), + }) + if (skus.length === 0) { + console.log(`mergeDuplicateProducts: product ${pid} has no SKUs โ€” skipping it`) + continue + } + if (skus.length > 1) { + // A previously-merged product (named exactly like the group, with one + // SKU per merged row) legitimately has multiple SKUs โ€” treat it as + // already merged and skip instead of throwing. + const product = allProducts.find((p) => p.id === pid) + if (product?.name && normalizeName(product.name) === normalizeName(group.name)) { + console.log(`mergeDuplicateProducts: product ${pid} is the previously merged "${group.name}" โ€” skipping`) + continue + } + throw new Error(`mergeDuplicateProducts: product ${pid} has ${skus.length} SKUs, expected exactly 1`) + } + usableIds.push(pid) + } + + if (usableIds.length < 2) { + const reason = + usableIds.length === 0 + ? 'no rows matched by name (already merged?)' + : `only ${usableIds.length} of the group's products still exist` + console.log(`mergeDuplicateProducts: skipping group "${group.name}" โ€” ${reason}`) + skippedGroups.push({ name: group.name, reason }) + continue + } + + const [first] = await db.select().from(productInfo).where(eq(productInfo.id, usableIds[0])) + + const [newProduct] = await db.insert(productInfo) + .values({ + name: group.name, + shortDescription: first.shortDescription, + longDescription: first.longDescription, + storeId: first.storeId, + incrementStep: first.incrementStep ?? 1, + productType: first.productType ?? 'item', + createdAt: first.createdAt, + }) + .returning({ id: productInfo.id }) + const newProductId = newProduct.id + + const newSkuIds: number[] = [] + for (const oldPid of usableIds) { + productMap.set(oldPid, newProductId) + + const [oldSku] = await db.query.productSkus.findMany({ + where: eq(productSkus.productId, oldPid), + }) + + const [newSku] = await db.insert(productSkus) + .values({ + productId: newProductId, + name: null, + images: oldSku.images, + isOffer: oldSku.isOffer, + isComboOnly: oldSku.isComboOnly, + isDeleted: oldSku.isDeleted, + createdAt: oldSku.createdAt, + }) + .returning({ id: productSkus.id }) + const newSkuId = newSku.id + newSkuIds.push(newSkuId) + skuMap.set(oldSku.id, newSkuId) + + // copy features, then any group-specific extras for the matched name + const oldFeatures = await db.query.skuFeatures.findMany({ + where: eq(skuFeatures.skuId, oldSku.id), + }) + for (const f of oldFeatures) { + await db.insert(skuFeatures).values({ + skuId: newSkuId, + featureName: f.featureName, + featureValue: f.featureValue, + }) + } + const matched = matchedNames.get(oldPid) + for (const [featureName, featureValue] of (matched ? group.extraFeatures[matched] : undefined) ?? []) { + await db.insert(skuFeatures).values({ skuId: newSkuId, featureName, featureValue }) + } + + // copy market stats + const [ms] = await db.query.productMarketStats.findMany({ + where: eq(productMarketStats.skuId, oldSku.id), + }) + if (ms) { + await db.insert(productMarketStats).values({ + skuId: newSkuId, + marketPrice: ms.marketPrice, + ourPrice: ms.ourPrice, + isFlashAvailable: ms.isFlashAvailable, + flashPrice: ms.flashPrice, + isOutOfStock: ms.isOutOfStock, + isSuspended: ms.isSuspended, + }) + } + } + + mergedGroups.push({ name: group.name, newProductId, newSkuIds, oldProductIds: usableIds }) + } + + // ---- re-point FK references (old sku -> new sku) --------------------- + let updatedRows = 0 + const remapColumn = async ( + table: any, + column: string, + map: Map + ): Promise => { + let n = 0 + for (const [oldId, newId] of map) { + const res = await db.update(table) + .set({ [column]: newId }) + .where(eq(table[column], oldId)) + n += Number(res?.meta?.changes ?? res?.changes ?? 0) + } + return n + } + + // product_tags / product_group_membership hold SKU ids (post sku-split schema) + updatedRows += await remapColumn(productTags, 'productId', skuMap) + updatedRows += await remapColumn(productGroupMembership, 'productId', skuMap) + updatedRows += await remapColumn(orderItems, 'skuId', skuMap) + updatedRows += await remapColumn(cartItems, 'skuId', skuMap) + updatedRows += await remapColumn(specialDeals, 'skuId', skuMap) + updatedRows += await remapColumn(couponApplicableProducts, 'skuId', skuMap) + updatedRows += await remapColumn(productCombos, 'skuId', skuMap) + updatedRows += await remapColumn(productCombos, 'comboSkuId', skuMap) + + // product_reviews references product_info ids + let reviewsUpdated = 0 + for (const [oldPid, newPid] of productMap) { + const res = await db.update(productReviews) + .set({ productId: newPid }) + .where(eq(productReviews.productId, oldPid)) + reviewsUpdated += Number(res?.meta?.changes ?? res?.changes ?? 0) + } + + // ---- re-point JSON sku_ids arrays ------------------------------------ + const jsonRowsUpdated: Record = {} + const remapJsonArray = async ( + table: any, + column: string, + map: Map + ): Promise => { + let n = 0 + const rows = await db.select().from(table).where(isNotNull(table[column])) + for (const row of rows) { + const arr = row[column] + if (!Array.isArray(arr)) continue + let changed = false + const next = arr.map((x: number) => { + const mapped = map.get(Number(x)) + if (mapped !== undefined) { + changed = true + return mapped + } + return x + }) + if (changed) { + await db.update(table) + .set({ [column]: next }) + .where(eq(table.id, row.id)) + n++ + } + } + return n + } + + jsonRowsUpdated.homeBanners = await remapJsonArray(homeBanners, 'skuIds', skuMap) + jsonRowsUpdated.deliverySlotInfo = await remapJsonArray(deliverySlotInfo, 'skuIds', skuMap) + jsonRowsUpdated.vendorSnippets = await remapJsonArray(vendorSnippets, 'skuIds', skuMap) + jsonRowsUpdated.coupons = await remapJsonArray(coupons, 'skuIds', skuMap) + jsonRowsUpdated.reservedCoupons = await remapJsonArray(reservedCoupons, 'skuIds', skuMap) + + // ---- delete the old rows ---------------------------------------------- + const oldSkuIds = [...skuMap.keys()] + await db.delete(productMarketStats).where(inArray(productMarketStats.skuId, oldSkuIds)) + await db.delete(skuFeatures).where(inArray(skuFeatures.skuId, oldSkuIds)) + await db.delete(productSkus).where(inArray(productSkus.id, oldSkuIds)) + await db.delete(productInfo).where(inArray(productInfo.id, [...productMap.keys()])) + + // ---- verification: no leftover references --------------------------------- + const leftoverChecks: [string, number][] = [] + const countLeftovers = async (table: any, column: string): Promise => { + if (oldSkuIds.length === 0) return 0 + const rows = await db.select({ c: sql`1` }).from(table).where(inArray(table[column], oldSkuIds)) + return rows.length + } + + leftoverChecks.push(['product_tags.product_id', await countLeftovers(productTags, 'productId')]) + leftoverChecks.push(['product_group_membership.product_id', await countLeftovers(productGroupMembership, 'productId')]) + leftoverChecks.push(['order_items.sku_id', await countLeftovers(orderItems, 'skuId')]) + leftoverChecks.push(['cart_items.sku_id', await countLeftovers(cartItems, 'skuId')]) + leftoverChecks.push(['special_deals.sku_id', await countLeftovers(specialDeals, 'skuId')]) + leftoverChecks.push(['coupon_applicable_products.sku_id', await countLeftovers(couponApplicableProducts, 'skuId')]) + leftoverChecks.push(['product_combos.sku_id', await countLeftovers(productCombos, 'skuId')]) + leftoverChecks.push(['product_combos.combo_sku_id', await countLeftovers(productCombos, 'comboSkuId')]) + leftoverChecks.push(['product_market_stats.sku_id', await countLeftovers(productMarketStats, 'skuId')]) + leftoverChecks.push(['sku_features.sku_id', await countLeftovers(skuFeatures, 'skuId')]) + + const bad = leftoverChecks.filter(([, n]) => n > 0) + if (bad.length > 0) { + throw new Error( + `mergeDuplicateProducts: leftover references to old ids: ${bad.map(([t, n]) => `${t}: ${n}`).join(', ')}` + ) + } + + // ---- final counts --------------------------------------------------------- + const countRows = async (table: any): Promise => { + const rows = await db.select({ c: sql`1` }).from(table) + return rows.length + } + + return { + groups: mergedGroups, + skippedGroups, + updatedRows, + reviewsUpdated, + jsonRowsUpdated, + finalCounts: { + productInfo: await countRows(productInfo), + productSkus: await countRows(productSkus), + skuFeatures: await countRows(skuFeatures), + productMarketStats: await countRows(productMarketStats), + }, + } +} diff --git a/packages/db_helper_sqlite/src/admin-apis/order.ts b/packages/db_helper_sqlite/src/admin-apis/order.ts index 66b5d41..40572b2 100644 --- a/packages/db_helper_sqlite/src/admin-apis/order.ts +++ b/packages/db_helper_sqlite/src/admin-apis/order.ts @@ -237,7 +237,7 @@ export async function getOrderDetails(orderId: number): Promise ({ id: item.id, - name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []), + name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || [], item.sku.name), skuName: item.sku.name ?? null, quantity: item.quantity, productSize: 1, @@ -376,7 +376,7 @@ export async function getSlotOrders(slotId: string): Promise ({ id: item.id, - name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []), + name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || [], item.sku.name), skuName: item.sku.name ?? null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), @@ -546,7 +546,7 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise ({ id: item.id, - name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []), + name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || [], item.sku.name), skuName: item.sku.name ?? null, quantity: parseFloat(item.quantity), price: parseFloat(item.price.toString()), diff --git a/packages/db_helper_sqlite/src/lib/sku-features.ts b/packages/db_helper_sqlite/src/lib/sku-features.ts index 483933a..cebd70f 100644 --- a/packages/db_helper_sqlite/src/lib/sku-features.ts +++ b/packages/db_helper_sqlite/src/lib/sku-features.ts @@ -37,8 +37,13 @@ export function composeUnitNotation(features: SkuFeatureLike[]): string { /** * Builds the display name for a SKU: the product name followed by the * values of all non-quantity features (values only). + * + * If `explicitName` (the optional `product_skus.name` column) is set and + * non-blank, it takes precedence over the computed name. */ -export function composeSkuName(baseName: string, features: SkuFeatureLike[]): string { +export function composeSkuName(baseName: string, features: SkuFeatureLike[], explicitName?: string | null): string { + const trimmed = explicitName?.trim() + if (trimmed) return trimmed const { others } = splitQuantityFeature(features) return [baseName, ...others].filter(Boolean).join(' ') } diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index b89bd94..618b225 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -134,7 +134,7 @@ export async function getAllProductsForCache(): Promise { return { id: sku.id, productId: sku.productId, - name: composeSkuName(sku.product?.name ?? 'Unknown', features), + name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name), skuName: sku.name ?? null, shortDescription: sku.product?.shortDescription ?? null, longDescription: sku.product?.longDescription ?? null, @@ -250,7 +250,7 @@ export async function getAllProductCombosForCache(): Promise < scripts/clear-db.sql +-- wrangler d1 execute --local --file scripts/clear-db.sql +-- wrangler d1 execute --remote --file scripts/clear-db.sql +-- +-- WARNING: This DROPS every table in the database. There is no undo. +-- ============================================================ + +-- Don't enforce FK constraints while dropping (ordering below is +-- child-first anyway, so this is belt-and-braces). +PRAGMA defer_foreign_keys = on; +PRAGMA foreign_keys = OFF; + +-- ---- leaf tables (no inbound references) ---- +DROP TABLE IF EXISTS sku_features; +DROP TABLE IF EXISTS product_market_stats; +DROP TABLE IF EXISTS product_tags; +DROP TABLE IF EXISTS product_group_membership; +DROP TABLE IF EXISTS product_combos; +DROP TABLE IF EXISTS order_items; +DROP TABLE IF EXISTS cart_items; +DROP TABLE IF EXISTS special_deals; +DROP TABLE IF EXISTS coupon_applicable_products; +DROP TABLE IF EXISTS product_reviews; + +-- ---- product/sku hierarchy ---- +DROP TABLE IF EXISTS product_skus; +DROP TABLE IF EXISTS product_info; +DROP TABLE IF EXISTS store_info; + +-- ---- orders & dependents ---- +DROP TABLE IF EXISTS coupon_usage; +DROP TABLE IF EXISTS order_status; +DROP TABLE IF EXISTS payments; +DROP TABLE IF EXISTS refunds; +DROP TABLE IF EXISTS complaints; +DROP TABLE IF EXISTS user_incidents; +DROP TABLE IF EXISTS orders; +DROP TABLE IF EXISTS payment_info; +DROP TABLE IF EXISTS coupon_applicable_users; + +-- ---- coupons & snippets ---- +DROP TABLE IF EXISTS coupons; +DROP TABLE IF EXISTS reserved_coupons; +DROP TABLE IF EXISTS vendor_snippets; +DROP TABLE IF EXISTS delivery_slot_info; + +-- ---- content/catalog ---- +DROP TABLE IF EXISTS home_banners; +DROP TABLE IF EXISTS product_group_info; +DROP TABLE IF EXISTS product_tag_info; +DROP TABLE IF EXISTS product_categories; +DROP TABLE IF EXISTS units; +DROP TABLE IF EXISTS upload_url_status; + +-- ---- notifications / misc ---- +DROP TABLE IF EXISTS user_notifications; +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS notif_creds; +DROP TABLE IF EXISTS key_val_store; + +-- ---- addresses ---- +DROP TABLE IF EXISTS addresses; +DROP TABLE IF EXISTS address_areas; +DROP TABLE IF EXISTS address_zones; + +-- ---- users ---- +DROP TABLE IF EXISTS unlogged_user_tokens; +DROP TABLE IF EXISTS user_creds; +DROP TABLE IF EXISTS user_details; +DROP TABLE IF EXISTS users; + +-- ---- staff ---- +DROP TABLE IF EXISTS staff_role_permissions; +DROP TABLE IF EXISTS staff_users; +DROP TABLE IF EXISTS staff_roles; +DROP TABLE IF EXISTS staff_permissions; + +-- (sqlite_sequence is internal; SQLite drops it automatically once the last +-- AUTOINCREMENT table is gone) + +PRAGMA foreign_keys = ON; +PRAGMA defer_foreign_keys = off; + +-- Optionally reclaim disk space (sqlite3 CLI only; not supported in D1): +-- VACUUM;