- Add thermal/full-page receipt format toggle (per-device, localStorage) - Full-page receipt uses clean invoice layout matching repair PDF style - Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced) - Manager override system: configurable PIN prompt for void, refund, discount, cash in/out - Discount threshold setting: require manager approval above X% - Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals - Repair line item dialog: product picker dropdown for parts/consumables from inventory - Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods) - Transaction completion auto-updates repair ticket status to picked_up - POS Repairs dialog with Pickup and New Intake tabs, customer account lookup - Inline price adjustment on cart items: % off, $ off, or set price with live preview - Order-level discount button with same three input modes - Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint - Fix: backend dev script uses --env-file for turbo compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { eq, and, count, desc, sql, type Column } from 'drizzle-orm'
|
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|
import { products, inventoryUnits, priceHistory as priceHistoryTable, productSuppliers, suppliers, stockReceipts } from '../db/schema/inventory.js'
|
|
import { ValidationError } from '../lib/errors.js'
|
|
import type {
|
|
ProductCreateInput,
|
|
ProductUpdateInput,
|
|
InventoryUnitCreateInput,
|
|
InventoryUnitUpdateInput,
|
|
ProductSupplierCreateInput,
|
|
ProductSupplierUpdateInput,
|
|
StockReceiptCreateInput,
|
|
PaginationInput,
|
|
} from '@lunarfront/shared/schemas'
|
|
import {
|
|
withPagination,
|
|
withSort,
|
|
buildSearchCondition,
|
|
paginatedResponse,
|
|
} from '../utils/pagination.js'
|
|
import { UnitStatusService, ItemConditionService } from './lookup.service.js'
|
|
|
|
export const ProductService = {
|
|
async create(db: PostgresJsDatabase<any>, input: ProductCreateInput) {
|
|
const [product] = await db
|
|
.insert(products)
|
|
.values({
|
|
...input,
|
|
price: input.price?.toString(),
|
|
minPrice: input.minPrice?.toString(),
|
|
rentalRateMonthly: input.rentalRateMonthly?.toString(),
|
|
})
|
|
.returning()
|
|
return product
|
|
},
|
|
|
|
async getById(db: PostgresJsDatabase<any>, id: string) {
|
|
const [product] = await db
|
|
.select()
|
|
.from(products)
|
|
.where(eq(products.id, id))
|
|
.limit(1)
|
|
return product ?? null
|
|
},
|
|
|
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
|
|
categoryId?: string
|
|
isActive?: boolean
|
|
isSerialized?: boolean
|
|
isRental?: boolean
|
|
isDualUseRepair?: boolean
|
|
isConsumable?: boolean
|
|
lowStock?: boolean
|
|
}) {
|
|
const conditions = [eq(products.isActive, filters?.isActive ?? true)]
|
|
|
|
if (params.q) {
|
|
conditions.push(buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])!)
|
|
}
|
|
if (filters?.categoryId) {
|
|
conditions.push(eq(products.categoryId, filters.categoryId))
|
|
}
|
|
if (filters?.isSerialized !== undefined) {
|
|
conditions.push(eq(products.isSerialized, filters.isSerialized))
|
|
}
|
|
if (filters?.isRental !== undefined) {
|
|
conditions.push(eq(products.isRental, filters.isRental))
|
|
}
|
|
if (filters?.isDualUseRepair !== undefined) {
|
|
conditions.push(eq(products.isDualUseRepair, filters.isDualUseRepair))
|
|
}
|
|
if (filters?.isConsumable !== undefined) {
|
|
conditions.push(eq(products.isConsumable, filters.isConsumable))
|
|
}
|
|
if (filters?.lowStock) {
|
|
// qty_on_hand <= qty_reorder_point (and reorder point is set) OR qty_on_hand = 0
|
|
conditions.push(
|
|
sql`(${products.qtyOnHand} = 0 OR (${products.qtyReorderPoint} IS NOT NULL AND ${products.qtyOnHand} <= ${products.qtyReorderPoint}))`
|
|
)
|
|
}
|
|
|
|
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
|
|
|
|
const sortableColumns: Record<string, Column> = {
|
|
name: products.name,
|
|
sku: products.sku,
|
|
brand: products.brand,
|
|
price: products.price,
|
|
created_at: products.createdAt,
|
|
}
|
|
|
|
let query = db.select().from(products).where(where).$dynamic()
|
|
query = withSort(query, params.sort, params.order, sortableColumns, products.name)
|
|
query = withPagination(query, params.page, params.limit)
|
|
|
|
const [data, [{ total }]] = await Promise.all([
|
|
query,
|
|
db.select({ total: count() }).from(products).where(where),
|
|
])
|
|
|
|
return paginatedResponse(data, total, params.page, params.limit)
|
|
},
|
|
|
|
async update(
|
|
db: PostgresJsDatabase<any>,
|
|
id: string,
|
|
input: ProductUpdateInput,
|
|
changedBy?: string,
|
|
) {
|
|
if (input.price !== undefined || input.minPrice !== undefined) {
|
|
const existing = await this.getById(db, id)
|
|
if (existing) {
|
|
await db.insert(priceHistoryTable).values({
|
|
productId: id,
|
|
previousPrice: existing.price,
|
|
newPrice: input.price?.toString() ?? existing.price ?? '0',
|
|
previousMinPrice: existing.minPrice,
|
|
newMinPrice: input.minPrice?.toString() ?? existing.minPrice,
|
|
changedBy,
|
|
})
|
|
}
|
|
}
|
|
|
|
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
|
if (input.price !== undefined) updates.price = input.price.toString()
|
|
if (input.minPrice !== undefined) updates.minPrice = input.minPrice.toString()
|
|
if (input.rentalRateMonthly !== undefined)
|
|
updates.rentalRateMonthly = input.rentalRateMonthly.toString()
|
|
|
|
const [product] = await db
|
|
.update(products)
|
|
.set(updates)
|
|
.where(eq(products.id, id))
|
|
.returning()
|
|
return product ?? null
|
|
},
|
|
|
|
async softDelete(db: PostgresJsDatabase<any>, id: string) {
|
|
const [product] = await db
|
|
.update(products)
|
|
.set({ isActive: false, updatedAt: new Date() })
|
|
.where(eq(products.id, id))
|
|
.returning()
|
|
return product ?? null
|
|
},
|
|
|
|
async getByUpc(db: PostgresJsDatabase<any>, upc: string) {
|
|
const [product] = await db
|
|
.select()
|
|
.from(products)
|
|
.where(and(eq(products.upc, upc), eq(products.isActive, true)))
|
|
.limit(1)
|
|
return product ?? null
|
|
},
|
|
|
|
async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) {
|
|
return db
|
|
.select()
|
|
.from(priceHistoryTable)
|
|
.where(eq(priceHistoryTable.productId, productId))
|
|
.orderBy(desc(priceHistoryTable.createdAt))
|
|
},
|
|
}
|
|
|
|
export const InventoryUnitService = {
|
|
async create(db: PostgresJsDatabase<any>, input: InventoryUnitCreateInput) {
|
|
if (input.condition) {
|
|
const valid = await ItemConditionService.validateSlug(db, input.condition)
|
|
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
|
|
}
|
|
if (input.status) {
|
|
const valid = await UnitStatusService.validateSlug(db, input.status)
|
|
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
|
|
}
|
|
|
|
const [unit] = await db
|
|
.insert(inventoryUnits)
|
|
.values({
|
|
productId: input.productId,
|
|
locationId: input.locationId,
|
|
serialNumber: input.serialNumber,
|
|
condition: input.condition,
|
|
status: input.status,
|
|
purchaseDate: input.purchaseDate,
|
|
purchaseCost: input.purchaseCost?.toString(),
|
|
notes: input.notes,
|
|
})
|
|
.returning()
|
|
return unit
|
|
},
|
|
|
|
async getById(db: PostgresJsDatabase<any>, id: string) {
|
|
const [unit] = await db
|
|
.select()
|
|
.from(inventoryUnits)
|
|
.where(eq(inventoryUnits.id, id))
|
|
.limit(1)
|
|
return unit ?? null
|
|
},
|
|
|
|
async listByProduct(
|
|
db: PostgresJsDatabase<any>,
|
|
productId: string,
|
|
params: PaginationInput,
|
|
) {
|
|
const where = eq(inventoryUnits.productId, productId)
|
|
|
|
const sortableColumns: Record<string, Column> = {
|
|
serial_number: inventoryUnits.serialNumber,
|
|
status: inventoryUnits.status,
|
|
condition: inventoryUnits.condition,
|
|
created_at: inventoryUnits.createdAt,
|
|
}
|
|
|
|
let query = db.select().from(inventoryUnits).where(where).$dynamic()
|
|
query = withSort(query, params.sort, params.order, sortableColumns, inventoryUnits.createdAt)
|
|
query = withPagination(query, params.page, params.limit)
|
|
|
|
const [data, [{ total }]] = await Promise.all([
|
|
query,
|
|
db.select({ total: count() }).from(inventoryUnits).where(where),
|
|
])
|
|
|
|
return paginatedResponse(data, total, params.page, params.limit)
|
|
},
|
|
|
|
async update(
|
|
db: PostgresJsDatabase<any>,
|
|
id: string,
|
|
input: InventoryUnitUpdateInput,
|
|
) {
|
|
if (input.condition) {
|
|
const valid = await ItemConditionService.validateSlug(db, input.condition)
|
|
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
|
|
}
|
|
if (input.status) {
|
|
const valid = await UnitStatusService.validateSlug(db, input.status)
|
|
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
|
|
}
|
|
|
|
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
|
if (input.purchaseCost !== undefined) updates.purchaseCost = input.purchaseCost.toString()
|
|
|
|
const [unit] = await db
|
|
.update(inventoryUnits)
|
|
.set(updates)
|
|
.where(eq(inventoryUnits.id, id))
|
|
.returning()
|
|
return unit ?? null
|
|
},
|
|
}
|
|
|
|
export const ProductSupplierService = {
|
|
async listByProduct(db: PostgresJsDatabase<any>, productId: string) {
|
|
return db
|
|
.select({
|
|
id: productSuppliers.id,
|
|
productId: productSuppliers.productId,
|
|
supplierId: productSuppliers.supplierId,
|
|
supplierSku: productSuppliers.supplierSku,
|
|
isPreferred: productSuppliers.isPreferred,
|
|
createdAt: productSuppliers.createdAt,
|
|
supplierName: suppliers.name,
|
|
supplierContactName: suppliers.contactName,
|
|
supplierEmail: suppliers.email,
|
|
supplierPhone: suppliers.phone,
|
|
supplierAccountNumber: suppliers.accountNumber,
|
|
supplierPaymentTerms: suppliers.paymentTerms,
|
|
})
|
|
.from(productSuppliers)
|
|
.innerJoin(suppliers, eq(productSuppliers.supplierId, suppliers.id))
|
|
.where(eq(productSuppliers.productId, productId))
|
|
.orderBy(desc(productSuppliers.isPreferred), suppliers.name)
|
|
},
|
|
|
|
async create(db: PostgresJsDatabase<any>, productId: string, input: ProductSupplierCreateInput) {
|
|
if (input.isPreferred) {
|
|
await db
|
|
.update(productSuppliers)
|
|
.set({ isPreferred: false })
|
|
.where(eq(productSuppliers.productId, productId))
|
|
}
|
|
const [row] = await db
|
|
.insert(productSuppliers)
|
|
.values({ productId, ...input })
|
|
.returning()
|
|
return row
|
|
},
|
|
|
|
async update(db: PostgresJsDatabase<any>, id: string, productId: string, input: ProductSupplierUpdateInput) {
|
|
if (input.isPreferred) {
|
|
await db
|
|
.update(productSuppliers)
|
|
.set({ isPreferred: false })
|
|
.where(eq(productSuppliers.productId, productId))
|
|
}
|
|
const [row] = await db
|
|
.update(productSuppliers)
|
|
.set(input)
|
|
.where(eq(productSuppliers.id, id))
|
|
.returning()
|
|
return row ?? null
|
|
},
|
|
|
|
async delete(db: PostgresJsDatabase<any>, id: string) {
|
|
const [row] = await db
|
|
.delete(productSuppliers)
|
|
.where(eq(productSuppliers.id, id))
|
|
.returning()
|
|
return row ?? null
|
|
},
|
|
}
|
|
|
|
export const StockReceiptService = {
|
|
async create(db: PostgresJsDatabase<any>, productId: string, input: StockReceiptCreateInput) {
|
|
const totalCost = (input.costPerUnit * input.qty).toFixed(2)
|
|
|
|
const [receipt] = await db
|
|
.insert(stockReceipts)
|
|
.values({
|
|
productId,
|
|
supplierId: input.supplierId,
|
|
inventoryUnitId: input.inventoryUnitId,
|
|
qty: input.qty,
|
|
costPerUnit: input.costPerUnit.toString(),
|
|
totalCost,
|
|
receivedDate: input.receivedDate,
|
|
invoiceNumber: input.invoiceNumber,
|
|
notes: input.notes,
|
|
})
|
|
.returning()
|
|
|
|
// For non-serialized products, increment qty_on_hand atomically
|
|
await db
|
|
.update(products)
|
|
.set({ qtyOnHand: sql`${products.qtyOnHand} + ${input.qty}`, updatedAt: new Date() })
|
|
.where(and(eq(products.id, productId), eq(products.isSerialized, false)))
|
|
|
|
return receipt
|
|
},
|
|
|
|
async listByProduct(db: PostgresJsDatabase<any>, productId: string) {
|
|
return db
|
|
.select({
|
|
id: stockReceipts.id,
|
|
productId: stockReceipts.productId,
|
|
supplierId: stockReceipts.supplierId,
|
|
inventoryUnitId: stockReceipts.inventoryUnitId,
|
|
qty: stockReceipts.qty,
|
|
costPerUnit: stockReceipts.costPerUnit,
|
|
totalCost: stockReceipts.totalCost,
|
|
receivedDate: stockReceipts.receivedDate,
|
|
invoiceNumber: stockReceipts.invoiceNumber,
|
|
notes: stockReceipts.notes,
|
|
createdAt: stockReceipts.createdAt,
|
|
supplierName: suppliers.name,
|
|
})
|
|
.from(stockReceipts)
|
|
.leftJoin(suppliers, eq(stockReceipts.supplierId, suppliers.id))
|
|
.where(eq(stockReceipts.productId, productId))
|
|
.orderBy(desc(stockReceipts.receivedDate), desc(stockReceipts.createdAt))
|
|
},
|
|
}
|