This commit is contained in:
shafi54 2026-08-15 10:34:28 +05:30
parent 21002332fb
commit 48decbac6f
13 changed files with 760 additions and 13 deletions

View file

@ -340,6 +340,14 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
)}
</View>
<MyTextInput
topLabel="SKU Name (optional)"
placeholder="Overrides the auto-generated name"
value={variant.name ?? ''}
onChangeText={handleChange(`variants.${vIndex}.name`)}
style={{ marginBottom: 12 }}
/>
<FieldArray name={`variants.${vIndex}.attributes`}>
{({ push: pushAttr, remove: removeAttr }) => (
<View style={tw`mb-3`}>

View file

@ -94,6 +94,8 @@ export {
addProductToGroup,
removeProductFromGroup,
updateProductPrices,
// Merge duplicate products into multi-SKU products
mergeDuplicateProducts,
// Admin - Slots
getActiveSlotsWithProducts,
getActiveSlots,

View file

@ -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<typeof createApp> | 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(

View file

@ -62,7 +62,7 @@ const OffersSection = ({
</View>
) : (
<>
<View style={tw`flex-row flex-wrap justify-between px-4`}>
<View style={tw`flex-row flex-wrap gap-5 px-4`}>
{visible.map((item: any) => (
<View key={item.id} style={tw`mb-3`}>
<ProductCard

View file

@ -0,0 +1,141 @@
# Duplicate / Same-Product Products in `local_8_aug.sql`
**Source:** `apps/backend/dumps/local_8_aug.sql` · **product_info rows:** 135 · **SKUs:** 135 (each product currently has exactly one SKU in this dump)
> 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.

View file

@ -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,

View file

@ -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<string, [string, string][]> = {
'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<string, string[]> {
const sections = new Map<string, string[]>()
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<string, [string, string][]>
}
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<string, number>
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<MergeReport> {
const mergeGroups = buildMergeGroups(mdContent ?? DUPLICATES_MD)
// ---- resolve products by name (no hardcoded ids) ------------------------
const allProducts = await db.select().from(productInfo)
const assignedTo = new Set<number>() // product ids already claimed by a group
const resolvedGroups: { group: MergeGroup; productIds: number[]; matchedNames: Map<number, string> }[] = []
for (const group of mergeGroups) {
const productIds: number[] = []
const matchedNames = new Map<number, string>()
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<number, number>() // old product id -> new product id
const skuMap = new Map<number, number>() // 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<number, number>
): Promise<number> => {
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<string, number> = {}
const remapJsonArray = async (
table: any,
column: string,
map: Map<number, number>
): Promise<number> => {
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<number> => {
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<number> => {
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),
},
}
}

View file

@ -237,7 +237,7 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
isDelivered: orderStatusRecord?.isDelivered || false,
items: orderData.orderItems.map((item: any) => ({
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<AdminGetSlotOrdersR
const items = order.orderItems.map((item: any) => ({
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<AdminGetAl
const items = order.orderItems
.map((item: any) => ({
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()),

View file

@ -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(' ')
}

View file

@ -134,7 +134,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
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<ProductComboCacheDa
return {
comboSkuId: ci.comboSkuId,
skuId: ci.skuId,
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', features),
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', features, ci.sku?.name),
skuName: ci.sku?.name ?? null,
images: ci.sku?.images,
unitNotation: composeUnitNotation(features),
@ -383,7 +383,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
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,
productQuantity: 1,
shortDescription: sku.product?.shortDescription ?? null,

View file

@ -63,7 +63,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
skuId: ci.skuId,
skuName: ci.sku?.name ?? null,
unitNotation: composeUnitNotation(ciFeatures),
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures),
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures, ci.sku?.name),
images: getStringArray(ci.sku?.images),
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
isOffer: ci.sku?.isOffer ?? false,
@ -73,7 +73,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
return {
id: sku.id,
productId: sku.productId,
name: composeSkuName(product?.name ?? 'Unknown', features),
name: composeSkuName(product?.name ?? 'Unknown', features, sku.name),
shortDescription: product?.shortDescription ?? null,
longDescription: product?.longDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
@ -250,7 +250,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
const marketStats = sku.marketStats
return {
id: sku.product?.id ?? 0,
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
skuId: sku.id,
skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null,
@ -369,7 +369,7 @@ const mapOffersPageProduct = (sku: {
const features = sku.features || []
return {
id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0',
marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null,
unitNotation: composeUnitNotation(features),

View file

@ -93,7 +93,7 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
const marketStats = sku.marketStats
return {
id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
shortDescription: sku.product?.shortDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,

89
scripts/clear-db.sql Normal file
View file

@ -0,0 +1,89 @@
-- ============================================================
-- clear-db.sql — DROP ALL TABLES (schema + data, everything)
--
-- Usage:
-- sqlite3 <your.db> < scripts/clear-db.sql
-- wrangler d1 execute <db-name> --local --file scripts/clear-db.sql
-- wrangler d1 execute <db-name> --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;