enh
This commit is contained in:
parent
21002332fb
commit
48decbac6f
13 changed files with 760 additions and 13 deletions
|
|
@ -340,6 +340,14 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
)}
|
)}
|
||||||
</View>
|
</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`}>
|
<FieldArray name={`variants.${vIndex}.attributes`}>
|
||||||
{({ push: pushAttr, remove: removeAttr }) => (
|
{({ push: pushAttr, remove: removeAttr }) => (
|
||||||
<View style={tw`mb-3`}>
|
<View style={tw`mb-3`}>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ export {
|
||||||
addProductToGroup,
|
addProductToGroup,
|
||||||
removeProductFromGroup,
|
removeProductFromGroup,
|
||||||
updateProductPrices,
|
updateProductPrices,
|
||||||
|
// Merge duplicate products into multi-SKU products
|
||||||
|
mergeDuplicateProducts,
|
||||||
// Admin - Slots
|
// Admin - Slots
|
||||||
getActiveSlotsWithProducts,
|
getActiveSlotsWithProducts,
|
||||||
getActiveSlots,
|
getActiveSlots,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { CacheCreator } from './src/jobs/cache-creator'
|
||||||
import { createApp } from './src/app'
|
import { createApp } from './src/app'
|
||||||
import { ensureWorkerInit } from './src/lib/worker-init'
|
import { ensureWorkerInit } from './src/lib/worker-init'
|
||||||
import { runFlashDeliveryToggleCron } from './src/lib/flash-delivery-cron'
|
import { runFlashDeliveryToggleCron } from './src/lib/flash-delivery-cron'
|
||||||
|
import { mergeDuplicateProducts } from './src/sqliteImporter'
|
||||||
import {
|
import {
|
||||||
handleNotifQueue,
|
handleNotifQueue,
|
||||||
handleOrderPlacedQueue,
|
handleOrderPlacedQueue,
|
||||||
|
|
@ -17,6 +18,7 @@ export { CacheCreator }
|
||||||
|
|
||||||
let app: ReturnType<typeof createApp> | null = null
|
let app: ReturnType<typeof createApp> | null = null
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(
|
async fetch(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -39,6 +41,7 @@ export default {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = createApp()
|
app = createApp()
|
||||||
}
|
}
|
||||||
|
// await mergeDuplicateProducts()
|
||||||
return app.fetch(request, env, ctx)
|
return app.fetch(request, env, ctx)
|
||||||
},
|
},
|
||||||
async queue(
|
async queue(
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ const OffersSection = ({
|
||||||
</View>
|
</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) => (
|
{visible.map((item: any) => (
|
||||||
<View key={item.id} style={tw`mb-3`}>
|
<View key={item.id} style={tw`mb-3`}>
|
||||||
<ProductCard
|
<ProductCard
|
||||||
|
|
|
||||||
141
docs/duplicate-products-local_8_aug.md
Normal file
141
docs/duplicate-products-local_8_aug.md
Normal 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.
|
||||||
|
|
@ -110,6 +110,11 @@ export {
|
||||||
updateProductPrices,
|
updateProductPrices,
|
||||||
} from './src/admin-apis/product'
|
} from './src/admin-apis/product'
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Merge duplicate products into multi-SKU products
|
||||||
|
mergeDuplicateProducts,
|
||||||
|
} from './src/admin-apis/merge-duplicate-products'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Slots
|
// Slots
|
||||||
getActiveSlotsWithProducts,
|
getActiveSlotsWithProducts,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -237,7 +237,7 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
|
||||||
isDelivered: orderStatusRecord?.isDelivered || false,
|
isDelivered: orderStatusRecord?.isDelivered || false,
|
||||||
items: orderData.orderItems.map((item: any) => ({
|
items: orderData.orderItems.map((item: any) => ({
|
||||||
id: item.id,
|
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,
|
skuName: item.sku.name ?? null,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
productSize: 1,
|
productSize: 1,
|
||||||
|
|
@ -376,7 +376,7 @@ export async function getSlotOrders(slotId: string): Promise<AdminGetSlotOrdersR
|
||||||
|
|
||||||
const items = order.orderItems.map((item: any) => ({
|
const items = order.orderItems.map((item: any) => ({
|
||||||
id: item.id,
|
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,
|
skuName: item.sku.name ?? null,
|
||||||
quantity: parseFloat(item.quantity),
|
quantity: parseFloat(item.quantity),
|
||||||
price: parseFloat(item.price.toString()),
|
price: parseFloat(item.price.toString()),
|
||||||
|
|
@ -546,7 +546,7 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise<AdminGetAl
|
||||||
const items = order.orderItems
|
const items = order.orderItems
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
id: item.id,
|
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,
|
skuName: item.sku.name ?? null,
|
||||||
quantity: parseFloat(item.quantity),
|
quantity: parseFloat(item.quantity),
|
||||||
price: parseFloat(item.price.toString()),
|
price: parseFloat(item.price.toString()),
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,13 @@ export function composeUnitNotation(features: SkuFeatureLike[]): string {
|
||||||
/**
|
/**
|
||||||
* Builds the display name for a SKU: the product name followed by the
|
* Builds the display name for a SKU: the product name followed by the
|
||||||
* values of all non-quantity features (values only).
|
* 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)
|
const { others } = splitQuantityFeature(features)
|
||||||
return [baseName, ...others].filter(Boolean).join(' ')
|
return [baseName, ...others].filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
||||||
return {
|
return {
|
||||||
id: sku.id,
|
id: sku.id,
|
||||||
productId: sku.productId,
|
productId: sku.productId,
|
||||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
|
||||||
skuName: sku.name ?? null,
|
skuName: sku.name ?? null,
|
||||||
shortDescription: sku.product?.shortDescription ?? null,
|
shortDescription: sku.product?.shortDescription ?? null,
|
||||||
longDescription: sku.product?.longDescription ?? null,
|
longDescription: sku.product?.longDescription ?? null,
|
||||||
|
|
@ -250,7 +250,7 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
|
||||||
return {
|
return {
|
||||||
comboSkuId: ci.comboSkuId,
|
comboSkuId: ci.comboSkuId,
|
||||||
skuId: ci.skuId,
|
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,
|
skuName: ci.sku?.name ?? null,
|
||||||
images: ci.sku?.images,
|
images: ci.sku?.images,
|
||||||
unitNotation: composeUnitNotation(features),
|
unitNotation: composeUnitNotation(features),
|
||||||
|
|
@ -383,7 +383,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
||||||
return {
|
return {
|
||||||
id: sku.id,
|
id: sku.id,
|
||||||
productId: sku.productId,
|
productId: sku.productId,
|
||||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
|
||||||
skuName: sku.name ?? null,
|
skuName: sku.name ?? null,
|
||||||
productQuantity: 1,
|
productQuantity: 1,
|
||||||
shortDescription: sku.product?.shortDescription ?? null,
|
shortDescription: sku.product?.shortDescription ?? null,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
|
||||||
skuId: ci.skuId,
|
skuId: ci.skuId,
|
||||||
skuName: ci.sku?.name ?? null,
|
skuName: ci.sku?.name ?? null,
|
||||||
unitNotation: composeUnitNotation(ciFeatures),
|
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),
|
images: getStringArray(ci.sku?.images),
|
||||||
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
||||||
isOffer: ci.sku?.isOffer ?? false,
|
isOffer: ci.sku?.isOffer ?? false,
|
||||||
|
|
@ -73,7 +73,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
|
||||||
return {
|
return {
|
||||||
id: sku.id,
|
id: sku.id,
|
||||||
productId: sku.productId,
|
productId: sku.productId,
|
||||||
name: composeSkuName(product?.name ?? 'Unknown', features),
|
name: composeSkuName(product?.name ?? 'Unknown', features, sku.name),
|
||||||
shortDescription: product?.shortDescription ?? null,
|
shortDescription: product?.shortDescription ?? null,
|
||||||
longDescription: product?.longDescription ?? null,
|
longDescription: product?.longDescription ?? null,
|
||||||
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
|
|
@ -250,7 +250,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
|
||||||
const marketStats = sku.marketStats
|
const marketStats = sku.marketStats
|
||||||
return {
|
return {
|
||||||
id: sku.product?.id ?? 0,
|
id: sku.product?.id ?? 0,
|
||||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
|
||||||
skuId: sku.id,
|
skuId: sku.id,
|
||||||
skuName: sku.name ?? null,
|
skuName: sku.name ?? null,
|
||||||
shortDescription: sku.product?.shortDescription ?? null,
|
shortDescription: sku.product?.shortDescription ?? null,
|
||||||
|
|
@ -369,7 +369,7 @@ const mapOffersPageProduct = (sku: {
|
||||||
const features = sku.features || []
|
const features = sku.features || []
|
||||||
return {
|
return {
|
||||||
id: sku.id,
|
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',
|
price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null,
|
marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null,
|
||||||
unitNotation: composeUnitNotation(features),
|
unitNotation: composeUnitNotation(features),
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
|
||||||
const marketStats = sku.marketStats
|
const marketStats = sku.marketStats
|
||||||
return {
|
return {
|
||||||
id: sku.id,
|
id: sku.id,
|
||||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
|
||||||
shortDescription: sku.product?.shortDescription ?? null,
|
shortDescription: sku.product?.shortDescription ?? null,
|
||||||
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
||||||
|
|
|
||||||
89
scripts/clear-db.sql
Normal file
89
scripts/clear-db.sql
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue