diff --git a/src/modules/purchases/__tests__/purchases-flow.integration.test.ts b/src/modules/purchases/__tests__/purchases-flow.integration.test.ts new file mode 100644 index 0000000..526dadd --- /dev/null +++ b/src/modules/purchases/__tests__/purchases-flow.integration.test.ts @@ -0,0 +1,746 @@ +/** + * Integration tests for the complete purchase flow + * + * Tests the following flows: + * 1. RFQ -> Purchase Order conversion + * 2. Purchase Order -> Partial Receipt -> Complete Receipt + * 3. Receipt -> Supplier Invoice + * 4. Supplier Returns + * 5. Cost control and variance tracking + */ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + createMockRepository, + createMockQueryBuilder, + createMockPurchaseOrder, + createMockPurchaseOrderLine, + createMockRfq, + createMockRfqLine, +} from '../../../__tests__/helpers.js'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); +const mockGetClient = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), + getClient: () => mockGetClient(), +})); + +// Mock sequences service +const mockGetNextNumber = jest.fn(); +jest.mock('../../core/sequences.service.js', () => ({ + sequencesService: { + getNextNumber: (...args: any[]) => mockGetNextNumber(...args), + }, + SEQUENCE_CODES: { + PURCHASE_ORDER: 'PO', + PICKING_IN: 'WH/IN', + INVOICE_SUPPLIER: 'BILL', + }, +})); + +// Import services after mocking +import { purchasesService } from '../purchases.service.js'; +import { rfqsService } from '../rfqs.service.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js'; + +describe('Purchases Flow Integration Tests', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + const companyId = 'company-uuid'; + const supplierId = 'supplier-uuid'; + const currencyId = 'currency-uuid'; + const productId = 'product-uuid-1'; + const uomId = 'uom-uuid-1'; + + const mockClient = { + query: jest.fn(), + release: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetClient.mockResolvedValue(mockClient); + mockGetNextNumber.mockImplementation((code: string) => { + if (code === 'PO') return Promise.resolve('PO-00001'); + if (code === 'WH/IN') return Promise.resolve('WH/IN/00001'); + if (code === 'BILL') return Promise.resolve('BILL-00001'); + return Promise.resolve(`${code}-00001`); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ==================== Flow 1: RFQ -> Purchase Order ==================== + describe('Flow 1: RFQ to Purchase Order Conversion', () => { + describe('create RFQ and convert to PO', () => { + it('should create an RFQ with lines', async () => { + const rfqData = { + company_id: companyId, + partner_ids: [supplierId], + request_date: '2024-06-15', + lines: [ + { + product_id: productId, + description: 'Product A', + quantity: 100, + uom_id: uomId, + }, + { + product_id: 'product-uuid-2', + description: 'Product B', + quantity: 50, + uom_id: uomId, + }, + ], + }; + + const mockRfq = createMockRfq({ + id: 'rfq-001', + name: 'RFQ-000001', + status: 'draft', + ...rfqData, + }); + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence + .mockResolvedValueOnce({ rows: [mockRfq] }) // INSERT RFQ + .mockResolvedValueOnce(undefined) // INSERT line 1 + .mockResolvedValueOnce(undefined) // INSERT line 2 + .mockResolvedValueOnce(undefined); // COMMIT + + mockQueryOne.mockResolvedValue(mockRfq); + mockQuery.mockResolvedValue([createMockRfqLine(), createMockRfqLine({ id: 'line-2' })]); + + const result = await rfqsService.create(rfqData, tenantId, userId); + + expect(result).toBeDefined(); + expect(result.name).toBe('RFQ-000001'); + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + }); + + it('should send RFQ to suppliers', async () => { + const draftRfq = createMockRfq({ + id: 'rfq-001', + status: 'draft', + lines: [createMockRfqLine()], + }); + + mockQueryOne.mockResolvedValue(draftRfq); + mockQuery.mockResolvedValue([createMockRfqLine()]); + + await rfqsService.send('rfq-001', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'sent'"), + expect.any(Array) + ); + }); + + it('should accept RFQ and prepare for PO creation', async () => { + const sentRfq = createMockRfq({ + id: 'rfq-001', + status: 'sent', + lines: [createMockRfqLine()], + }); + + mockQueryOne.mockResolvedValue(sentRfq); + mockQuery.mockResolvedValue([]); + + const result = await rfqsService.accept('rfq-001', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'accepted'"), + expect.any(Array) + ); + }); + + it('should create purchase order from accepted RFQ data', async () => { + // Simulating PO creation with data from an accepted RFQ + const poData = { + company_id: companyId, + partner_id: supplierId, + order_date: '2024-06-15', + currency_id: currencyId, + lines: [ + { + product_id: productId, + description: 'Product A', + quantity: 100, + uom_id: uomId, + price_unit: 50, + amount_untaxed: 5000, + }, + ], + }; + + const mockOrder = createMockPurchaseOrder({ + id: 'po-001', + name: 'PO-00001', + status: 'draft', + }); + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ rows: [mockOrder] }) // INSERT order + .mockResolvedValueOnce(undefined) // INSERT line + .mockResolvedValueOnce(undefined); // COMMIT + + mockQueryOne.mockResolvedValue(mockOrder); + mockQuery.mockResolvedValue([createMockPurchaseOrderLine()]); + + const result = await purchasesService.create(poData, tenantId, userId); + + expect(result).toBeDefined(); + expect(result.name).toBe(mockOrder.name); + }); + }); + }); + + // ==================== Flow 2: PO -> Partial Receipt -> Complete Receipt ==================== + describe('Flow 2: Purchase Order to Receipt', () => { + describe('confirm order and create receipt', () => { + it('should confirm purchase order and create incoming picking', async () => { + const draftOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'draft', + company_id: companyId, + partner_id: supplierId, + amount_total: 5000, + }); + const mockLines = [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + price_unit: 50, + }), + ]; + + mockQueryOne.mockResolvedValue(draftOrder); + mockQuery.mockResolvedValue(mockLines); + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'supplier-loc-uuid' }] }) // supplier location + .mockResolvedValueOnce({ rows: [{ location_id: 'internal-loc-uuid', warehouse_id: 'wh-uuid' }] }) // internal location + .mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking + .mockResolvedValueOnce(undefined) // INSERT stock_move + .mockResolvedValueOnce(undefined) // UPDATE order status + .mockResolvedValueOnce(undefined); // COMMIT + + await purchasesService.confirm('po-001', tenantId, userId); + + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + expect(mockGetNextNumber).toHaveBeenCalledWith('WH/IN', tenantId); + }); + + it('should handle partial receipt of ordered quantities', async () => { + // Scenario: Order 100 units, receive 60 units first + const confirmedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'confirmed', + company_id: companyId, + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 0, + price_unit: 50, + }), + ], + }); + + mockQueryOne.mockResolvedValue(confirmedOrder); + mockQuery.mockResolvedValue(confirmedOrder.lines); + + // Verify order is in confirmed status, ready for receipt + const order = await purchasesService.findById('po-001', tenantId); + + expect(order.status).toBe('confirmed'); + expect(order.lines![0].quantity).toBe(100); + expect(order.lines![0].qty_received).toBe(0); + }); + + it('should update order status to partial after partial receipt', async () => { + // After receiving 60 of 100 units + const partialReceivedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'partial', + receipt_status: 'partial', + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 60, + price_unit: 50, + status: 'partial', + }), + ], + }); + + mockQueryOne.mockResolvedValue(partialReceivedOrder); + mockQuery.mockResolvedValue(partialReceivedOrder.lines); + + const order = await purchasesService.findById('po-001', tenantId); + + expect(order.status).toBe('partial'); + expect(order.receipt_status).toBe('partial'); + expect(order.lines![0].qty_received).toBe(60); + }); + + it('should complete receipt when all quantities received', async () => { + // After receiving remaining 40 units (total 100) + const fullyReceivedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'received', + receipt_status: 'received', + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 100, + price_unit: 50, + status: 'received', + }), + ], + }); + + mockQueryOne.mockResolvedValue(fullyReceivedOrder); + mockQuery.mockResolvedValue(fullyReceivedOrder.lines); + + const order = await purchasesService.findById('po-001', tenantId); + + expect(order.receipt_status).toBe('received'); + expect(order.lines![0].qty_received).toBe(order.lines![0].quantity); + }); + }); + }); + + // ==================== Flow 3: Receipt -> Supplier Invoice ==================== + describe('Flow 3: Receipt to Supplier Invoice', () => { + describe('create supplier invoice from received order', () => { + it('should validate order state allows invoicing', () => { + // Valid states for invoicing + const validStates = ['confirmed', 'done']; + const invalidStates = ['draft', 'cancelled']; + + expect(validStates.includes('confirmed')).toBe(true); + expect(validStates.includes('done')).toBe(true); + expect(validStates.includes('draft')).toBe(false); + expect(invalidStates.includes('draft')).toBe(true); + }); + + it('should calculate quantities to invoice based on received minus invoiced', () => { + const line = { + quantity: 100, + qty_received: 100, + qty_invoiced: 0, + price_unit: 50, + }; + + const qtyToInvoice = line.qty_received - line.qty_invoiced; + const invoiceAmount = qtyToInvoice * line.price_unit; + + expect(qtyToInvoice).toBe(100); + expect(invoiceAmount).toBe(5000); + }); + + it('should throw error when trying to invoice draft order', async () => { + const draftOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'draft', + }); + + mockQueryOne.mockResolvedValue(draftOrder); + mockQuery.mockResolvedValue([]); + + await expect( + purchasesService.createSupplierInvoice('po-001', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw error when order already fully invoiced', async () => { + const fullyInvoicedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'done', + invoice_status: 'invoiced', + }); + + mockQueryOne.mockResolvedValue(fullyInvoicedOrder); + mockQuery.mockResolvedValue([]); + + await expect( + purchasesService.createSupplierInvoice('po-001', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw error when no received quantities to invoice', async () => { + const confirmedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'confirmed', + invoice_status: 'pending', + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 0, + qty_invoiced: 0, + }), + ], + }); + + mockQueryOne.mockResolvedValue(confirmedOrder); + mockQuery.mockResolvedValue(confirmedOrder.lines); + + await expect( + purchasesService.createSupplierInvoice('po-001', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should calculate partial invoice amount correctly', () => { + // Received 100, already invoiced 50, should invoice remaining 50 + const line = { + quantity: 100, + qty_received: 100, + qty_invoiced: 50, + price_unit: 50, + }; + + const qtyToInvoice = line.qty_received - line.qty_invoiced; + const invoiceAmount = qtyToInvoice * line.price_unit; + + expect(qtyToInvoice).toBe(50); + expect(invoiceAmount).toBe(2500); + }); + + it('should determine invoice status based on invoiced vs received', () => { + const determineInvoiceStatus = (lines: Array<{ qty_invoiced: number; quantity: number }>) => { + const totalQty = lines.reduce((sum, l) => sum + l.quantity, 0); + const totalInvoiced = lines.reduce((sum, l) => sum + l.qty_invoiced, 0); + + if (totalInvoiced >= totalQty) return 'invoiced'; + if (totalInvoiced > 0) return 'partial'; + return 'pending'; + }; + + expect(determineInvoiceStatus([{ quantity: 100, qty_invoiced: 100 }])).toBe('invoiced'); + expect(determineInvoiceStatus([{ quantity: 100, qty_invoiced: 50 }])).toBe('partial'); + expect(determineInvoiceStatus([{ quantity: 100, qty_invoiced: 0 }])).toBe('pending'); + }); + }); + }); + + // ==================== Flow 4: Supplier Returns ==================== + describe('Flow 4: Supplier Returns', () => { + describe('handling supplier returns', () => { + it('should track returned quantities in order lines', async () => { + // Order received 100 units, returning 10 + const orderWithReturns = createMockPurchaseOrder({ + id: 'po-001', + status: 'received', + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 100, + qty_invoiced: 100, + }), + ], + }); + + mockQueryOne.mockResolvedValue(orderWithReturns); + mockQuery.mockResolvedValue(orderWithReturns.lines); + + const order = await purchasesService.findById('po-001', tenantId); + + expect(order.status).toBe('received'); + expect(order.lines![0].qty_received).toBe(100); + }); + + it('should not allow returns exceeding received quantities', async () => { + // This is a validation scenario - return qty should not exceed received qty + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 50, + }); + + // Attempting to return more than received + const returnQty = 60; + const maxReturnQty = orderLine.qty_received; + + expect(returnQty).toBeGreaterThan(maxReturnQty); + // Business rule: cannot return more than received + }); + }); + }); + + // ==================== Flow 5: Cost Control and Variances ==================== + describe('Flow 5: Cost Control and Variance Tracking', () => { + describe('price and quantity variance detection', () => { + it('should detect price variance between PO and invoice', async () => { + const orderedPrice = 50.00; + const invoicedPrice = 55.00; + const variancePercent = ((invoicedPrice - orderedPrice) / orderedPrice) * 100; + + expect(variancePercent).toBe(10); + expect(variancePercent).toBeGreaterThan(2); // Exceeds typical 2% tolerance + }); + + it('should detect quantity variance (over-receipt)', async () => { + const orderedQty = 100; + const receivedQty = 105; + const variancePercent = ((receivedQty - orderedQty) / orderedQty) * 100; + + expect(variancePercent).toBe(5); + expect(variancePercent).toBeGreaterThan(0.5); // Exceeds typical 0.5% tolerance + }); + + it('should detect quantity variance (short receipt)', async () => { + const orderedQty = 100; + const receivedQty = 95; + const variancePercent = ((orderedQty - receivedQty) / orderedQty) * 100; + + expect(variancePercent).toBe(5); + expect(variancePercent).toBeGreaterThan(0.5); // Exceeds tolerance + }); + + it('should calculate total order variance', async () => { + const orderLine = { + quantity: 100, + price_unit: 50, + amount_untaxed: 5000, + }; + + const receivedLine = { + quantity: 95, + price_invoiced: 52, + amount_invoiced: 95 * 52, // 4940 + }; + + const expectedTotal = orderLine.amount_untaxed; + const actualTotal = receivedLine.amount_invoiced; + const variance = expectedTotal - actualTotal; + + expect(variance).toBe(60); // $5000 - $4940 = $60 + }); + + it('should apply tolerance rules for matching', async () => { + const QUANTITY_TOLERANCE = 0.5; // 0.5% + const PRICE_TOLERANCE = 2.0; // 2% + + // Within tolerance + const qtyVariance1 = 0.4; // 0.4% - within tolerance + expect(qtyVariance1).toBeLessThanOrEqual(QUANTITY_TOLERANCE); + + // Outside tolerance + const qtyVariance2 = 1.0; // 1% - outside tolerance + expect(qtyVariance2).toBeGreaterThan(QUANTITY_TOLERANCE); + + // Price within tolerance + const priceVariance1 = 1.5; // 1.5% - within tolerance + expect(priceVariance1).toBeLessThanOrEqual(PRICE_TOLERANCE); + + // Price outside tolerance + const priceVariance2 = 3.0; // 3% - outside tolerance + expect(priceVariance2).toBeGreaterThan(PRICE_TOLERANCE); + }); + }); + + describe('three-way matching validation', () => { + it('should validate PO -> Receipt -> Invoice matching', async () => { + // PO values + const poQty = 100; + const poPrice = 50.00; + const poTotal = poQty * poPrice; + + // Receipt values + const receiptQty = 100; + + // Invoice values + const invoiceQty = 100; + const invoicePrice = 50.00; + const invoiceTotal = invoiceQty * invoicePrice; + + // Matching validation + const qtyMatch = receiptQty === poQty && invoiceQty === receiptQty; + const priceMatch = invoicePrice === poPrice; + const totalMatch = invoiceTotal === poTotal; + + expect(qtyMatch).toBe(true); + expect(priceMatch).toBe(true); + expect(totalMatch).toBe(true); + }); + + it('should flag mismatches for review', async () => { + // PO values + const poQty = 100; + const poPrice = 50.00; + + // Receipt values - short receipt + const receiptQty = 95; + + // Invoice values - price increase + const invoiceQty = 95; + const invoicePrice = 52.00; + + // Matching validation + const qtyMismatch = receiptQty !== poQty; + const priceMismatch = invoicePrice !== poPrice; + + expect(qtyMismatch).toBe(true); + expect(priceMismatch).toBe(true); + + // Calculate variances for reporting + const qtyVariance = poQty - receiptQty; + const priceVariance = invoicePrice - poPrice; + + expect(qtyVariance).toBe(5); + expect(priceVariance).toBe(2); + }); + }); + }); + + // ==================== Error Handling ==================== + describe('Error Handling', () => { + it('should handle order not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect(purchasesService.findById('nonexistent', tenantId)).rejects.toThrow(NotFoundError); + }); + + it('should handle database transaction rollback', async () => { + const poData = { + company_id: companyId, + partner_id: supplierId, + currency_id: currencyId, + lines: [ + { + product_id: productId, + description: 'Test', + quantity: 10, + uom_id: uomId, + price_unit: 100, + amount_untaxed: 1000, + }, + ], + }; + + const dbError = new Error('DB constraint violation'); + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockRejectedValueOnce(dbError); // INSERT fails + + await expect(purchasesService.create(poData, tenantId, userId)).rejects.toThrow(); + + expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); + expect(mockClient.release).toHaveBeenCalled(); + }); + + it('should prevent operations on cancelled orders', async () => { + const cancelledOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'cancelled', + }); + + mockQueryOne.mockResolvedValue(cancelledOrder); + mockQuery.mockResolvedValue([]); + + // Cannot update cancelled order + await expect( + purchasesService.update('po-001', { notes: 'test' }, tenantId, userId) + ).rejects.toThrow(ConflictError); + + // Cannot cancel already cancelled order + await expect(purchasesService.cancel('po-001', tenantId, userId)).rejects.toThrow( + ConflictError + ); + }); + + it('should prevent deletion of confirmed orders', async () => { + const confirmedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'confirmed', + }); + + mockQueryOne.mockResolvedValue(confirmedOrder); + mockQuery.mockResolvedValue([]); + + await expect(purchasesService.delete('po-001', tenantId)).rejects.toThrow(ConflictError); + }); + }); + + // ==================== Multi-line Order Tests ==================== + describe('Multi-line Order Processing', () => { + it('should handle orders with multiple products', async () => { + const multiLineOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'draft', + amount_untaxed: 10000, + amount_total: 10000, + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + product_id: 'product-1', + quantity: 100, + price_unit: 50, + amount_untaxed: 5000, + }), + createMockPurchaseOrderLine({ + id: 'line-2', + product_id: 'product-2', + quantity: 50, + price_unit: 100, + amount_untaxed: 5000, + }), + ], + }); + + mockQueryOne.mockResolvedValue(multiLineOrder); + mockQuery.mockResolvedValue(multiLineOrder.lines); + + const order = await purchasesService.findById('po-001', tenantId); + + expect(order.lines).toHaveLength(2); + expect(order.amount_untaxed).toBe(10000); + }); + + it('should handle partial receipt of some lines', async () => { + const partiallyReceivedOrder = createMockPurchaseOrder({ + id: 'po-001', + status: 'partial', + receipt_status: 'partial', + lines: [ + createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 100, // Fully received + status: 'received', + }), + createMockPurchaseOrderLine({ + id: 'line-2', + quantity: 50, + qty_received: 25, // Partially received + status: 'partial', + }), + ], + }); + + mockQueryOne.mockResolvedValue(partiallyReceivedOrder); + mockQuery.mockResolvedValue(partiallyReceivedOrder.lines); + + const order = await purchasesService.findById('po-001', tenantId); + + expect(order.status).toBe('partial'); + expect(order.lines![0].status).toBe('received'); + expect(order.lines![1].status).toBe('partial'); + }); + }); +}); diff --git a/src/modules/purchases/__tests__/receipts.service.test.ts b/src/modules/purchases/__tests__/receipts.service.test.ts new file mode 100644 index 0000000..741bd16 --- /dev/null +++ b/src/modules/purchases/__tests__/receipts.service.test.ts @@ -0,0 +1,681 @@ +/** + * Unit tests for Purchase Receipts + * + * Tests receipt creation, confirmation, and quantity validation + */ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + createMockRepository, + createMockQueryBuilder, + createMockPurchaseOrder, + createMockPurchaseOrderLine, +} from '../../../__tests__/helpers.js'; + +// Types for receipt entities +interface PurchaseReceipt { + id: string; + tenantId: string; + orderId: string; + receiptNumber: string; + receiptDate: Date; + receivedBy?: string; + warehouseId?: string; + locationId?: string; + supplierDeliveryNote?: string; + supplierInvoiceNumber?: string; + status: 'draft' | 'confirmed' | 'cancelled'; + notes?: string; + createdAt: Date; + createdBy?: string; + updatedAt: Date; + items?: PurchaseReceiptItem[]; +} + +interface PurchaseReceiptItem { + id: string; + receiptId: string; + orderItemId?: string; + productId?: string; + quantityExpected?: number; + quantityReceived: number; + quantityRejected: number; + lotNumber?: string; + serialNumber?: string; + expiryDate?: Date; + locationId?: string; + qualityStatus: 'pending' | 'approved' | 'rejected' | 'quarantine'; + qualityNotes?: string; + createdAt: Date; + updatedAt: Date; +} + +// Factory functions for receipts +function createMockReceipt(overrides: Partial = {}): PurchaseReceipt { + return { + id: 'receipt-uuid-1', + tenantId: 'test-tenant-uuid', + orderId: 'order-uuid-1', + receiptNumber: 'REC-000001', + receiptDate: new Date(), + receivedBy: 'user-uuid-1', + warehouseId: 'warehouse-uuid-1', + locationId: 'location-uuid-1', + supplierDeliveryNote: null, + supplierInvoiceNumber: null, + status: 'draft', + notes: null, + createdAt: new Date(), + createdBy: 'user-uuid-1', + updatedAt: new Date(), + items: [], + ...overrides, + }; +} + +function createMockReceiptItem(overrides: Partial = {}): PurchaseReceiptItem { + return { + id: 'receipt-item-uuid-1', + receiptId: 'receipt-uuid-1', + orderItemId: 'order-item-uuid-1', + productId: 'product-uuid-1', + quantityExpected: 100, + quantityReceived: 100, + quantityRejected: 0, + lotNumber: null, + serialNumber: null, + expiryDate: null, + locationId: 'location-uuid-1', + qualityStatus: 'pending', + qualityNotes: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('ReceiptsService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + const orderId = 'order-uuid-1'; + + let mockReceiptRepository: ReturnType; + let mockReceiptItemRepository: ReturnType; + let mockOrderRepository: ReturnType; + let mockOrderItemRepository: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockReceiptRepository = createMockRepository(); + mockReceiptItemRepository = createMockRepository(); + mockOrderRepository = createMockRepository(); + mockOrderItemRepository = createMockRepository(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Receipt Creation', () => { + describe('createReceipt', () => { + it('should create receipt for confirmed order', async () => { + const confirmedOrder = createMockPurchaseOrder({ + id: orderId, + status: 'confirmed', + lines: [createMockPurchaseOrderLine({ quantity: 100, qty_received: 0 })], + }); + + const newReceipt = createMockReceipt({ + orderId: confirmedOrder.id, + status: 'draft', + }); + + mockOrderRepository.findOne.mockResolvedValue(confirmedOrder); + mockReceiptRepository.create.mockReturnValue(newReceipt); + mockReceiptRepository.save.mockResolvedValue(newReceipt); + + const result = await mockReceiptRepository.save( + mockReceiptRepository.create({ + tenantId, + orderId: confirmedOrder.id, + status: 'draft', + }) + ); + + expect(result).toBeDefined(); + expect(result.status).toBe('draft'); + expect(result.orderId).toBe(orderId); + }); + + it('should not create receipt for draft order', async () => { + const draftOrder = createMockPurchaseOrder({ + id: orderId, + status: 'draft', + }); + + mockOrderRepository.findOne.mockResolvedValue(draftOrder); + + // Validation logic + const canCreateReceipt = draftOrder.status === 'confirmed' || draftOrder.status === 'partial'; + + expect(canCreateReceipt).toBe(false); + }); + + it('should not create receipt for cancelled order', async () => { + const cancelledOrder = createMockPurchaseOrder({ + id: orderId, + status: 'cancelled', + }); + + mockOrderRepository.findOne.mockResolvedValue(cancelledOrder); + + const canCreateReceipt = cancelledOrder.status === 'confirmed' || cancelledOrder.status === 'partial'; + + expect(canCreateReceipt).toBe(false); + }); + }); + + describe('addReceiptItem', () => { + it('should add item to receipt', async () => { + const receipt = createMockReceipt({ status: 'draft' }); + const newItem = createMockReceiptItem({ + receiptId: receipt.id, + quantityReceived: 50, + }); + + mockReceiptRepository.findOne.mockResolvedValue(receipt); + mockReceiptItemRepository.create.mockReturnValue(newItem); + mockReceiptItemRepository.save.mockResolvedValue(newItem); + + const result = await mockReceiptItemRepository.save( + mockReceiptItemRepository.create(newItem) + ); + + expect(result).toBeDefined(); + expect(result.quantityReceived).toBe(50); + expect(result.receiptId).toBe(receipt.id); + }); + + it('should validate quantity does not exceed remaining', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 80, + }); + + const remainingQty = orderLine.quantity - orderLine.qty_received; + const requestedQty = 30; // Exceeds remaining + + expect(requestedQty).toBeGreaterThan(remainingQty); + expect(remainingQty).toBe(20); + }); + + it('should not add item to confirmed receipt', async () => { + const confirmedReceipt = createMockReceipt({ status: 'confirmed' }); + + mockReceiptRepository.findOne.mockResolvedValue(confirmedReceipt); + + const canAddItem = confirmedReceipt.status === 'draft'; + + expect(canAddItem).toBe(false); + }); + }); + }); + + describe('Receipt Confirmation', () => { + describe('confirmReceipt', () => { + it('should confirm receipt and update order quantities', async () => { + const receipt = createMockReceipt({ + status: 'draft', + items: [ + createMockReceiptItem({ + orderItemId: 'line-1', + quantityReceived: 50, + }), + ], + }); + + const orderLine = createMockPurchaseOrderLine({ + id: 'line-1', + quantity: 100, + qty_received: 0, + }); + + mockReceiptRepository.findOne.mockResolvedValue(receipt); + mockOrderItemRepository.findOne.mockResolvedValue(orderLine); + + // Simulate confirmation + receipt.status = 'confirmed'; + orderLine.qty_received = 50; + + expect(receipt.status).toBe('confirmed'); + expect(orderLine.qty_received).toBe(50); + }); + + it('should not confirm empty receipt', async () => { + const emptyReceipt = createMockReceipt({ + status: 'draft', + items: [], + }); + + mockReceiptRepository.findOne.mockResolvedValue(emptyReceipt); + + const canConfirm = emptyReceipt.items && emptyReceipt.items.length > 0; + + expect(canConfirm).toBe(false); + }); + + it('should update order status to partial for incomplete receipt', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 0, + }); + + // Receive 50 of 100 + orderLine.qty_received = 50; + + const isComplete = orderLine.qty_received >= orderLine.quantity; + const newStatus = isComplete ? 'received' : 'partial'; + + expect(newStatus).toBe('partial'); + }); + + it('should update order status to received for complete receipt', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 0, + }); + + // Receive all 100 + orderLine.qty_received = 100; + + const isComplete = orderLine.qty_received >= orderLine.quantity; + const newStatus = isComplete ? 'received' : 'partial'; + + expect(newStatus).toBe('received'); + }); + }); + + describe('cancelReceipt', () => { + it('should cancel draft receipt', async () => { + const draftReceipt = createMockReceipt({ status: 'draft' }); + + mockReceiptRepository.findOne.mockResolvedValue(draftReceipt); + + const canCancel = draftReceipt.status === 'draft'; + + expect(canCancel).toBe(true); + draftReceipt.status = 'cancelled'; + expect(draftReceipt.status).toBe('cancelled'); + }); + + it('should not cancel confirmed receipt without reversal', async () => { + const confirmedReceipt = createMockReceipt({ status: 'confirmed' }); + + mockReceiptRepository.findOne.mockResolvedValue(confirmedReceipt); + + const canCancel = confirmedReceipt.status === 'draft'; + + expect(canCancel).toBe(false); + }); + }); + }); + + describe('Quantity Validation', () => { + describe('validateReceiptQuantities', () => { + it('should accept exact quantity', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 0, + }); + + const receiveQty = 100; + const remainingQty = orderLine.quantity - orderLine.qty_received; + const isValid = receiveQty <= remainingQty; + + expect(isValid).toBe(true); + }); + + it('should accept partial quantity', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 0, + }); + + const receiveQty = 50; + const remainingQty = orderLine.quantity - orderLine.qty_received; + const isValid = receiveQty <= remainingQty; + + expect(isValid).toBe(true); + }); + + it('should reject quantity exceeding remaining', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 80, + }); + + const receiveQty = 30; // Only 20 remaining + const remainingQty = orderLine.quantity - orderLine.qty_received; + const isValid = receiveQty <= remainingQty; + + expect(isValid).toBe(false); + expect(remainingQty).toBe(20); + }); + + it('should handle over-receipt with tolerance', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 0, + }); + + const TOLERANCE_PERCENT = 5; // 5% over-receipt allowed + const maxAllowed = orderLine.quantity * (1 + TOLERANCE_PERCENT / 100); + const receiveQty = 103; // 3% over + + const isWithinTolerance = receiveQty <= maxAllowed; + + expect(isWithinTolerance).toBe(true); + expect(maxAllowed).toBe(105); + }); + + it('should reject over-receipt exceeding tolerance', async () => { + const orderLine = createMockPurchaseOrderLine({ + quantity: 100, + qty_received: 0, + }); + + const TOLERANCE_PERCENT = 5; + const maxAllowed = orderLine.quantity * (1 + TOLERANCE_PERCENT / 100); + const receiveQty = 110; // 10% over, exceeds tolerance + + const isWithinTolerance = receiveQty <= maxAllowed; + + expect(isWithinTolerance).toBe(false); + }); + }); + + describe('handleRejectedQuantities', () => { + it('should track rejected quantities', async () => { + const receiptItem = createMockReceiptItem({ + quantityExpected: 100, + quantityReceived: 90, + quantityRejected: 10, + }); + + const totalProcessed = receiptItem.quantityReceived + receiptItem.quantityRejected; + + expect(totalProcessed).toBe(100); + expect(receiptItem.quantityRejected).toBe(10); + }); + + it('should update quality status for rejected items', async () => { + const receiptItem = createMockReceiptItem({ + quantityReceived: 90, + quantityRejected: 10, + qualityStatus: 'pending', + }); + + // If items are rejected, mark for quality review + if (receiptItem.quantityRejected > 0) { + receiptItem.qualityStatus = 'rejected'; + receiptItem.qualityNotes = 'Items damaged during shipping'; + } + + expect(receiptItem.qualityStatus).toBe('rejected'); + expect(receiptItem.qualityNotes).toBeDefined(); + }); + }); + }); + + describe('Quality Control', () => { + describe('qualityInspection', () => { + it('should put items in quarantine for inspection', async () => { + const receiptItem = createMockReceiptItem({ + quantityReceived: 100, + qualityStatus: 'pending', + }); + + // Move to quarantine for inspection + receiptItem.qualityStatus = 'quarantine'; + + expect(receiptItem.qualityStatus).toBe('quarantine'); + }); + + it('should approve items after inspection', async () => { + const receiptItem = createMockReceiptItem({ + quantityReceived: 100, + qualityStatus: 'quarantine', + }); + + // Approve after inspection + receiptItem.qualityStatus = 'approved'; + + expect(receiptItem.qualityStatus).toBe('approved'); + }); + + it('should reject items failing inspection', async () => { + const receiptItem = createMockReceiptItem({ + quantityReceived: 100, + qualityStatus: 'quarantine', + }); + + // Reject after inspection + receiptItem.qualityStatus = 'rejected'; + receiptItem.qualityNotes = 'Failed quality standards'; + + expect(receiptItem.qualityStatus).toBe('rejected'); + }); + }); + }); + + describe('Lot and Serial Number Tracking', () => { + describe('lotTracking', () => { + it('should track lot number for received items', async () => { + const receiptItem = createMockReceiptItem({ + quantityReceived: 100, + lotNumber: 'LOT-2024-001', + expiryDate: new Date('2025-06-15'), + }); + + expect(receiptItem.lotNumber).toBe('LOT-2024-001'); + expect(receiptItem.expiryDate).toBeDefined(); + }); + + it('should track serial number for serialized items', async () => { + const receiptItem = createMockReceiptItem({ + quantityReceived: 1, + serialNumber: 'SN-ABC123', + }); + + expect(receiptItem.serialNumber).toBe('SN-ABC123'); + expect(receiptItem.quantityReceived).toBe(1); // Serialized items must be qty 1 + }); + + it('should validate expiry date is in future', async () => { + const today = new Date(); + const futureDate = new Date(today.getFullYear() + 1, today.getMonth(), today.getDate()); + const pastDate = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()); + + const isFutureValid = futureDate > today; + const isPastValid = pastDate > today; + + expect(isFutureValid).toBe(true); + expect(isPastValid).toBe(false); + }); + }); + }); + + describe('Multi-Item Receipt', () => { + describe('receiveMultipleItems', () => { + it('should process receipt with multiple items', async () => { + const receipt = createMockReceipt({ + status: 'draft', + items: [ + createMockReceiptItem({ + id: 'item-1', + orderItemId: 'line-1', + quantityReceived: 100, + }), + createMockReceiptItem({ + id: 'item-2', + orderItemId: 'line-2', + quantityReceived: 50, + }), + createMockReceiptItem({ + id: 'item-3', + orderItemId: 'line-3', + quantityReceived: 25, + }), + ], + }); + + expect(receipt.items).toHaveLength(3); + + const totalReceived = receipt.items!.reduce( + (sum, item) => sum + item.quantityReceived, + 0 + ); + + expect(totalReceived).toBe(175); + }); + + it('should handle mixed quality statuses', async () => { + const receipt = createMockReceipt({ + status: 'draft', + items: [ + createMockReceiptItem({ + id: 'item-1', + quantityReceived: 100, + qualityStatus: 'approved', + }), + createMockReceiptItem({ + id: 'item-2', + quantityReceived: 50, + qualityStatus: 'quarantine', + }), + createMockReceiptItem({ + id: 'item-3', + quantityReceived: 25, + qualityStatus: 'rejected', + }), + ], + }); + + const approvedItems = receipt.items!.filter( + (i) => i.qualityStatus === 'approved' + ); + const pendingQcItems = receipt.items!.filter( + (i) => i.qualityStatus === 'quarantine' + ); + const rejectedItems = receipt.items!.filter( + (i) => i.qualityStatus === 'rejected' + ); + + expect(approvedItems).toHaveLength(1); + expect(pendingQcItems).toHaveLength(1); + expect(rejectedItems).toHaveLength(1); + }); + }); + }); + + describe('Warehouse Location Assignment', () => { + describe('assignLocation', () => { + it('should assign default warehouse location', async () => { + const receiptItem = createMockReceiptItem({ + locationId: 'default-location-uuid', + }); + + expect(receiptItem.locationId).toBe('default-location-uuid'); + }); + + it('should allow custom location assignment', async () => { + const receiptItem = createMockReceiptItem({ + locationId: 'custom-location-uuid', + }); + + expect(receiptItem.locationId).toBe('custom-location-uuid'); + }); + + it('should validate location belongs to warehouse', async () => { + const warehouseId = 'warehouse-uuid-1'; + const validLocation = { id: 'loc-1', warehouseId: 'warehouse-uuid-1' }; + const invalidLocation = { id: 'loc-2', warehouseId: 'warehouse-uuid-2' }; + + const isValidLocation = validLocation.warehouseId === warehouseId; + const isInvalidLocation = invalidLocation.warehouseId === warehouseId; + + expect(isValidLocation).toBe(true); + expect(isInvalidLocation).toBe(false); + }); + }); + }); + + describe('Supplier Reference Tracking', () => { + describe('supplierDocuments', () => { + it('should track supplier delivery note', async () => { + const receipt = createMockReceipt({ + supplierDeliveryNote: 'DN-SUP-2024-001', + }); + + expect(receipt.supplierDeliveryNote).toBe('DN-SUP-2024-001'); + }); + + it('should track supplier invoice number', async () => { + const receipt = createMockReceipt({ + supplierInvoiceNumber: 'INV-SUP-2024-001', + }); + + expect(receipt.supplierInvoiceNumber).toBe('INV-SUP-2024-001'); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero quantity receipt item', async () => { + const receiptItem = createMockReceiptItem({ + quantityExpected: 100, + quantityReceived: 0, + quantityRejected: 0, + }); + + const wasReceived = receiptItem.quantityReceived > 0; + + expect(wasReceived).toBe(false); + }); + + it('should handle receipt with all items rejected', async () => { + const receipt = createMockReceipt({ + items: [ + createMockReceiptItem({ + quantityReceived: 0, + quantityRejected: 100, + qualityStatus: 'rejected', + }), + ], + }); + + const totalReceived = receipt.items!.reduce( + (sum, i) => sum + i.quantityReceived, + 0 + ); + const totalRejected = receipt.items!.reduce( + (sum, i) => sum + i.quantityRejected, + 0 + ); + + expect(totalReceived).toBe(0); + expect(totalRejected).toBe(100); + }); + + it('should handle decimal quantities', async () => { + const receiptItem = createMockReceiptItem({ + quantityExpected: 100.5, + quantityReceived: 100.25, + quantityRejected: 0.25, + }); + + const total = receiptItem.quantityReceived + receiptItem.quantityRejected; + + expect(total).toBeCloseTo(100.5, 2); + }); + }); +});