diff --git a/src/modules/sales/__tests__/pricelists.service.test.ts b/src/modules/sales/__tests__/pricelists.service.test.ts new file mode 100644 index 0000000..5b8dc85 --- /dev/null +++ b/src/modules/sales/__tests__/pricelists.service.test.ts @@ -0,0 +1,561 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), + getClient: jest.fn(), +})); + +// Import after mocking +import { pricelistsService } from '../pricelists.service.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js'; + +// Helper to create mock pricelist +function createMockPricelist(overrides: Record = {}) { + return { + id: 'pricelist-uuid-1', + tenant_id: 'test-tenant-uuid', + company_id: 'company-uuid-1', + company_name: 'Test Company', + name: 'Standard Pricelist', + currency_id: 'currency-uuid-1', + currency_code: 'MXN', + active: true, + items: [], + created_at: new Date(), + ...overrides, + }; +} + +// Helper to create mock pricelist item +function createMockPricelistItem(overrides: Record = {}) { + return { + id: 'pricelist-item-uuid-1', + pricelist_id: 'pricelist-uuid-1', + product_id: 'product-uuid-1', + product_name: 'Test Product', + product_category_id: null, + category_name: null, + price: 100, + min_quantity: 1, + valid_from: null, + valid_to: null, + active: true, + ...overrides, + }; +} + +describe('PricelistsService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return pricelists with pagination', async () => { + const mockPricelists = [ + createMockPricelist({ id: '1', name: 'Standard' }), + createMockPricelist({ id: '2', name: 'VIP' }), + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(mockPricelists); + + const result = await pricelistsService.findAll(tenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by company_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await pricelistsService.findAll(tenantId, { company_id: 'company-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('p.company_id = $'), + expect.arrayContaining([tenantId, 'company-uuid']) + ); + }); + + it('should filter by active status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await pricelistsService.findAll(tenantId, { active: true }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('p.active = $'), + expect.arrayContaining([tenantId, true]) + ); + }); + + it('should filter by inactive status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await pricelistsService.findAll(tenantId, { active: false }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('p.active = $'), + expect.arrayContaining([tenantId, false]) + ); + }); + + it('should apply pagination correctly', async () => { + mockQueryOne.mockResolvedValue({ count: '50' }); + mockQuery.mockResolvedValue([]); + + await pricelistsService.findAll(tenantId, { page: 3, limit: 10 }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('LIMIT'), + expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3) + ); + }); + + it('should use default pagination when not provided', async () => { + mockQueryOne.mockResolvedValue({ count: '50' }); + mockQuery.mockResolvedValue([]); + + await pricelistsService.findAll(tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('LIMIT'), + expect.arrayContaining([20, 0]) // default limit=20, offset=0 + ); + }); + }); + + describe('findById', () => { + it('should return pricelist with items when found', async () => { + const mockPricelist = createMockPricelist(); + const mockItems = [createMockPricelistItem()]; + + mockQueryOne.mockResolvedValue(mockPricelist); + mockQuery.mockResolvedValue(mockItems); + + const result = await pricelistsService.findById('pricelist-uuid-1', tenantId); + + expect(result).toEqual({ ...mockPricelist, items: mockItems }); + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('WHERE p.id = $1 AND p.tenant_id = $2'), + ['pricelist-uuid-1', tenantId] + ); + }); + + it('should throw NotFoundError when pricelist not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + pricelistsService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should return empty items array when no items exist', async () => { + const mockPricelist = createMockPricelist(); + mockQueryOne.mockResolvedValue(mockPricelist); + mockQuery.mockResolvedValue([]); + + const result = await pricelistsService.findById('pricelist-uuid-1', tenantId); + + expect(result.items).toEqual([]); + }); + }); + + describe('create', () => { + const createDto = { + name: 'New Pricelist', + currency_id: 'currency-uuid', + }; + + it('should create pricelist successfully', async () => { + mockQueryOne + .mockResolvedValueOnce(null) // unique name check + .mockResolvedValueOnce(createMockPricelist({ name: 'New Pricelist' })); // INSERT + + const result = await pricelistsService.create(createDto, tenantId, userId); + + expect(result.name).toBe('New Pricelist'); + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO sales.pricelists'), + expect.any(Array) + ); + }); + + it('should create pricelist with company_id', async () => { + const dtoWithCompany = { ...createDto, company_id: 'company-uuid' }; + mockQueryOne + .mockResolvedValueOnce(null) // unique name check + .mockResolvedValueOnce(createMockPricelist({ name: 'New Pricelist', company_id: 'company-uuid' })); + + await pricelistsService.create(dtoWithCompany, tenantId, userId); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO sales.pricelists'), + expect.arrayContaining(['company-uuid']) + ); + }); + + it('should throw ConflictError when name already exists', async () => { + mockQueryOne.mockResolvedValueOnce({ id: 'existing-id' }); // name exists + + await expect( + pricelistsService.create(createDto, tenantId, userId) + ).rejects.toThrow(ConflictError); + }); + }); + + describe('update', () => { + it('should update pricelist name', async () => { + const existingPricelist = createMockPricelist(); + mockQueryOne + .mockResolvedValueOnce(existingPricelist) // findById + .mockResolvedValueOnce(null) // unique name check + .mockResolvedValueOnce(existingPricelist); // findById after update + mockQuery + .mockResolvedValueOnce([]) // items for findById + .mockResolvedValueOnce(undefined) // UPDATE + .mockResolvedValueOnce([]); // items for findById after update + + await pricelistsService.update( + 'pricelist-uuid-1', + { name: 'Updated Pricelist' }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE sales.pricelists SET'), + expect.any(Array) + ); + }); + + it('should update pricelist currency_id', async () => { + const existingPricelist = createMockPricelist(); + mockQueryOne + .mockResolvedValueOnce(existingPricelist) + .mockResolvedValueOnce(existingPricelist); + mockQuery + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([]); + + await pricelistsService.update( + 'pricelist-uuid-1', + { currency_id: 'new-currency-uuid' }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('currency_id'), + expect.arrayContaining(['new-currency-uuid']) + ); + }); + + it('should update pricelist active status', async () => { + const existingPricelist = createMockPricelist(); + mockQueryOne + .mockResolvedValueOnce(existingPricelist) + .mockResolvedValueOnce(existingPricelist); + mockQuery + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([]); + + await pricelistsService.update( + 'pricelist-uuid-1', + { active: false }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('active'), + expect.arrayContaining([false]) + ); + }); + + it('should throw ConflictError when updating to existing name', async () => { + const existingPricelist = createMockPricelist(); + mockQueryOne + .mockResolvedValueOnce(existingPricelist) // findById + .mockResolvedValueOnce({ id: 'other-pricelist' }); // name conflict + mockQuery.mockResolvedValue([]); + + await expect( + pricelistsService.update('pricelist-uuid-1', { name: 'Existing Name' }, tenantId, userId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw NotFoundError when pricelist not found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + await expect( + pricelistsService.update('nonexistent-id', { name: 'Test' }, tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('addItem', () => { + const itemDto = { + product_id: 'product-uuid', + price: 150, + min_quantity: 10, + }; + + it('should add item with product_id', async () => { + const pricelist = createMockPricelist(); + const newItem = createMockPricelistItem({ product_id: 'product-uuid', price: 150 }); + + mockQueryOne + .mockResolvedValueOnce(pricelist) // findById + .mockResolvedValueOnce(newItem); // INSERT + mockQuery.mockResolvedValue([]); // items for findById + + const result = await pricelistsService.addItem('pricelist-uuid-1', itemDto, tenantId, userId); + + expect(result.product_id).toBe('product-uuid'); + expect(result.price).toBe(150); + }); + + it('should add item with product_category_id', async () => { + const categoryItemDto = { + product_category_id: 'category-uuid', + price: 200, + }; + const pricelist = createMockPricelist(); + const newItem = createMockPricelistItem({ product_category_id: 'category-uuid', price: 200 }); + + mockQueryOne + .mockResolvedValueOnce(pricelist) + .mockResolvedValueOnce(newItem); + mockQuery.mockResolvedValue([]); + + const result = await pricelistsService.addItem('pricelist-uuid-1', categoryItemDto, tenantId, userId); + + expect(result.price).toBe(200); + }); + + it('should add item with validity dates', async () => { + const itemWithDates = { + product_id: 'product-uuid', + price: 100, + valid_from: '2024-01-01', + valid_to: '2024-12-31', + }; + const pricelist = createMockPricelist(); + const newItem = createMockPricelistItem(itemWithDates); + + mockQueryOne + .mockResolvedValueOnce(pricelist) + .mockResolvedValueOnce(newItem); + mockQuery.mockResolvedValue([]); + + await pricelistsService.addItem('pricelist-uuid-1', itemWithDates, tenantId, userId); + + expect(mockQueryOne).toHaveBeenLastCalledWith( + expect.stringContaining('INSERT INTO sales.pricelist_items'), + expect.arrayContaining(['2024-01-01', '2024-12-31']) + ); + }); + + it('should throw ValidationError when neither product nor category specified', async () => { + const invalidDto = { price: 100 }; + const pricelist = createMockPricelist(); + + mockQueryOne.mockResolvedValueOnce(pricelist); + mockQuery.mockResolvedValue([]); + + await expect( + pricelistsService.addItem('pricelist-uuid-1', invalidDto as any, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when both product and category specified', async () => { + const invalidDto = { + product_id: 'product-uuid', + product_category_id: 'category-uuid', + price: 100, + }; + const pricelist = createMockPricelist(); + + mockQueryOne.mockResolvedValueOnce(pricelist); + mockQuery.mockResolvedValue([]); + + await expect( + pricelistsService.addItem('pricelist-uuid-1', invalidDto, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw NotFoundError when pricelist not found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + await expect( + pricelistsService.addItem('nonexistent-id', itemDto, tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + + it('should use default min_quantity of 1', async () => { + const itemWithoutMinQty = { + product_id: 'product-uuid', + price: 100, + }; + const pricelist = createMockPricelist(); + const newItem = createMockPricelistItem(); + + mockQueryOne + .mockResolvedValueOnce(pricelist) + .mockResolvedValueOnce(newItem); + mockQuery.mockResolvedValue([]); + + await pricelistsService.addItem('pricelist-uuid-1', itemWithoutMinQty, tenantId, userId); + + expect(mockQueryOne).toHaveBeenLastCalledWith( + expect.stringContaining('INSERT INTO sales.pricelist_items'), + expect.arrayContaining([1]) // default min_quantity + ); + }); + }); + + describe('removeItem', () => { + it('should remove item from pricelist', async () => { + const pricelist = createMockPricelist(); + mockQueryOne.mockResolvedValue(pricelist); + mockQuery + .mockResolvedValueOnce([createMockPricelistItem()]) // items for findById + .mockResolvedValueOnce(undefined); // DELETE + + await pricelistsService.removeItem('pricelist-uuid-1', 'item-uuid', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM sales.pricelist_items'), + ['item-uuid', 'pricelist-uuid-1'] + ); + }); + + it('should throw NotFoundError when pricelist not found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + await expect( + pricelistsService.removeItem('nonexistent-id', 'item-uuid', tenantId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('getProductPrice', () => { + it('should return product-specific price', async () => { + mockQueryOne.mockResolvedValue({ price: 150 }); + + const result = await pricelistsService.getProductPrice( + 'product-uuid', + 'pricelist-uuid', + 5 + ); + + expect(result).toBe(150); + }); + + it('should return category price when no product-specific price', async () => { + mockQueryOne.mockResolvedValue({ price: 120 }); + + const result = await pricelistsService.getProductPrice( + 'product-uuid', + 'pricelist-uuid', + 1 + ); + + expect(result).toBe(120); + }); + + it('should return null when no price found', async () => { + mockQueryOne.mockResolvedValue(null); + + const result = await pricelistsService.getProductPrice( + 'product-uuid', + 'pricelist-uuid', + 1 + ); + + expect(result).toBeNull(); + }); + + it('should use default quantity of 1', async () => { + mockQueryOne.mockResolvedValue({ price: 100 }); + + await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid'); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('min_quantity <= $3'), + ['pricelist-uuid', 'product-uuid', 1] + ); + }); + + it('should filter by min_quantity', async () => { + mockQueryOne.mockResolvedValue({ price: 80 }); + + await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 100); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('min_quantity <= $3'), + expect.arrayContaining([100]) + ); + }); + + it('should filter by valid dates', async () => { + mockQueryOne.mockResolvedValue({ price: 100 }); + + await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('valid_from IS NULL OR valid_from <= CURRENT_DATE'), + expect.any(Array) + ); + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('valid_to IS NULL OR valid_to >= CURRENT_DATE'), + expect.any(Array) + ); + }); + + it('should only return active items', async () => { + mockQueryOne.mockResolvedValue({ price: 100 }); + + await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('active = true'), + expect.any(Array) + ); + }); + + it('should prioritize product-specific over category prices', async () => { + mockQueryOne.mockResolvedValue({ price: 100 }); + + await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY product_id NULLS LAST'), + expect.any(Array) + ); + }); + + it('should prioritize higher min_quantity', async () => { + mockQueryOne.mockResolvedValue({ price: 100 }); + + await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('min_quantity DESC'), + expect.any(Array) + ); + }); + }); +}); diff --git a/src/modules/sales/__tests__/sales-flow.integration.test.ts b/src/modules/sales/__tests__/sales-flow.integration.test.ts new file mode 100644 index 0000000..61fc92d --- /dev/null +++ b/src/modules/sales/__tests__/sales-flow.integration.test.ts @@ -0,0 +1,973 @@ +/** + * Sales Flow Integration Tests + * + * Tests the complete Order-to-Cash flow: + * 1. Quotation -> Approval -> Sales Order conversion + * 2. Sales Order -> Picking -> Delivery confirmation + * 3. Delivery -> Invoice generation + * 4. Order cancellation and credit note scenarios + * 5. Pricelist application and discounts + */ + +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + createMockQuotation, + createMockQuotationLine, + createMockSalesOrder, + createMockSalesOrderLine, +} from '../../../__tests__/helpers.js'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); +const mockGetClient = jest.fn(); + +// Create a mock client for transaction handling +const createMockClient = () => ({ + query: jest.fn(), + release: jest.fn(), +}); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), + getClient: () => mockGetClient(), +})); + +// Mock taxesService +jest.mock('../../financial/taxes.service.js', () => ({ + taxesService: { + calculateTaxes: jest.fn(() => Promise.resolve({ + amountUntaxed: 1000, + amountTax: 160, + amountTotal: 1160, + })), + }, +})); + +// Mock sequencesService +jest.mock('../../core/sequences.service.js', () => ({ + sequencesService: { + getNextNumber: jest.fn((code: string) => { + if (code === 'SO') return Promise.resolve('SO-000001'); + if (code === 'WH/OUT') return Promise.resolve('WH/OUT/000001'); + return Promise.resolve('SEQ-000001'); + }), + }, + SEQUENCE_CODES: { + SALES_ORDER: 'SO', + PICKING_OUT: 'WH/OUT', + }, +})); + +// Mock stockReservationService +const mockReserveWithClient = jest.fn(); +const mockReleaseWithClient = jest.fn(); + +jest.mock('../../inventory/stock-reservation.service.js', () => ({ + stockReservationService: { + reserveWithClient: (...args: any[]) => mockReserveWithClient(...args), + releaseWithClient: (...args: any[]) => mockReleaseWithClient(...args), + checkAvailability: jest.fn(() => Promise.resolve({ available: true, lines: [] })), + }, +})); + +// Mock logger +jest.mock('../../../shared/utils/logger.js', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +// Import services after mocking +import { quotationsService } from '../quotations.service.js'; +import { ordersService } from '../orders.service.js'; +import { pricelistsService } from '../pricelists.service.js'; +import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; + +describe('Sales Flow Integration Tests', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + const companyId = 'company-uuid-1'; + const partnerId = 'partner-uuid-1'; + const currencyId = 'currency-uuid-1'; + const productId = 'product-uuid-1'; + + beforeEach(() => { + jest.clearAllMocks(); + mockReserveWithClient.mockResolvedValue({ success: true, errors: [] }); + mockReleaseWithClient.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Flow 1: Quotation -> Approval -> Sales Order Conversion', () => { + it('should create quotation, add lines, and convert to sales order', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + // Step 1: Create quotation + const quotationData = createMockQuotation({ + id: 'quotation-uuid', + name: 'QUO-000001', + status: 'draft', + company_id: companyId, + partner_id: partnerId, + currency_id: currencyId, + }); + + mockQueryOne + .mockResolvedValueOnce({ next_num: 1 }) // sequence for quotation + .mockResolvedValueOnce(quotationData); // INSERT quotation + + const createdQuotation = await quotationsService.create({ + company_id: companyId, + partner_id: partnerId, + currency_id: currencyId, + validity_date: '2024-12-31', + }, tenantId, userId); + + expect(createdQuotation.name).toBe('QUO-000001'); + expect(createdQuotation.status).toBe('draft'); + + // Step 2: Add line to quotation + const lineData = createMockQuotationLine({ + id: 'line-uuid', + quotation_id: 'quotation-uuid', + product_id: productId, + quantity: 10, + price_unit: 100, + }); + + mockQueryOne + .mockResolvedValueOnce(quotationData) // findById for addLine + .mockResolvedValueOnce(lineData); // INSERT line + mockQuery + .mockResolvedValueOnce([]) // lines for findById + .mockResolvedValueOnce(undefined); // updateTotals + + const addedLine = await quotationsService.addLine('quotation-uuid', { + product_id: productId, + description: 'Test Product', + quantity: 10, + uom_id: 'uom-uuid', + price_unit: 100, + }, tenantId, userId); + + expect(addedLine.product_id).toBe(productId); + + // Step 3: Confirm quotation (convert to sales order) + const quotationWithLines = { + ...quotationData, + lines: [lineData], + }; + + mockQueryOne + .mockResolvedValueOnce(quotationWithLines) // findById for confirm + .mockResolvedValueOnce(quotationWithLines); // findById after confirm + mockQuery + .mockResolvedValueOnce([lineData]) // lines for first findById + .mockResolvedValueOnce([lineData]); // lines for second findById + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // order sequence + .mockResolvedValueOnce({ rows: [{ id: 'order-uuid' }] }) // INSERT order + .mockResolvedValueOnce(undefined) // INSERT order lines + .mockResolvedValueOnce(undefined) // UPDATE quotation + .mockResolvedValueOnce(undefined); // COMMIT + + const { quotation, orderId } = await quotationsService.confirm('quotation-uuid', tenantId, userId); + + expect(orderId).toBe('order-uuid'); + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + }); + + it('should not convert quotation without lines', async () => { + const quotationWithoutLines = createMockQuotation({ + id: 'quotation-uuid', + status: 'draft', + lines: [], + }); + + mockQueryOne.mockResolvedValue(quotationWithoutLines); + mockQuery.mockResolvedValue([]); // no lines + + await expect( + quotationsService.confirm('quotation-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should not convert already confirmed quotation', async () => { + const confirmedQuotation = createMockQuotation({ + id: 'quotation-uuid', + status: 'confirmed', + }); + + mockQueryOne.mockResolvedValue(confirmedQuotation); + mockQuery.mockResolvedValue([]); + + await expect( + quotationsService.confirm('quotation-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should convert sent quotation to sales order', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const sentQuotation = createMockQuotation({ + id: 'quotation-uuid', + status: 'sent', + lines: [createMockQuotationLine()], + }); + + mockQueryOne + .mockResolvedValueOnce(sentQuotation) + .mockResolvedValueOnce(sentQuotation); + mockQuery.mockResolvedValue([createMockQuotationLine()]); + + mockClient.query + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) + .mockResolvedValueOnce({ rows: [{ id: 'order-uuid' }] }) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const { orderId } = await quotationsService.confirm('quotation-uuid', tenantId, userId); + + expect(orderId).toBe('order-uuid'); + }); + }); + + describe('Flow 2: Sales Order -> Picking -> Delivery Confirmation', () => { + it('should confirm sales order and create picking', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const orderWithLines = createMockSalesOrder({ + id: 'order-uuid', + name: 'SO-000001', + status: 'draft', + company_id: companyId, + lines: [createMockSalesOrderLine({ product_id: productId, quantity: 5 })], + }); + + mockQueryOne + .mockResolvedValueOnce(orderWithLines) // findById before confirm + .mockResolvedValueOnce({ ...orderWithLines, status: 'sent', picking_id: 'picking-uuid' }); // findById after confirm + mockQuery + .mockResolvedValueOnce([createMockSalesOrderLine({ product_id: productId, quantity: 5 })]) // lines before + .mockResolvedValueOnce([createMockSalesOrderLine({ product_id: productId, quantity: 5 })]); // lines after + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid', warehouse_id: 'warehouse-uuid' }] }) // location query + .mockResolvedValueOnce({ rows: [{ id: 'customer-location-uuid' }] }) // customer location + .mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking + .mockResolvedValueOnce({ rows: [] }) // INSERT stock_move + .mockResolvedValueOnce({ rows: [] }) // UPDATE order status + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const confirmedOrder = await ordersService.confirm('order-uuid', tenantId, userId); + + expect(confirmedOrder.status).toBe('sent'); + expect(confirmedOrder.picking_id).toBe('picking-uuid'); + expect(mockReserveWithClient).toHaveBeenCalled(); + }); + + it('should fail confirmation if stock is insufficient', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + mockReserveWithClient.mockResolvedValue({ + success: false, + errors: ['Insufficient stock for product Test Product'], + }); + + const orderWithLines = createMockSalesOrder({ + id: 'order-uuid', + status: 'draft', + company_id: companyId, + lines: [createMockSalesOrderLine({ product_id: productId, quantity: 1000 })], + }); + + mockQueryOne.mockResolvedValue(orderWithLines); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ product_id: productId, quantity: 1000 })]); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid', warehouse_id: 'warehouse-uuid' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'customer-location-uuid' }] }); + + await expect( + ordersService.confirm('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + + expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); + }); + + it('should not confirm order without lines', async () => { + const orderWithoutLines = createMockSalesOrder({ + id: 'order-uuid', + status: 'draft', + lines: [], + }); + + mockQueryOne.mockResolvedValue(orderWithoutLines); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.confirm('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should not confirm already confirmed order', async () => { + const confirmedOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + }); + + mockQueryOne.mockResolvedValue(confirmedOrder); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.confirm('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should create customer location if not exists', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const orderWithLines = createMockSalesOrder({ + id: 'order-uuid', + status: 'draft', + company_id: companyId, + lines: [createMockSalesOrderLine()], + }); + + mockQueryOne + .mockResolvedValueOnce(orderWithLines) + .mockResolvedValueOnce({ ...orderWithLines, status: 'sent' }); + mockQuery.mockResolvedValue([createMockSalesOrderLine()]); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid', warehouse_id: 'warehouse-uuid' }] }) + .mockResolvedValueOnce({ rows: [] }) // no customer location + .mockResolvedValueOnce({ rows: [{ id: 'new-customer-location-uuid' }] }) // INSERT customer location + .mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + await ordersService.confirm('order-uuid', tenantId, userId); + + // Verify customer location creation query was called + // The INSERT statement should contain inventory.locations + const calls = mockClient.query.mock.calls; + const insertLocationCall = calls.find((call: any[]) => + typeof call[0] === 'string' && call[0].includes('INSERT INTO inventory.locations') + ); + expect(insertLocationCall).toBeDefined(); + }); + }); + + describe('Flow 3: Delivery Confirmed -> Invoice Generation', () => { + it('should create invoice from confirmed order (policy=order)', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const confirmedOrder = createMockSalesOrder({ + id: 'order-uuid', + name: 'SO-000001', + status: 'sent', + invoice_status: 'pending', + invoice_policy: 'order', + company_id: companyId, + currency_id: currencyId, + partner_id: partnerId, + lines: [createMockSalesOrderLine({ + id: 'line-uuid', + quantity: 10, + qty_invoiced: 0, + price_unit: 100, + discount: 0, + })], + }); + + mockQueryOne.mockResolvedValue(confirmedOrder); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ + id: 'line-uuid', + quantity: 10, + qty_invoiced: 0, + price_unit: 100, + discount: 0, + })]); + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // invoice sequence + .mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid' }] }) // INSERT invoice + .mockResolvedValueOnce(undefined) // INSERT invoice line + .mockResolvedValueOnce(undefined) // UPDATE qty_invoiced + .mockResolvedValueOnce(undefined) // UPDATE invoice totals + .mockResolvedValueOnce(undefined) // UPDATE order status + .mockResolvedValueOnce(undefined); // COMMIT + + const { invoiceId } = await ordersService.createInvoice('order-uuid', tenantId, userId); + + expect(invoiceId).toBe('invoice-uuid'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + }); + + it('should create invoice based on delivered quantity (policy=delivery)', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const orderWithDelivery = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + invoice_status: 'pending', + invoice_policy: 'delivery', + lines: [createMockSalesOrderLine({ + id: 'line-uuid', + quantity: 10, + qty_delivered: 5, + qty_invoiced: 0, + price_unit: 100, + })], + }); + + mockQueryOne.mockResolvedValue(orderWithDelivery); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ + id: 'line-uuid', + quantity: 10, + qty_delivered: 5, + qty_invoiced: 0, + price_unit: 100, + })]); + + mockClient.query + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) + .mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid' }] }) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const { invoiceId } = await ordersService.createInvoice('order-uuid', tenantId, userId); + + expect(invoiceId).toBe('invoice-uuid'); + }); + + it('should not invoice draft order', async () => { + const draftOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'draft', + }); + + mockQueryOne.mockResolvedValue(draftOrder); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.createInvoice('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should not invoice fully invoiced order', async () => { + const fullyInvoicedOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + invoice_status: 'invoiced', + }); + + mockQueryOne.mockResolvedValue(fullyInvoicedOrder); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.createInvoice('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should not invoice when no lines to invoice', async () => { + const orderFullyInvoicedLines = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + invoice_status: 'pending', + invoice_policy: 'order', + lines: [createMockSalesOrderLine({ + quantity: 10, + qty_invoiced: 10, // fully invoiced + })], + }); + + mockQueryOne.mockResolvedValue(orderFullyInvoicedLines); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ + quantity: 10, + qty_invoiced: 10, + })]); + + await expect( + ordersService.createInvoice('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should handle partial invoicing', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + // First invoice - partial + const partiallyInvoicedOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + invoice_status: 'pending', + invoice_policy: 'order', + lines: [createMockSalesOrderLine({ + id: 'line-uuid', + quantity: 10, + qty_invoiced: 5, // partially invoiced + price_unit: 100, + })], + }); + + mockQueryOne.mockResolvedValue(partiallyInvoicedOrder); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ + id: 'line-uuid', + quantity: 10, + qty_invoiced: 5, + price_unit: 100, + })]); + + mockClient.query + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ rows: [{ next_num: 2 }] }) + .mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid-2' }] }) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const { invoiceId } = await ordersService.createInvoice('order-uuid', tenantId, userId); + + expect(invoiceId).toBe('invoice-uuid-2'); + }); + }); + + describe('Flow 4: Order Cancellation', () => { + it('should cancel draft order', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const draftOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'draft', + delivery_status: 'pending', + invoice_status: 'pending', + }); + + mockQueryOne + .mockResolvedValueOnce(draftOrder) + .mockResolvedValueOnce({ ...draftOrder, status: 'cancelled' }); + mockQuery.mockResolvedValue([]); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // UPDATE status + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const cancelledOrder = await ordersService.cancel('order-uuid', tenantId, userId); + + expect(cancelledOrder.status).toBe('cancelled'); + }); + + it('should cancel confirmed order and release stock', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const confirmedOrder = createMockSalesOrder({ + id: 'order-uuid', + name: 'SO-000001', + status: 'sent', + delivery_status: 'pending', + invoice_status: 'pending', + picking_id: 'picking-uuid', + lines: [createMockSalesOrderLine({ product_id: productId, quantity: 5 })], + }); + + mockQueryOne + .mockResolvedValueOnce(confirmedOrder) + .mockResolvedValueOnce({ ...confirmedOrder, status: 'cancelled' }); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ product_id: productId, quantity: 5 })]); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid' }] }) // get picking location + .mockResolvedValueOnce({ rows: [] }) // UPDATE picking cancelled + .mockResolvedValueOnce({ rows: [] }) // UPDATE stock_moves cancelled + .mockResolvedValueOnce({ rows: [] }) // UPDATE order status + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await ordersService.cancel('order-uuid', tenantId, userId); + + expect(mockReleaseWithClient).toHaveBeenCalled(); + }); + + it('should not cancel completed order', async () => { + const completedOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'done', + }); + + mockQueryOne.mockResolvedValue(completedOrder); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.cancel('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should not cancel order with deliveries', async () => { + const orderWithDeliveries = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + delivery_status: 'partial', + invoice_status: 'pending', + }); + + mockQueryOne.mockResolvedValue(orderWithDeliveries); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.cancel('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should not cancel order with invoices', async () => { + const orderWithInvoices = createMockSalesOrder({ + id: 'order-uuid', + status: 'sent', + delivery_status: 'pending', + invoice_status: 'partial', + }); + + mockQueryOne.mockResolvedValue(orderWithInvoices); + mockQuery.mockResolvedValue([]); + + await expect( + ordersService.cancel('order-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should cancel quotation in draft status', async () => { + const draftQuotation = createMockQuotation({ + id: 'quotation-uuid', + status: 'draft', + }); + + mockQueryOne.mockResolvedValue(draftQuotation); + mockQuery + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(undefined); + + await quotationsService.cancel('quotation-uuid', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'cancelled'"), + expect.any(Array) + ); + }); + + it('should not cancel confirmed quotation', async () => { + const confirmedQuotation = createMockQuotation({ + id: 'quotation-uuid', + status: 'confirmed', + }); + + mockQueryOne.mockResolvedValue(confirmedQuotation); + mockQuery.mockResolvedValue([]); + + await expect( + quotationsService.cancel('quotation-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('Flow 5: Pricelist Application and Discounts', () => { + it('should get product price from pricelist', async () => { + mockQueryOne.mockResolvedValue({ price: 85 }); // discounted price + + const price = await pricelistsService.getProductPrice( + productId, + 'pricelist-uuid', + 10 + ); + + expect(price).toBe(85); + }); + + it('should return higher discount for larger quantities', async () => { + // First call with small quantity + mockQueryOne.mockResolvedValueOnce({ price: 100 }); + + const smallQtyPrice = await pricelistsService.getProductPrice( + productId, + 'pricelist-uuid', + 1 + ); + + expect(smallQtyPrice).toBe(100); + + // Second call with large quantity + mockQueryOne.mockResolvedValueOnce({ price: 80 }); + + const largeQtyPrice = await pricelistsService.getProductPrice( + productId, + 'pricelist-uuid', + 100 + ); + + expect(largeQtyPrice).toBe(80); + }); + + it('should return null when product not in pricelist', async () => { + mockQueryOne.mockResolvedValue(null); + + const price = await pricelistsService.getProductPrice( + 'unknown-product', + 'pricelist-uuid', + 1 + ); + + expect(price).toBeNull(); + }); + + it('should respect pricelist validity dates', async () => { + mockQueryOne.mockResolvedValue({ price: 90 }); + + await pricelistsService.getProductPrice(productId, 'pricelist-uuid', 1); + + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('valid_from IS NULL OR valid_from <= CURRENT_DATE'), + expect.any(Array) + ); + }); + }); + + describe('Error Handling and Rollback', () => { + it('should rollback on quotation confirmation error', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const quotation = createMockQuotation({ + status: 'draft', + lines: [createMockQuotationLine()], + }); + + mockQueryOne.mockResolvedValue(quotation); + mockQuery.mockResolvedValue([createMockQuotationLine()]); + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockRejectedValueOnce(new Error('Database error')); // fail on sequence + + await expect( + quotationsService.confirm('quotation-uuid', tenantId, userId) + ).rejects.toThrow('Database error'); + + expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); + expect(mockClient.release).toHaveBeenCalled(); + }); + + it('should rollback on order confirmation error', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const order = createMockSalesOrder({ + status: 'draft', + company_id: companyId, + lines: [createMockSalesOrderLine()], + }); + + mockQueryOne.mockResolvedValue(order); + mockQuery.mockResolvedValue([createMockSalesOrderLine()]); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'loc-uuid', warehouse_id: 'wh-uuid' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'cust-loc-uuid' }] }) + .mockRejectedValueOnce(new Error('Picking creation failed')); + + await expect( + ordersService.confirm('order-uuid', tenantId, userId) + ).rejects.toThrow('Picking creation failed'); + + expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); + }); + + it('should rollback on invoice creation error', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const order = createMockSalesOrder({ + status: 'sent', + invoice_status: 'pending', + invoice_policy: 'order', + lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })], + }); + + mockQueryOne.mockResolvedValue(order); + mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })]); + + mockClient.query + .mockResolvedValueOnce(undefined) // BEGIN + .mockRejectedValueOnce(new Error('Sequence error')); + + await expect( + ordersService.createInvoice('order-uuid', tenantId, userId) + ).rejects.toThrow('Sequence error'); + + expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); + }); + }); + + describe('State Transitions', () => { + it('should transition quotation: draft -> sent', async () => { + const draftQuotation = createMockQuotation({ + status: 'draft', + lines: [createMockQuotationLine()], + }); + + mockQueryOne + .mockResolvedValueOnce(draftQuotation) + .mockResolvedValueOnce({ ...draftQuotation, status: 'sent' }); + mockQuery + .mockResolvedValueOnce([createMockQuotationLine()]) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([createMockQuotationLine()]); + + const sentQuotation = await quotationsService.send('quotation-uuid', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'sent'"), + expect.any(Array) + ); + }); + + it('should not send quotation without lines', async () => { + const emptyQuotation = createMockQuotation({ + status: 'draft', + lines: [], + }); + + mockQueryOne.mockResolvedValue(emptyQuotation); + mockQuery.mockResolvedValue([]); + + await expect( + quotationsService.send('quotation-uuid', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should track order delivery_status transitions', async () => { + // pending -> partial -> delivered + const orderStates = [ + createMockSalesOrder({ delivery_status: 'pending' }), + createMockSalesOrder({ delivery_status: 'partial' }), + createMockSalesOrder({ delivery_status: 'delivered' }), + ]; + + for (const order of orderStates) { + mockQueryOne.mockResolvedValueOnce(order); + mockQuery.mockResolvedValueOnce([]); + + const result = await ordersService.findById('order-uuid', tenantId); + expect(['pending', 'partial', 'delivered']).toContain(result.delivery_status); + } + }); + + it('should track order invoice_status transitions', async () => { + // pending -> partial -> invoiced + const orderStates = [ + createMockSalesOrder({ invoice_status: 'pending' }), + createMockSalesOrder({ invoice_status: 'partial' }), + createMockSalesOrder({ invoice_status: 'invoiced' }), + ]; + + for (const order of orderStates) { + mockQueryOne.mockResolvedValueOnce(order); + mockQuery.mockResolvedValueOnce([]); + + const result = await ordersService.findById('order-uuid', tenantId); + expect(['pending', 'partial', 'invoiced']).toContain(result.invoice_status); + } + }); + }); + + describe('Multi-line Order Processing', () => { + it('should handle order with multiple lines', async () => { + const mockClient = createMockClient(); + mockGetClient.mockResolvedValue(mockClient); + + const multiLineOrder = createMockSalesOrder({ + id: 'order-uuid', + status: 'draft', + company_id: companyId, + lines: [ + createMockSalesOrderLine({ id: 'line-1', product_id: 'product-1', quantity: 5 }), + createMockSalesOrderLine({ id: 'line-2', product_id: 'product-2', quantity: 10 }), + createMockSalesOrderLine({ id: 'line-3', product_id: 'product-3', quantity: 3 }), + ], + }); + + const lines = [ + createMockSalesOrderLine({ id: 'line-1', product_id: 'product-1', quantity: 5 }), + createMockSalesOrderLine({ id: 'line-2', product_id: 'product-2', quantity: 10 }), + createMockSalesOrderLine({ id: 'line-3', product_id: 'product-3', quantity: 3 }), + ]; + + mockQueryOne + .mockResolvedValueOnce(multiLineOrder) + .mockResolvedValueOnce({ ...multiLineOrder, status: 'sent' }); + mockQuery + .mockResolvedValueOnce(lines) + .mockResolvedValueOnce(lines); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ location_id: 'loc-uuid', warehouse_id: 'wh-uuid' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'cust-loc-uuid' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) + .mockResolvedValueOnce({ rows: [] }) // stock_move for line 1 + .mockResolvedValueOnce({ rows: [] }) // stock_move for line 2 + .mockResolvedValueOnce({ rows: [] }) // stock_move for line 3 + .mockResolvedValueOnce({ rows: [] }) // UPDATE order + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await ordersService.confirm('order-uuid', tenantId, userId); + + // Verify stock reservation was called with all lines + expect(mockReserveWithClient).toHaveBeenCalledWith( + mockClient, + expect.arrayContaining([ + expect.objectContaining({ productId: 'product-1', quantity: 5 }), + expect.objectContaining({ productId: 'product-2', quantity: 10 }), + expect.objectContaining({ productId: 'product-3', quantity: 3 }), + ]), + tenantId, + expect.any(String), + false + ); + }); + }); +});