feat: repair-POS integration, receipt formats, manager overrides, price adjustments
- 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>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- Add 'consumable' to repair_line_item_type enum
|
||||
ALTER TYPE repair_line_item_type ADD VALUE IF NOT EXISTS 'consumable';
|
||||
|
||||
-- Add is_consumable flag to product table
|
||||
ALTER TABLE product ADD COLUMN IF NOT EXISTS is_consumable boolean NOT NULL DEFAULT false;
|
||||
@@ -302,6 +302,13 @@
|
||||
"when": 1775590000000,
|
||||
"tag": "0042_user-pin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "7",
|
||||
"when": 1775680000000,
|
||||
"tag": "0043_repair-pos-consumable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export const products = pgTable('product', {
|
||||
isSerialized: boolean('is_serialized').notNull().default(false),
|
||||
isRental: boolean('is_rental').notNull().default(false),
|
||||
isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false),
|
||||
isConsumable: boolean('is_consumable').notNull().default(false),
|
||||
taxCategory: taxCategoryEnum('tax_category').notNull().default('goods'),
|
||||
price: numeric('price', { precision: 10, scale: 2 }),
|
||||
minPrice: numeric('min_price', { precision: 10, scale: 2 }),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const repairLineItemTypeEnum = pgEnum('repair_line_item_type', [
|
||||
'part',
|
||||
'flat_rate',
|
||||
'misc',
|
||||
'consumable',
|
||||
])
|
||||
|
||||
export const repairConditionInEnum = pgEnum('repair_condition_in', [
|
||||
|
||||
@@ -41,6 +41,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
|
||||
isSerialized: q.isSerialized === 'true' ? true : q.isSerialized === 'false' ? false : undefined,
|
||||
isRental: q.isRental === 'true' ? true : q.isRental === 'false' ? false : undefined,
|
||||
isDualUseRepair: q.isDualUseRepair === 'true' ? true : q.isDualUseRepair === 'false' ? false : undefined,
|
||||
isConsumable: q.isConsumable === 'true' ? true : q.isConsumable === 'false' ? false : undefined,
|
||||
lowStock: q.lowStock === 'true',
|
||||
}
|
||||
const result = await ProductService.list(app.db, params, filters)
|
||||
|
||||
@@ -27,6 +27,13 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.status(201).send(ticket)
|
||||
})
|
||||
|
||||
app.get('/repair-tickets/ready', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
const result = await RepairTicketService.listReadyForPickup(app.db, params)
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.get('/repair-tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
|
||||
@@ -19,6 +19,14 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.status(201).send(txn)
|
||||
})
|
||||
|
||||
app.post('/transactions/from-repair/:ticketId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { ticketId } = request.params as { ticketId: string }
|
||||
const body = request.body as { locationId?: string } | undefined
|
||||
const txn = await TransactionService.createFromRepairTicket(app.db, ticketId, body?.locationId, request.user.id)
|
||||
request.log.info({ transactionId: txn?.id, ticketId, userId: request.user.id }, 'Repair payment transaction created')
|
||||
return reply.status(201).send(txn)
|
||||
})
|
||||
|
||||
app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ProductService = {
|
||||
isSerialized?: boolean
|
||||
isRental?: boolean
|
||||
isDualUseRepair?: boolean
|
||||
isConsumable?: boolean
|
||||
lowStock?: boolean
|
||||
}) {
|
||||
const conditions = [eq(products.isActive, filters?.isActive ?? true)]
|
||||
@@ -68,6 +69,9 @@ export const ProductService = {
|
||||
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(
|
||||
|
||||
@@ -174,6 +174,25 @@ export const RepairTicketService = {
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async listReadyForPickup(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
||||
const baseWhere = eq(repairTickets.status, 'ready')
|
||||
const searchCondition = params.q
|
||||
? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.customerPhone])
|
||||
: undefined
|
||||
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||
|
||||
let query = db.select().from(repairTickets).where(where).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, { ticket_number: repairTickets.ticketNumber, customer_name: repairTickets.customerName, created_at: repairTickets.createdAt }, repairTickets.createdAt)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(repairTickets).where(where),
|
||||
])
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: RepairTicketUpdateInput) {
|
||||
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||
if (input.estimatedCost !== undefined) values.estimatedCost = input.estimatedCost.toString()
|
||||
|
||||
@@ -63,6 +63,8 @@ export const TaxService = {
|
||||
switch (itemType) {
|
||||
case 'labor':
|
||||
return 'service'
|
||||
case 'consumable':
|
||||
return 'exempt'
|
||||
case 'part':
|
||||
case 'flat_rate':
|
||||
case 'misc':
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
drawerSessions,
|
||||
} from '../db/schema/pos.js'
|
||||
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
||||
import { repairTickets, repairLineItems } from '../db/schema/repairs.js'
|
||||
import { companies, locations } from '../db/schema/stores.js'
|
||||
import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js'
|
||||
import { TaxService } from './tax.service.js'
|
||||
@@ -47,6 +48,78 @@ export const TransactionService = {
|
||||
return txn
|
||||
},
|
||||
|
||||
async createFromRepairTicket(db: PostgresJsDatabase<any>, ticketId: string, locationId: string | undefined, processedBy: string) {
|
||||
// Fetch ticket
|
||||
const [ticket] = await db
|
||||
.select()
|
||||
.from(repairTickets)
|
||||
.where(eq(repairTickets.id, ticketId))
|
||||
.limit(1)
|
||||
|
||||
if (!ticket) throw new NotFoundError('Repair ticket')
|
||||
if (!['ready', 'approved', 'in_progress'].includes(ticket.status)) {
|
||||
throw new ValidationError('Ticket must be in ready, approved, or in_progress status to check out')
|
||||
}
|
||||
|
||||
// Check for existing pending repair_payment for this ticket
|
||||
const [existing] = await db
|
||||
.select({ id: transactions.id })
|
||||
.from(transactions)
|
||||
.where(and(
|
||||
eq(transactions.repairTicketId, ticketId),
|
||||
eq(transactions.status, 'pending'),
|
||||
))
|
||||
.limit(1)
|
||||
if (existing) throw new ConflictError('A pending transaction already exists for this ticket')
|
||||
|
||||
// Fetch non-consumable line items
|
||||
const items = await db
|
||||
.select()
|
||||
.from(repairLineItems)
|
||||
.where(eq(repairLineItems.repairTicketId, ticketId))
|
||||
|
||||
const billableItems = items.filter((i) => i.itemType !== 'consumable')
|
||||
if (billableItems.length === 0) throw new ValidationError('No billable line items on this ticket')
|
||||
|
||||
// Create transaction
|
||||
const txn = await this.create(db, {
|
||||
transactionType: 'repair_payment',
|
||||
locationId: locationId ?? ticket.locationId ?? undefined,
|
||||
accountId: ticket.accountId ?? undefined,
|
||||
repairTicketId: ticketId,
|
||||
}, processedBy)
|
||||
|
||||
// Add each billable line item
|
||||
for (const item of billableItems) {
|
||||
const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType)
|
||||
let taxRate = 0
|
||||
const txnLocationId = locationId ?? ticket.locationId
|
||||
if (txnLocationId) {
|
||||
taxRate = await TaxService.getRateForLocation(db, txnLocationId, taxCategory)
|
||||
}
|
||||
|
||||
const unitPrice = parseFloat(item.unitPrice)
|
||||
const qty = Math.round(parseFloat(item.qty))
|
||||
const lineSubtotal = unitPrice * qty
|
||||
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
|
||||
const lineTotal = lineSubtotal + taxAmount
|
||||
|
||||
await db.insert(transactionLineItems).values({
|
||||
transactionId: txn.id,
|
||||
productId: item.productId,
|
||||
description: item.description,
|
||||
qty,
|
||||
unitPrice: unitPrice.toString(),
|
||||
taxRate: taxRate.toString(),
|
||||
taxAmount: taxAmount.toString(),
|
||||
lineTotal: lineTotal.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
await this.recalculateTotals(db, txn.id)
|
||||
return this.getById(db, txn.id)
|
||||
},
|
||||
|
||||
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
|
||||
const txn = await this.getById(db, transactionId)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
@@ -309,6 +382,15 @@ export const TransactionService = {
|
||||
})
|
||||
.where(eq(transactions.id, transactionId))
|
||||
.returning()
|
||||
|
||||
// If this is a repair payment, update ticket status to picked_up
|
||||
if (completed.transactionType === 'repair_payment' && completed.repairTicketId) {
|
||||
await db
|
||||
.update(repairTickets)
|
||||
.set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() })
|
||||
.where(eq(repairTickets.id, completed.repairTicketId))
|
||||
}
|
||||
|
||||
return completed
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user