fix: code review fixes + unit/API tests for repair-POS integration
Code review fixes: - Wrap createFromRepairTicket() in DB transaction for atomicity - Wrap complete() inventory + status updates in DB transaction - Repair ticket status update now atomic with transaction completion - Add Zod validation on from-repair route body - Fix requiresDiscountOverride: threshold and manual_discount are independent checks - Order discount distributes proportionally across line items (not first-only) - Extract shared receipt calculations into useReceiptData/useBarcode hooks - Add error handling for barcode generation Tests: - Unit: consumable tax category mapping, exempt rate short-circuit - API: ready-for-pickup listing + search, from-repair transaction creation, consumable exclusion from line items, tax rate verification (labor=service, part=goods), duplicate prevention, ticket auto-pickup on payment completion, isConsumable product filter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [priceItemId, setPriceItemId] = useState<string | null>(null)
|
||||
const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(null)
|
||||
const [pendingOrderDiscount, setPendingOrderDiscount] = useState<{ amount: number; reason: string } | null>(null)
|
||||
const [discountOverrideOpen, setDiscountOverrideOpen] = useState(false)
|
||||
const lineItems = transaction?.lineItems ?? []
|
||||
|
||||
@@ -52,6 +53,28 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const orderDiscountMutation = useMutation({
|
||||
mutationFn: async ({ amount, reason }: { amount: number; reason: string }) => {
|
||||
// Distribute discount proportionally across all line items
|
||||
let remaining = amount
|
||||
for (let i = 0; i < lineItems.length; i++) {
|
||||
const item = lineItems[i]
|
||||
const itemTotal = parseFloat(item.unitPrice) * item.qty
|
||||
const isLast = i === lineItems.length - 1
|
||||
const share = isLast ? remaining : Math.round((itemTotal / subtotal) * amount * 100) / 100
|
||||
remaining -= share
|
||||
if (share > 0) {
|
||||
await posMutations.applyDiscount(currentTransactionId!, { lineItemId: item.id, amount: share, reason })
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
||||
toast.success('Order discount applied')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const voidMutation = useMutation({
|
||||
mutationFn: () => posMutations.void(currentTransactionId!),
|
||||
onSuccess: () => {
|
||||
@@ -204,13 +227,13 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
||||
onApply={(amount, reason) => {
|
||||
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
|
||||
if (requiresDiscountOverride(pct)) {
|
||||
setPendingDiscount({ lineItemId: lineItems[0].id, amount, reason })
|
||||
setPendingOrderDiscount({ amount, reason })
|
||||
setDiscountOverrideOpen(true)
|
||||
} else {
|
||||
discountMutation.mutate({ lineItemId: lineItems[0].id, amount, reason })
|
||||
orderDiscountMutation.mutate({ amount, reason })
|
||||
}
|
||||
}}
|
||||
isPending={discountMutation.isPending}
|
||||
isPending={orderDiscountMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -296,6 +319,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
||||
if (pendingDiscount) {
|
||||
discountMutation.mutate(pendingDiscount)
|
||||
setPendingDiscount(null)
|
||||
} else if (pendingOrderDiscount) {
|
||||
orderDiscountMutation.mutate(pendingOrderDiscount)
|
||||
setPendingOrderDiscount(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -193,7 +193,9 @@ export function setDiscountThreshold(pct: number) {
|
||||
}
|
||||
|
||||
export function requiresDiscountOverride(discountPct: number): boolean {
|
||||
// Check percentage threshold first
|
||||
const threshold = getDiscountThreshold()
|
||||
if (threshold <= 0) return requiresOverride('manual_discount')
|
||||
return discountPct >= threshold
|
||||
if (threshold > 0 && discountPct >= threshold) return true
|
||||
// Fall back to the blanket manual_discount toggle
|
||||
return requiresOverride('manual_discount')
|
||||
}
|
||||
|
||||
@@ -110,30 +110,41 @@ export function POSReceipt({ data, size = 'thermal', footerText, config }: POSRe
|
||||
return <ThermalReceipt data={data} config={config} footerText={footerText} />
|
||||
}
|
||||
|
||||
function useReceiptData(data: POSReceiptProps['data']) {
|
||||
const { transaction: txn, company, location } = data
|
||||
return {
|
||||
txn,
|
||||
company,
|
||||
location,
|
||||
date: new Date(txn.completedAt ?? txn.createdAt),
|
||||
subtotal: parseFloat(txn.subtotal),
|
||||
discountTotal: parseFloat(txn.discountTotal),
|
||||
taxTotal: parseFloat(txn.taxTotal),
|
||||
total: parseFloat(txn.total),
|
||||
rounding: parseFloat(txn.roundingAdjustment),
|
||||
tendered: txn.amountTendered ? parseFloat(txn.amountTendered) : null,
|
||||
change: txn.changeGiven ? parseFloat(txn.changeGiven) : null,
|
||||
addr: location.address ?? company.address,
|
||||
phone: location.phone ?? company.phone,
|
||||
email: location.email ?? company.email,
|
||||
}
|
||||
}
|
||||
|
||||
function useBarcode(ref: React.RefObject<SVGSVGElement | null>, value: string, opts: { width: number; height: number; fontSize: number }) {
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
try {
|
||||
JsBarcode(ref.current, value, { format: 'CODE128', displayValue: true, margin: 4, ...opts })
|
||||
} catch { /* barcode generation failed — show text fallback */ }
|
||||
}
|
||||
}, [value])
|
||||
}
|
||||
|
||||
function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
|
||||
const barcodeRef = useRef<SVGSVGElement>(null)
|
||||
const { transaction: txn, company, location } = data
|
||||
const logoSrc = useStoreLogo()
|
||||
|
||||
useEffect(() => {
|
||||
if (barcodeRef.current) {
|
||||
JsBarcode(barcodeRef.current, txn.transactionNumber, {
|
||||
format: 'CODE128', width: 1.5, height: 40, displayValue: true, fontSize: 10, margin: 4,
|
||||
})
|
||||
}
|
||||
}, [txn.transactionNumber])
|
||||
|
||||
const date = new Date(txn.completedAt ?? txn.createdAt)
|
||||
const subtotal = parseFloat(txn.subtotal)
|
||||
const discountTotal = parseFloat(txn.discountTotal)
|
||||
const taxTotal = parseFloat(txn.taxTotal)
|
||||
const total = parseFloat(txn.total)
|
||||
const rounding = parseFloat(txn.roundingAdjustment)
|
||||
const tendered = txn.amountTendered ? parseFloat(txn.amountTendered) : null
|
||||
const change = txn.changeGiven ? parseFloat(txn.changeGiven) : null
|
||||
|
||||
const addr = location.address ?? company.address
|
||||
const phone = location.phone ?? company.phone
|
||||
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone } = useReceiptData(data)
|
||||
useBarcode(barcodeRef, txn.transactionNumber, { width: 1.5, height: 40, fontSize: 10 })
|
||||
|
||||
const s = {
|
||||
row: { display: 'flex', justifyContent: 'space-between' } as const,
|
||||
@@ -245,29 +256,9 @@ function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['d
|
||||
|
||||
function FullPageReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
|
||||
const barcodeRef = useRef<SVGSVGElement>(null)
|
||||
const { transaction: txn, company, location } = data
|
||||
const logoSrc = useStoreLogo()
|
||||
|
||||
useEffect(() => {
|
||||
if (barcodeRef.current) {
|
||||
JsBarcode(barcodeRef.current, txn.transactionNumber, {
|
||||
format: 'CODE128', width: 2, height: 50, displayValue: true, fontSize: 12, margin: 4,
|
||||
})
|
||||
}
|
||||
}, [txn.transactionNumber])
|
||||
|
||||
const date = new Date(txn.completedAt ?? txn.createdAt)
|
||||
const subtotal = parseFloat(txn.subtotal)
|
||||
const discountTotal = parseFloat(txn.discountTotal)
|
||||
const taxTotal = parseFloat(txn.taxTotal)
|
||||
const total = parseFloat(txn.total)
|
||||
const rounding = parseFloat(txn.roundingAdjustment)
|
||||
const tendered = txn.amountTendered ? parseFloat(txn.amountTendered) : null
|
||||
const change = txn.changeGiven ? parseFloat(txn.changeGiven) : null
|
||||
|
||||
const addr = location.address ?? company.address
|
||||
const phone = location.phone ?? company.phone
|
||||
const email = location.email ?? company.email
|
||||
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone, email } = useReceiptData(data)
|
||||
useBarcode(barcodeRef, txn.transactionNumber, { width: 2, height: 50, fontSize: 12 })
|
||||
|
||||
const f = (n: number) => `$${n.toFixed(2)}`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user