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:
ryan
2026-04-05 01:32:28 +00:00
parent e16655d133
commit 9d51fb2118
32 changed files with 1507 additions and 199 deletions

View File

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

View File

@@ -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
}
]
}

View File

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

View File

@@ -36,6 +36,7 @@ export const repairLineItemTypeEnum = pgEnum('repair_line_item_type', [
'part',
'flat_rate',
'misc',
'consumable',
])
export const repairConditionInEnum = pgEnum('repair_condition_in', [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,8 @@ export const TaxService = {
switch (itemType) {
case 'labor':
return 'service'
case 'consumable':
return 'exempt'
case 'part':
case 'flat_rate':
case 'misc':

View File

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