427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
import { db } from '../db/db_index'
|
|
import {
|
|
deliverySlotInfo,
|
|
productInfo,
|
|
productSkus,
|
|
skuFeatures,
|
|
vendorSnippets,
|
|
productGroupInfo,
|
|
} from '../db/schema'
|
|
import { and, asc, desc, eq, gt, inArray } from 'drizzle-orm'
|
|
import type {
|
|
AdminDeliverySlot,
|
|
AdminSlotWithProducts,
|
|
AdminSlotWithProductsAndSnippetsBase,
|
|
AdminSlotCreateResult,
|
|
AdminSlotUpdateResult,
|
|
AdminVendorSnippet,
|
|
AdminSlotProductSummary,
|
|
AdminUpdateSlotCapacityResult,
|
|
} from '@packages/shared'
|
|
import { coerceDate } from '../lib/date'
|
|
|
|
type SlotSnippetInput = {
|
|
name: string
|
|
skuIds: number[]
|
|
validTill?: string
|
|
}
|
|
|
|
const getStringArray = (value: unknown): string[] | null => {
|
|
if (!Array.isArray(value)) return null
|
|
return value.map((item) => String(item))
|
|
}
|
|
|
|
const getNumberArray = (value: unknown): number[] => {
|
|
if (!Array.isArray(value)) return []
|
|
return value.map((item) => Number(item))
|
|
}
|
|
|
|
const normalizeSkuIds = (value: unknown): number[] => {
|
|
if (!Array.isArray(value)) return []
|
|
const ids = value
|
|
.map((item) => Number(item))
|
|
.filter((item) => Number.isFinite(item))
|
|
|
|
return Array.from(new Set(ids))
|
|
}
|
|
|
|
const chunkArray = <T>(items: T[], size: number): T[][] => {
|
|
if (size <= 0) return [items]
|
|
const chunks: T[][] = []
|
|
for (let i = 0; i < items.length; i += size) {
|
|
chunks.push(items.slice(i, i + size))
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
const SKU_ID_CHUNK_SIZE = 40
|
|
|
|
const fetchExistingSkuIds = async (tx: any, skuIds: number[]) => {
|
|
const existingIds = new Set<number>()
|
|
const chunks = chunkArray(skuIds, SKU_ID_CHUNK_SIZE)
|
|
for (const chunk of chunks) {
|
|
if (chunk.length === 0) continue
|
|
const skus = await tx.query.productSkus.findMany({
|
|
where: inArray(productSkus.id, chunk),
|
|
columns: { id: true },
|
|
})
|
|
skus.forEach((sku: { id: number }) => existingIds.add(sku.id))
|
|
}
|
|
return existingIds
|
|
}
|
|
|
|
const mapDeliverySlot = (slot: typeof deliverySlotInfo.$inferSelect): AdminDeliverySlot => ({
|
|
id: slot.id,
|
|
deliveryTime: coerceDate(slot.deliveryTime) ?? new Date(0),
|
|
freezeTime: coerceDate(slot.freezeTime) ?? new Date(0),
|
|
isActive: slot.isActive,
|
|
isFlash: slot.isFlash,
|
|
isCapacityFull: slot.isCapacityFull,
|
|
deliverySequence: slot.deliverySequence,
|
|
groupIds: slot.groupIds,
|
|
})
|
|
|
|
const mapSlotSkuSummary = (sku: { id: number; images: unknown; name: string | null; product: { name: string } | null; features: Array<{ featureName: string; featureValue: string }> }): AdminSlotProductSummary => ({
|
|
id: sku.id,
|
|
name: sku.product?.name ?? 'Unknown',
|
|
images: getStringArray(sku.images),
|
|
skuName: sku.name ?? null,
|
|
features: (sku.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
|
|
})
|
|
|
|
const mapVendorSnippet = (snippet: typeof vendorSnippets.$inferSelect): AdminVendorSnippet => ({
|
|
id: snippet.id,
|
|
snippetCode: snippet.snippetCode,
|
|
slotId: snippet.slotId ?? null,
|
|
skuIds: snippet.skuIds || [],
|
|
isPermanent: snippet.isPermanent,
|
|
validTill: coerceDate(snippet.validTill),
|
|
createdAt: coerceDate(snippet.createdAt) ?? new Date(0),
|
|
})
|
|
|
|
export async function getActiveSlotsWithProducts(limit: number = 20): Promise<AdminSlotWithProducts[]> {
|
|
const slots = await db.query.deliverySlotInfo
|
|
.findMany({
|
|
where: eq(deliverySlotInfo.isActive, true),
|
|
orderBy: desc(deliverySlotInfo.deliveryTime),
|
|
limit,
|
|
})
|
|
|
|
// Get all unique SKU IDs from all slots
|
|
const allSkuIds = new Set<number>()
|
|
for (const slot of slots) {
|
|
for (const skuId of (slot.skuIds || [])) {
|
|
allSkuIds.add(skuId)
|
|
}
|
|
}
|
|
|
|
// Fetch all SKUs in one query
|
|
const skuIdsArray = Array.from(allSkuIds)
|
|
const skuIdsSet = new Set(skuIdsArray)
|
|
|
|
let skusData = await db.query.productSkus.findMany({
|
|
with: { features: true, product: true },
|
|
})
|
|
skusData = skusData.filter((item: any) => skuIdsSet.has(item.id))
|
|
|
|
// Create a map for quick lookup
|
|
const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
|
|
|
|
return slots.map((slot) => ({
|
|
...mapDeliverySlot(slot),
|
|
deliverySequence: getNumberArray(slot.deliverySequence),
|
|
products: (slot.skuIds || [])
|
|
.map((skuId: number) => skuMap.get(skuId))
|
|
.filter((p): p is NonNullable<typeof p> => p != null)
|
|
.map((sku: any) => mapSlotSkuSummary(sku)),
|
|
}))
|
|
}
|
|
|
|
export async function staleSlotsCleanup(): Promise<number> {
|
|
// Get the 20 most recent slot IDs
|
|
const recentSlots = await db
|
|
.select({ id: deliverySlotInfo.id })
|
|
.from(deliverySlotInfo)
|
|
.orderBy(desc(deliverySlotInfo.id))
|
|
.limit(20)
|
|
|
|
if (recentSlots.length < 20) {
|
|
return 0 // Less than 20 slots exist, nothing to clean
|
|
}
|
|
|
|
// Get the threshold (the 20th most recent slot ID - minimum of the 20)
|
|
const threshold = Math.min(...recentSlots.map((s) => s.id))
|
|
|
|
// Clear productIds for all slots older than threshold
|
|
const result = await db
|
|
.update(deliverySlotInfo)
|
|
.set({ skuIds: [] })
|
|
.where(eq(deliverySlotInfo.id, threshold))
|
|
|
|
return 1
|
|
}
|
|
|
|
export async function getActiveSlots(): Promise<AdminDeliverySlot[]> {
|
|
const slots = await db.query.deliverySlotInfo.findMany({
|
|
where: eq(deliverySlotInfo.isActive, true),
|
|
})
|
|
|
|
return slots.map(mapDeliverySlot)
|
|
}
|
|
|
|
export async function getSlotsAfterDate(afterDate: Date): Promise<AdminDeliverySlot[]> {
|
|
const slots = await db.query.deliverySlotInfo.findMany({
|
|
where: and(
|
|
eq(deliverySlotInfo.isActive, true),
|
|
gt(deliverySlotInfo.deliveryTime, afterDate)
|
|
),
|
|
orderBy: asc(deliverySlotInfo.deliveryTime),
|
|
})
|
|
|
|
return slots.map(mapDeliverySlot)
|
|
}
|
|
|
|
export async function getSlotByIdWithRelations(id: number): Promise<AdminSlotWithProductsAndSnippetsBase | null> {
|
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
|
where: eq(deliverySlotInfo.id, id),
|
|
with: {
|
|
vendorSnippets: true,
|
|
},
|
|
})
|
|
|
|
if (!slot) {
|
|
return null
|
|
}
|
|
|
|
// Fetch SKUs for this slot
|
|
const skuIds = slot.skuIds || []
|
|
const skuIdSet = new Set(skuIds)
|
|
let skusData = skuIds.length > 0
|
|
? await db.query.productSkus.findMany({
|
|
with: { features: true, product: true },
|
|
columns: { id: true, images: true, name: true },
|
|
})
|
|
: []
|
|
skusData = skusData.filter((item: any) => skuIdSet.has(item.id))
|
|
return {
|
|
...mapDeliverySlot(slot),
|
|
deliverySequence: getNumberArray(slot.deliverySequence),
|
|
groupIds: getNumberArray(slot.groupIds),
|
|
products: skusData.map((sku: any) => mapSlotSkuSummary(sku)),
|
|
vendorSnippets: slot.vendorSnippets.map(mapVendorSnippet),
|
|
}
|
|
}
|
|
|
|
export async function createSlotWithRelations(input: {
|
|
deliveryTime: string
|
|
freezeTime: string
|
|
isActive?: boolean
|
|
skuIds?: number[]
|
|
vendorSnippets?: SlotSnippetInput[]
|
|
groupIds?: number[]
|
|
}): Promise<AdminSlotCreateResult> {
|
|
const { deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input
|
|
|
|
const normalizedSkuIds = normalizeSkuIds(skuIds)
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
// Validate SKU IDs if provided
|
|
if (normalizedSkuIds.length > 0) {
|
|
const existingIds = await fetchExistingSkuIds(tx, normalizedSkuIds)
|
|
const missingIds = normalizedSkuIds.filter((skuId) => !existingIds.has(skuId))
|
|
if (missingIds.length > 0) {
|
|
throw new Error(`Invalid SKU IDs: ${missingIds.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
const [newSlot] = await tx
|
|
.insert(deliverySlotInfo)
|
|
.values({
|
|
deliveryTime: new Date(deliveryTime),
|
|
freezeTime: new Date(freezeTime),
|
|
isActive: isActive !== undefined ? isActive : true,
|
|
groupIds: groupIds !== undefined ? groupIds : [],
|
|
skuIds: normalizedSkuIds,
|
|
})
|
|
.returning()
|
|
|
|
let createdSnippets: AdminVendorSnippet[] = []
|
|
if (snippets && snippets.length > 0) {
|
|
for (const snippet of snippets) {
|
|
const skus = await tx.query.productSkus.findMany({
|
|
where: inArray(productSkus.id, snippet.skuIds),
|
|
})
|
|
if (skus.length !== snippet.skuIds.length) {
|
|
throw new Error(`One or more invalid SKU IDs in snippet "${snippet.name}"`)
|
|
}
|
|
|
|
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
|
where: eq(vendorSnippets.snippetCode, snippet.name),
|
|
})
|
|
if (existingSnippet) {
|
|
throw new Error(`Snippet name "${snippet.name}" already exists`)
|
|
}
|
|
|
|
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
|
snippetCode: snippet.name,
|
|
slotId: newSlot.id,
|
|
skuIds: snippet.skuIds,
|
|
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
|
}).returning()
|
|
|
|
createdSnippets.push(mapVendorSnippet(createdSnippet))
|
|
}
|
|
}
|
|
|
|
return {
|
|
slot: mapDeliverySlot(newSlot),
|
|
createdSnippets,
|
|
message: 'Slot created successfully',
|
|
}
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
export async function updateSlotWithRelations(input: {
|
|
id: number
|
|
deliveryTime: string
|
|
freezeTime: string
|
|
isActive?: boolean
|
|
skuIds?: number[]
|
|
vendorSnippets?: SlotSnippetInput[]
|
|
groupIds?: number[]
|
|
}): Promise<AdminSlotUpdateResult | null> {
|
|
const { id, deliveryTime, freezeTime, isActive, skuIds, vendorSnippets: snippets, groupIds } = input
|
|
|
|
let validGroupIds = groupIds
|
|
if (groupIds && groupIds.length > 0) {
|
|
const existingGroups = await db.query.productGroupInfo.findMany({
|
|
where: inArray(productGroupInfo.id, groupIds),
|
|
columns: { id: true },
|
|
})
|
|
validGroupIds = existingGroups.map((group: { id: number }) => group.id)
|
|
}
|
|
|
|
const normalizedSkuIds = skuIds !== undefined ? normalizeSkuIds(skuIds) : undefined
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
// Validate SKU IDs if provided
|
|
if (normalizedSkuIds !== undefined && normalizedSkuIds.length > 0) {
|
|
const existingIds = await fetchExistingSkuIds(tx, normalizedSkuIds)
|
|
const missingIds = normalizedSkuIds.filter((skuId) => !existingIds.has(skuId))
|
|
if (missingIds.length > 0) {
|
|
throw new Error(`Invalid SKU IDs: ${missingIds.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
const [updatedSlot] = await tx
|
|
.update(deliverySlotInfo)
|
|
.set({
|
|
deliveryTime: new Date(deliveryTime),
|
|
freezeTime: new Date(freezeTime),
|
|
isActive: isActive !== undefined ? isActive : true,
|
|
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
|
...(normalizedSkuIds !== undefined && { skuIds: normalizedSkuIds }),
|
|
})
|
|
.where(eq(deliverySlotInfo.id, id))
|
|
.returning()
|
|
|
|
if (!updatedSlot) {
|
|
return null
|
|
}
|
|
|
|
let createdSnippets: AdminVendorSnippet[] = []
|
|
if (snippets && snippets.length > 0) {
|
|
for (const snippet of snippets) {
|
|
const skus = await tx.query.productSkus.findMany({
|
|
where: inArray(productSkus.id, snippet.skuIds),
|
|
})
|
|
if (skus.length !== snippet.skuIds.length) {
|
|
throw new Error(`One or more invalid SKU IDs in snippet "${snippet.name}"`)
|
|
}
|
|
|
|
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
|
where: eq(vendorSnippets.snippetCode, snippet.name),
|
|
})
|
|
if (existingSnippet) {
|
|
throw new Error(`Snippet name "${snippet.name}" already exists`)
|
|
}
|
|
|
|
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
|
snippetCode: snippet.name,
|
|
slotId: id,
|
|
skuIds: snippet.skuIds,
|
|
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
|
}).returning()
|
|
|
|
createdSnippets.push(mapVendorSnippet(createdSnippet))
|
|
}
|
|
}
|
|
|
|
return {
|
|
slot: mapDeliverySlot(updatedSlot),
|
|
createdSnippets,
|
|
message: 'Slot updated successfully',
|
|
}
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
export async function deleteSlotById(id: number): Promise<AdminDeliverySlot | null> {
|
|
const [deletedSlot] = await db
|
|
.update(deliverySlotInfo)
|
|
.set({ isActive: false })
|
|
.where(eq(deliverySlotInfo.id, id))
|
|
.returning()
|
|
|
|
if (!deletedSlot) {
|
|
return null
|
|
}
|
|
|
|
return mapDeliverySlot(deletedSlot)
|
|
}
|
|
|
|
export async function getSlotDeliverySequence(slotId: number): Promise<AdminDeliverySlot | null> {
|
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
|
where: eq(deliverySlotInfo.id, slotId),
|
|
})
|
|
|
|
if (!slot) {
|
|
return null
|
|
}
|
|
|
|
return mapDeliverySlot(slot)
|
|
}
|
|
|
|
export async function updateSlotDeliverySequence(slotId: number, sequence: unknown) {
|
|
const [updatedSlot] = await db
|
|
.update(deliverySlotInfo)
|
|
.set({ deliverySequence: sequence as Record<string, number> })
|
|
.where(eq(deliverySlotInfo.id, slotId))
|
|
.returning({
|
|
id: deliverySlotInfo.id,
|
|
deliverySequence: deliverySlotInfo.deliverySequence,
|
|
})
|
|
|
|
return updatedSlot || null
|
|
}
|
|
|
|
export async function updateSlotCapacity(slotId: number, isCapacityFull: boolean): Promise<AdminUpdateSlotCapacityResult | null> {
|
|
const [updatedSlot] = await db
|
|
.update(deliverySlotInfo)
|
|
.set({ isCapacityFull })
|
|
.where(eq(deliverySlotInfo.id, slotId))
|
|
.returning()
|
|
|
|
if (!updatedSlot) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
slot: mapDeliverySlot(updatedSlot),
|
|
message: `Slot ${isCapacityFull ? 'marked as full capacity' : 'capacity reset'}`,
|
|
}
|
|
}
|