diff --git a/src/modules/field/plans/plans.service.spec.ts b/src/modules/field/plans/plans.service.spec.ts index 5aaaa10..7e72667 100644 --- a/src/modules/field/plans/plans.service.spec.ts +++ b/src/modules/field/plans/plans.service.spec.ts @@ -36,7 +36,10 @@ describe('PlansService', () => { const employeesService = { findById: jest.fn() }; const branchesService = { findById: jest.fn() }; const customersService = { findById: jest.fn() }; - const salesInvoicesService = { findById: jest.fn() }; + const salesInvoicesService = { + findById: jest.fn(), + markDraftsProcessed: jest.fn(), + }; const packingSlipsService = { findById: jest.fn() }; const privilegesService = { checkPermission: jest.fn() }; diff --git a/src/modules/field/plans/plans.service.ts b/src/modules/field/plans/plans.service.ts index f6f4f7b..956d347 100644 --- a/src/modules/field/plans/plans.service.ts +++ b/src/modules/field/plans/plans.service.ts @@ -136,6 +136,12 @@ export class PlansService { const created = await this.plansRepository.create( await this.toWritePayload(input), ); + await this.processAttachedInvoices( + created.purpose, + [], + created.invoiceIds, + input.userId, + ); return this.toItem(created); } @@ -201,6 +207,12 @@ export class PlansService { packingSlipIds: attachments.packingSlipIds, userId: input.userId, }); + await this.processAttachedInvoices( + purpose, + existing.invoiceIds, + attachments.invoiceIds, + input.userId, + ); return this.toItem(updated); } @@ -515,6 +527,23 @@ export class PlansService { }; } + private async processAttachedInvoices( + purpose: FieldPurpose, + previousIds: readonly string[], + nextIds: readonly string[], + userId: string, + ): Promise { + if (purpose !== 'sales') { + return; + } + const previous = new Set(previousIds); + const newlyAttached = nextIds.filter((id) => !previous.has(id)); + if (newlyAttached.length === 0) { + return; + } + await this.salesInvoicesService.markDraftsProcessed(newlyAttached, userId); + } + private async assertInvoices(ids: readonly string[]): Promise { for (const id of ids) { await this.salesInvoicesService.findById(id); diff --git a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts index 9503a03..c6db562 100644 --- a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts +++ b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts @@ -168,6 +168,13 @@ export class UpdatePackingSlipStatusDto { @ApiProperty({ enum: PACKING_SLIP_STATUSES }) @IsIn([...PACKING_SLIP_STATUSES]) status!: string; + + @ApiPropertyOptional({ type: [SalesLineDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SalesLineDto) + products?: SalesLineDto[]; } export class BulkIdsDto { diff --git a/src/modules/sales/packing-slips/packing-slips-write.controller.ts b/src/modules/sales/packing-slips/packing-slips-write.controller.ts index 3d98533..f63726a 100644 --- a/src/modules/sales/packing-slips/packing-slips-write.controller.ts +++ b/src/modules/sales/packing-slips/packing-slips-write.controller.ts @@ -155,7 +155,12 @@ export class PackingSlipsWriteController { @Body() dto: UpdatePackingSlipStatusDto, @CurrentUser('id') userId: string, ): Promise { - return this.packingSlipsService.updateStatus(id, dto.status, userId); + return this.packingSlipsService.updateStatus( + id, + dto.status, + userId, + dto.products, + ); } @Patch(':id') diff --git a/src/modules/sales/packing-slips/packing-slips.module.ts b/src/modules/sales/packing-slips/packing-slips.module.ts index 3d93796..dee4d46 100644 --- a/src/modules/sales/packing-slips/packing-slips.module.ts +++ b/src/modules/sales/packing-slips/packing-slips.module.ts @@ -1,17 +1,23 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { CustomersModule } from '../../configuration/customers/customers.module'; import { ProductsModule } from '../../configuration/products/products.module'; import { SalesOrdersModule } from '../sales-orders/sales-orders.module'; import { DocumentCodeService } from '../shared/document-code.service'; +import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module'; import { PackingSlipsReadController } from './packing-slips-read.controller'; import { PackingSlipsWriteController } from './packing-slips-write.controller'; import { PackingSlipsRepository } from './packing-slips.repository'; import { PackingSlipsService } from './packing-slips.service'; @Module({ - imports: [CustomersModule, ProductsModule, SalesOrdersModule], + imports: [ + CustomersModule, + ProductsModule, + forwardRef(() => SalesOrdersModule), + forwardRef(() => SalesDocumentFlowModule), + ], controllers: [PackingSlipsReadController, PackingSlipsWriteController], providers: [DocumentCodeService, PackingSlipsRepository, PackingSlipsService], - exports: [PackingSlipsService, DocumentCodeService], + exports: [PackingSlipsService, PackingSlipsRepository, DocumentCodeService], }) export class PackingSlipsModule {} diff --git a/src/modules/sales/packing-slips/packing-slips.repository.ts b/src/modules/sales/packing-slips/packing-slips.repository.ts index 2c5804e..67c60de 100644 --- a/src/modules/sales/packing-slips/packing-slips.repository.ts +++ b/src/modules/sales/packing-slips/packing-slips.repository.ts @@ -92,6 +92,14 @@ export class PackingSlipsRepository { return this.hydrateOne(row, products); } + async listBySalesOrderId(salesOrderId: string): Promise { + const rows = await this.db + .select() + .from(packingSlips) + .where(eq(packingSlips.salesOrderId, salesOrderId)); + return this.hydrate(rows.map((row) => this.toDomain(row, []))); + } + async create(input: CreatePackingSlipInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = diff --git a/src/modules/sales/packing-slips/packing-slips.service.spec.ts b/src/modules/sales/packing-slips/packing-slips.service.spec.ts index 36f66ce..c607040 100644 --- a/src/modules/sales/packing-slips/packing-slips.service.spec.ts +++ b/src/modules/sales/packing-slips/packing-slips.service.spec.ts @@ -6,6 +6,7 @@ import { Status } from '../../../common/value-objects/status/status'; import { CustomersService } from '../../configuration/customers/customers.service'; import { ProductsService } from '../../configuration/products/products.service'; import { SalesOrdersService } from '../sales-orders/sales-orders.service'; +import { SalesDocumentFlowService } from '../shared/sales-document-flow.service'; import { PACKING_SLIP_STATUSES } from '../shared/sales-fields'; import type { PackingSlip } from './packing-slip'; import { PackingSlipsRepository } from './packing-slips.repository'; @@ -39,7 +40,10 @@ describe('PackingSlipsService', () => { }; const customersService = { findById: jest.fn() }; const productsService = { findById: jest.fn() }; - const salesOrdersService = { findById: jest.fn() }; + const salesOrdersService = { findById: jest.fn(), updateStatus: jest.fn() }; + const salesDocumentFlowService = { + completePacking: jest.fn(), + }; const now = DateTime.fromUnixMs(1_700_000_000_000); const sample: PackingSlip = { @@ -95,6 +99,10 @@ describe('PackingSlipsService', () => { { provide: CustomersService, useValue: customersService }, { provide: ProductsService, useValue: productsService }, { provide: SalesOrdersService, useValue: salesOrdersService }, + { + provide: SalesDocumentFlowService, + useValue: salesDocumentFlowService, + }, ], }).compile(); service = moduleRef.get(PackingSlipsService); @@ -230,7 +238,11 @@ describe('PackingSlipsService', () => { }); it('updateStatus uses the packing-slip allow-list', async () => { - repository.updateStatus.mockResolvedValue(sample); + repository.findById.mockResolvedValue(sample); + repository.updateStatus.mockResolvedValue({ + ...sample, + status: Status.create('processed', PACKING_SLIP_STATUSES), + }); await service.updateStatus('ps-1', 'processed', 'user-1'); expect(repository.updateStatus).toHaveBeenCalledWith( 'ps-1', @@ -238,4 +250,23 @@ describe('PackingSlipsService', () => { 'user-1', ); }); + + it('complete delegates to the sales document flow', async () => { + repository.findById.mockResolvedValue({ + ...sample, + status: Status.create('processed', PACKING_SLIP_STATUSES), + }); + salesDocumentFlowService.completePacking.mockResolvedValue({ + id: 'ps-1', + status: 'completed', + }); + await service.updateStatus('ps-1', 'completed', 'user-1', [ + { productId: 'prd-1', quantity: '1' }, + ]); + expect(salesDocumentFlowService.completePacking).toHaveBeenCalledWith( + 'ps-1', + 'user-1', + [{ productId: 'prd-1', quantity: '1' }], + ); + }); }); diff --git a/src/modules/sales/packing-slips/packing-slips.service.ts b/src/modules/sales/packing-slips/packing-slips.service.ts index d342407..afe5609 100644 --- a/src/modules/sales/packing-slips/packing-slips.service.ts +++ b/src/modules/sales/packing-slips/packing-slips.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, + Inject, Injectable, NotFoundException, + forwardRef, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; import { @@ -25,9 +27,12 @@ import { isValidDocumentNotes, isValidLatitude, isValidLongitude, + isAllowedStatusTransition, parseCsvRecord, PACKING_SLIP_STATUSES, + PACKING_SLIP_USER_TRANSITIONS, } from '../shared/sales-fields'; +import { SalesDocumentFlowService } from '../shared/sales-document-flow.service'; import type { CreatePackingSlipInput, PackingSlip, @@ -63,7 +68,10 @@ export class PackingSlipsService { private readonly packingSlipsRepository: PackingSlipsRepository, private readonly customersService: CustomersService, private readonly productsService: ProductsService, + @Inject(forwardRef(() => SalesOrdersService)) private readonly salesOrdersService: SalesOrdersService, + @Inject(forwardRef(() => SalesDocumentFlowService)) + private readonly salesDocumentFlowService: SalesDocumentFlowService, ) {} async list( @@ -182,8 +190,37 @@ export class PackingSlipsService { id: string, statusRaw: string, userId: string, + products?: SalesLineBody[], ): Promise> { + const current = await this.packingSlipsRepository.findById(id); + if (!current) { + throw new NotFoundException('Packing slip not found'); + } const status = this.assertStatus(statusRaw); + if ( + !isAllowedStatusTransition( + current.status.value, + status.value, + PACKING_SLIP_USER_TRANSITIONS, + ) + ) { + throw new BadRequestException('Invalid status transition'); + } + if (status.value === 'completed') { + return this.salesDocumentFlowService.completePacking( + id, + userId, + products, + ); + } + if (status.value === 'cancelled' && current.salesOrderId) { + await this.salesOrdersService.updateStatus( + current.salesOrderId, + 'cancelled', + userId, + ); + return this.findById(id); + } const updated = await this.packingSlipsRepository.updateStatus( id, status, @@ -197,13 +234,10 @@ export class PackingSlipsService { statusRaw: string, userId: string, ): Promise<{ updated: number }> { - const status = this.assertStatus(statusRaw); - const updated = await this.packingSlipsRepository.bulkUpdateStatus( - ids, - status, - userId, - ); - return { updated }; + for (const id of ids) { + await this.updateStatus(id, statusRaw, userId); + } + return { updated: ids.length }; } async delete(id: string): Promise { diff --git a/src/modules/sales/sales-invoices/sales-invoices.module.ts b/src/modules/sales/sales-invoices/sales-invoices.module.ts index 237f10d..b28c572 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.module.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { BranchesModule } from '../../configuration/branches/branches.module'; import { CustomersModule } from '../../configuration/customers/customers.module'; import { DivisionsModule } from '../../configuration/divisions/divisions.module'; @@ -7,6 +7,7 @@ import { ProductsModule } from '../../configuration/products/products.module'; import { PackingSlipsModule } from '../packing-slips/packing-slips.module'; import { SalesOrdersModule } from '../sales-orders/sales-orders.module'; import { DocumentCodeService } from '../shared/document-code.service'; +import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module'; import { SalesInvoicesReadController } from './sales-invoices-read.controller'; import { SalesInvoicesWriteController } from './sales-invoices-write.controller'; import { SalesInvoicesRepository } from './sales-invoices.repository'; @@ -19,8 +20,9 @@ import { SalesInvoicesService } from './sales-invoices.service'; DivisionsModule, CustomersModule, ProductsModule, - SalesOrdersModule, - PackingSlipsModule, + forwardRef(() => SalesOrdersModule), + forwardRef(() => PackingSlipsModule), + forwardRef(() => SalesDocumentFlowModule), ], controllers: [SalesInvoicesReadController, SalesInvoicesWriteController], providers: [ @@ -28,6 +30,6 @@ import { SalesInvoicesService } from './sales-invoices.service'; SalesInvoicesRepository, SalesInvoicesService, ], - exports: [SalesInvoicesService, DocumentCodeService], + exports: [SalesInvoicesService, SalesInvoicesRepository, DocumentCodeService], }) export class SalesInvoicesModule {} diff --git a/src/modules/sales/sales-invoices/sales-invoices.repository.ts b/src/modules/sales/sales-invoices/sales-invoices.repository.ts index 3ed5ffd..cff176e 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.repository.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.repository.ts @@ -18,6 +18,7 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; +import { planInvoices, plans } from '../../../database/plans-table'; import { salesInvoiceProducts, salesInvoices, @@ -102,6 +103,26 @@ export class SalesInvoicesRepository { return this.hydrateOne(row, products); } + async listBySalesOrderId(salesOrderId: string): Promise { + const rows = await this.db + .select() + .from(salesInvoices) + .where(eq(salesInvoices.salesOrderId, salesOrderId)); + return this.hydrate(rows.map((row) => this.toDomain(row, []))); + } + + async isOnSalesPlan(invoiceId: string): Promise { + const rows = await this.db + .select({ id: planInvoices.id }) + .from(planInvoices) + .innerJoin(plans, eq(plans.id, planInvoices.planId)) + .where( + and(eq(planInvoices.invoiceId, invoiceId), eq(plans.purpose, 'sales')), + ) + .limit(1); + return rows.length > 0; + } + async create(input: CreateSalesInvoiceInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts index 1e69307..574c212 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts @@ -31,6 +31,7 @@ describe('SalesInvoicesService', () => { | 'bulkDelete' | 'computeTotals' | 'refreshStoredBalance' + | 'isOnSalesPlan' > > = { list: jest.fn(), @@ -44,6 +45,7 @@ describe('SalesInvoicesService', () => { bulkDelete: jest.fn(), computeTotals: jest.fn(), refreshStoredBalance: jest.fn(), + isOnSalesPlan: jest.fn(), }; const employeesService = { findById: jest.fn() }; const branchesService = { findById: jest.fn() }; @@ -210,6 +212,31 @@ describe('SalesInvoicesService', () => { expect(result.status).toBe('partial'); }); + it('applyPaymentEffects reverts to draft when paid is zero', async () => { + repository.findById.mockResolvedValue({ + ...sample, + status: Status.create('partial', SALES_INVOICE_STATUSES), + }); + repository.computeTotals.mockResolvedValue({ + total: Decimal.create('25000'), + paid: Decimal.create('0'), + balance: Decimal.create('25000'), + }); + repository.refreshStoredBalance.mockResolvedValue(sample); + repository.isOnSalesPlan.mockResolvedValue(false); + repository.updateStatus.mockResolvedValue({ + ...sample, + status: Status.create('draft', SALES_INVOICE_STATUSES), + }); + const result = await service.applyPaymentEffects('si-1', 'user-1'); + expect(repository.updateStatus).toHaveBeenCalledWith( + 'si-1', + expect.objectContaining({ value: 'draft' }), + 'user-1', + ); + expect(result.status).toBe('draft'); + }); + it('applyPaymentEffects rejects overpayment', async () => { repository.findById.mockResolvedValue(sample); repository.computeTotals.mockResolvedValue({ diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.ts b/src/modules/sales/sales-invoices/sales-invoices.service.ts index da759d6..840c36b 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, + Inject, Injectable, NotFoundException, + forwardRef, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; import { @@ -26,8 +28,10 @@ import { SalesOrdersService } from '../sales-orders/sales-orders.service'; import { isValidDocumentCode, isValidDocumentNotes, + isAllowedStatusTransition, parseCsvRecord, SALES_INVOICE_STATUSES, + SALES_INVOICE_USER_TRANSITIONS, } from '../shared/sales-fields'; import type { CreateSalesInvoiceInput, @@ -77,7 +81,9 @@ export class SalesInvoicesService { private readonly divisionsService: DivisionsService, private readonly customersService: CustomersService, private readonly productsService: ProductsService, + @Inject(forwardRef(() => SalesOrdersService)) private readonly salesOrdersService: SalesOrdersService, + @Inject(forwardRef(() => PackingSlipsService)) private readonly packingSlipsService: PackingSlipsService, ) {} @@ -203,7 +209,21 @@ export class SalesInvoicesService { statusRaw: string, userId: string, ): Promise> { + const current = await this.salesInvoicesRepository.findById(id); + if (!current) { + throw new NotFoundException('Sales invoice not found'); + } const status = this.assertStatus(statusRaw); + if ( + current.salesOrderId || + !isAllowedStatusTransition( + current.status.value, + status.value, + SALES_INVOICE_USER_TRANSITIONS, + ) + ) { + throw new BadRequestException('Invalid status transition'); + } const updated = await this.salesInvoicesRepository.updateStatus( id, status, @@ -212,6 +232,20 @@ export class SalesInvoicesService { return this.toDetail(updated); } + async markDraftsProcessed(ids: string[], userId: string): Promise { + for (const id of ids) { + const invoice = await this.salesInvoicesRepository.findById(id); + if (!invoice || invoice.status.value !== 'draft') { + continue; + } + await this.salesInvoicesRepository.updateStatus( + id, + Status.create('processed', SALES_INVOICE_STATUSES), + userId, + ); + } + } + async applyPaymentEffects( invoiceId: string, userId: string, @@ -225,11 +259,21 @@ export class SalesInvoicesService { throw new BadRequestException('Payment exceeds invoice total'); } await this.salesInvoicesRepository.refreshStoredBalance(invoiceId); + if (invoice.status.value === 'cancelled') { + return this.findById(invoiceId); + } + let nextStatus: string; if (totals.paid.isZero()) { + const onPlan = + await this.salesInvoicesRepository.isOnSalesPlan(invoiceId); + nextStatus = onPlan ? 'processed' : 'draft'; + } else { + nextStatus = + totals.paid.compare(totals.total) >= 0 ? 'completed' : 'partial'; + } + if (nextStatus === invoice.status.value) { return this.findById(invoiceId); } - const nextStatus = - totals.paid.compare(totals.total) >= 0 ? 'completed' : 'partial'; const updated = await this.salesInvoicesRepository.updateStatus( invoiceId, Status.create(nextStatus, SALES_INVOICE_STATUSES), @@ -247,13 +291,10 @@ export class SalesInvoicesService { statusRaw: string, userId: string, ): Promise<{ updated: number }> { - const status = this.assertStatus(statusRaw); - const updated = await this.salesInvoicesRepository.bulkUpdateStatus( - ids, - status, - userId, - ); - return { updated }; + for (const id of ids) { + await this.updateStatus(id, statusRaw, userId); + } + return { updated: ids.length }; } async delete(id: string): Promise { diff --git a/src/modules/sales/sales-orders/dto/sales-order.dto.ts b/src/modules/sales/sales-orders/dto/sales-order.dto.ts index 4fbd2c8..d3de7ff 100644 --- a/src/modules/sales/sales-orders/dto/sales-order.dto.ts +++ b/src/modules/sales/sales-orders/dto/sales-order.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, + IsBoolean, IsIn, IsNotEmpty, IsNumber, @@ -213,6 +214,11 @@ export class UpdateSalesOrderStatusDto { @ApiProperty({ enum: SALES_ORDER_STATUSES }) @IsIn([...SALES_ORDER_STATUSES]) status!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + generatePackingSlip?: boolean; } export class BulkIdsDto { @@ -233,6 +239,11 @@ export class BulkStatusDto { @ApiProperty({ enum: SALES_ORDER_STATUSES }) @IsIn([...SALES_ORDER_STATUSES]) status!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + generatePackingSlip?: boolean; } export class ListSalesOrdersQueryDto extends PaginationQueryDto { @@ -307,4 +318,8 @@ export class SalesOrderDto { createdBy!: UserRelationDto; @ApiProperty({ type: UserRelationDto }) updatedBy!: UserRelationDto; + @ApiPropertyOptional({ type: [String] }) + packingSlipIds?: string[]; + @ApiPropertyOptional({ type: [String] }) + invoiceIds?: string[]; } diff --git a/src/modules/sales/sales-orders/sales-orders-write.controller.ts b/src/modules/sales/sales-orders/sales-orders-write.controller.ts index 5bf94f6..488f1ab 100644 --- a/src/modules/sales/sales-orders/sales-orders-write.controller.ts +++ b/src/modules/sales/sales-orders/sales-orders-write.controller.ts @@ -114,6 +114,7 @@ export class SalesOrdersWriteController { dto.ids, dto.status, userId, + { generatePackingSlip: dto.generatePackingSlip }, ); } @@ -158,7 +159,9 @@ export class SalesOrdersWriteController { @Body() dto: UpdateSalesOrderStatusDto, @CurrentUser('id') userId: string, ): Promise { - return this.salesOrdersService.updateStatus(id, dto.status, userId); + return this.salesOrdersService.updateStatus(id, dto.status, userId, { + generatePackingSlip: dto.generatePackingSlip, + }); } @Patch(':id') diff --git a/src/modules/sales/sales-orders/sales-orders.module.ts b/src/modules/sales/sales-orders/sales-orders.module.ts index 07c69f8..a71ec64 100644 --- a/src/modules/sales/sales-orders/sales-orders.module.ts +++ b/src/modules/sales/sales-orders/sales-orders.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { BranchesModule } from '../../configuration/branches/branches.module'; import { CustomersModule } from '../../configuration/customers/customers.module'; import { DivisionsModule } from '../../configuration/divisions/divisions.module'; @@ -6,6 +6,7 @@ import { EmployeesModule } from '../../configuration/employees/employees.module' import { ProductsModule } from '../../configuration/products/products.module'; import { SalesRequestsModule } from '../sales-requests/sales-requests.module'; import { DocumentCodeService } from '../shared/document-code.service'; +import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module'; import { SalesOrdersReadController } from './sales-orders-read.controller'; import { SalesOrdersWriteController } from './sales-orders-write.controller'; import { SalesOrdersRepository } from './sales-orders.repository'; @@ -19,9 +20,10 @@ import { SalesOrdersService } from './sales-orders.service'; CustomersModule, ProductsModule, SalesRequestsModule, + forwardRef(() => SalesDocumentFlowModule), ], controllers: [SalesOrdersReadController, SalesOrdersWriteController], providers: [DocumentCodeService, SalesOrdersRepository, SalesOrdersService], - exports: [SalesOrdersService, DocumentCodeService], + exports: [SalesOrdersService, SalesOrdersRepository, DocumentCodeService], }) export class SalesOrdersModule {} diff --git a/src/modules/sales/sales-orders/sales-orders.service.spec.ts b/src/modules/sales/sales-orders/sales-orders.service.spec.ts index 59e0cd1..6a21771 100644 --- a/src/modules/sales/sales-orders/sales-orders.service.spec.ts +++ b/src/modules/sales/sales-orders/sales-orders.service.spec.ts @@ -9,6 +9,7 @@ import { DivisionsService } from '../../configuration/divisions/divisions.servic import { EmployeesService } from '../../configuration/employees/employees.service'; import { ProductsService } from '../../configuration/products/products.service'; import { SalesRequestsService } from '../sales-requests/sales-requests.service'; +import { SalesDocumentFlowService } from '../shared/sales-document-flow.service'; import { SALES_ORDER_STATUSES } from '../shared/sales-fields'; import type { SalesOrder } from './sales-order'; import { SalesOrdersRepository } from './sales-orders.repository'; @@ -46,6 +47,14 @@ describe('SalesOrdersService', () => { const customersService = { findById: jest.fn() }; const productsService = { findById: jest.fn() }; const salesRequestsService = { findById: jest.fn() }; + const salesDocumentFlowService = { + relatedDocumentIds: jest.fn().mockResolvedValue({ + packingSlipIds: [], + invoiceIds: [], + }), + onOrderProcessed: jest.fn(), + cancelOrderDocuments: jest.fn(), + }; const now = DateTime.fromUnixMs(1_700_000_000_000); const sample: SalesOrder = { @@ -116,6 +125,10 @@ describe('SalesOrdersService', () => { { provide: CustomersService, useValue: customersService }, { provide: ProductsService, useValue: productsService }, { provide: SalesRequestsService, useValue: salesRequestsService }, + { + provide: SalesDocumentFlowService, + useValue: salesDocumentFlowService, + }, ], }).compile(); service = moduleRef.get(SalesOrdersService); @@ -150,13 +163,33 @@ describe('SalesOrdersService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it('updateStatus uses the sales-request allow-list', async () => { - repository.updateStatus.mockResolvedValue(sample); + it('updateStatus uses the sales-order allow-list and processes the order', async () => { + repository.findById.mockResolvedValue(sample); + repository.updateStatus.mockResolvedValue({ + ...sample, + status: Status.create('processed', SALES_ORDER_STATUSES), + }); await service.updateStatus('sr-1', 'processed', 'user-1'); expect(repository.updateStatus).toHaveBeenCalledWith( 'sr-1', expect.objectContaining({ value: 'processed' }), 'user-1', ); + expect(salesDocumentFlowService.onOrderProcessed).toHaveBeenCalledWith( + 'sr-1', + 'user-1', + true, + ); + }); + + it('rejects completing a sales order via user status', async () => { + repository.findById.mockResolvedValue({ + ...sample, + status: Status.create('processed', SALES_ORDER_STATUSES), + }); + await expect( + service.updateStatus('sr-1', 'completed', 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.updateStatus).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/sales/sales-orders/sales-orders.service.ts b/src/modules/sales/sales-orders/sales-orders.service.ts index b8f3b4f..6aefd3e 100644 --- a/src/modules/sales/sales-orders/sales-orders.service.ts +++ b/src/modules/sales/sales-orders/sales-orders.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, + Inject, Injectable, NotFoundException, + forwardRef, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; import { @@ -30,9 +32,13 @@ import { isValidImageUrl, isValidLatitude, isValidLongitude, + isAllowedStatusTransition, parseCsvRecord, SALES_ORDER_STATUSES, + SALES_ORDER_SYSTEM_TRANSITIONS, + SALES_ORDER_USER_TRANSITIONS, } from '../shared/sales-fields'; +import { SalesDocumentFlowService } from '../shared/sales-document-flow.service'; import type { CreateSalesOrderInput, SalesOrder, @@ -87,6 +93,8 @@ export class SalesOrdersService { private readonly customersService: CustomersService, private readonly productsService: ProductsService, private readonly salesRequestsService: SalesRequestsService, + @Inject(forwardRef(() => SalesDocumentFlowService)) + private readonly salesDocumentFlowService: SalesDocumentFlowService, ) {} async list( @@ -114,7 +122,7 @@ export class SalesOrdersService { async findById( id: string, - ): Promise> { + ): Promise>> { const found = await this.salesOrdersRepository.findById(id); if (!found) { throw new NotFoundException('Sales order not found'); @@ -138,7 +146,7 @@ export class SalesOrdersService { images?: SalesImageBody[]; status?: string; userId: string; - }): Promise> { + }): Promise>> { const merged = await this.mergeFromSalesRequest(input); await this.assertRelations(merged); const created = await this.salesOrdersRepository.create( @@ -165,7 +173,7 @@ export class SalesOrdersService { status?: unknown; userId: string; }, - ): Promise> { + ): Promise>> { if (input.status !== undefined) { throw new BadRequestException('status cannot be updated via PATCH'); } @@ -209,13 +217,66 @@ export class SalesOrdersService { id: string, statusRaw: string, userId: string, - ): Promise> { + options?: { generatePackingSlip?: boolean }, + ): Promise>> { + const current = await this.salesOrdersRepository.findById(id); + if (!current) { + throw new NotFoundException('Sales order not found'); + } const status = this.assertStatus(statusRaw); + if ( + !isAllowedStatusTransition( + current.status.value, + status.value, + SALES_ORDER_USER_TRANSITIONS, + ) + ) { + throw new BadRequestException('Invalid status transition'); + } const updated = await this.salesOrdersRepository.updateStatus( id, status, userId, ); + if (status.value === 'processed') { + await this.salesDocumentFlowService.onOrderProcessed( + id, + userId, + options?.generatePackingSlip !== false, + ); + } + if (status.value === 'cancelled') { + await this.salesDocumentFlowService.cancelOrderDocuments(id, userId); + } + const reloaded = await this.salesOrdersRepository.findById(id); + return this.toDetail(reloaded ?? updated); + } + + async completeFromSystem( + id: string, + userId: string, + ): Promise>> { + const current = await this.salesOrdersRepository.findById(id); + if (!current) { + throw new NotFoundException('Sales order not found'); + } + if (current.status.value === 'completed') { + return this.toDetail(current); + } + if ( + !isAllowedStatusTransition( + current.status.value, + 'completed', + SALES_ORDER_SYSTEM_TRANSITIONS, + ) + ) { + throw new BadRequestException('Invalid status transition'); + } + const updated = await this.salesOrdersRepository.updateStatus( + id, + Status.create('completed', SALES_ORDER_STATUSES), + userId, + ); return this.toDetail(updated); } @@ -223,14 +284,12 @@ export class SalesOrdersService { ids: string[], statusRaw: string, userId: string, + options?: { generatePackingSlip?: boolean }, ): Promise<{ updated: number }> { - const status = this.assertStatus(statusRaw); - const updated = await this.salesOrdersRepository.bulkUpdateStatus( - ids, - status, - userId, - ); - return { updated }; + for (const id of ids) { + await this.updateStatus(id, statusRaw, userId, options); + } + return { updated: ids.length }; } async delete(id: string): Promise { @@ -331,7 +390,10 @@ export class SalesOrdersService { }; } - toDetail(item: SalesOrder) { + async toDetail(item: SalesOrder) { + const related = await this.salesDocumentFlowService.relatedDocumentIds( + item.id, + ); return { ...this.toListItem(item), products: item.products.map((line) => ({ @@ -345,6 +407,8 @@ export class SalesOrdersService { url: image.url, description: image.description, })), + packingSlipIds: related.packingSlipIds, + invoiceIds: related.invoiceIds, }; } diff --git a/src/modules/sales/sales-payments/sales-payments.service.spec.ts b/src/modules/sales/sales-payments/sales-payments.service.spec.ts index 9708375..5a713b9 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.spec.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.spec.ts @@ -106,7 +106,10 @@ describe('SalesPaymentsService', () => { }); it('approving a payment recomputes referenced invoices', async () => { - repository.findById.mockResolvedValue(sample); + repository.findById.mockResolvedValue({ + ...sample, + status: Status.create('pending', SALES_PAYMENT_STATUSES), + }); salesInvoicesService.getTotals.mockResolvedValue({ total: Decimal.create('25000'), paid: Decimal.create('0'), @@ -124,7 +127,10 @@ describe('SalesPaymentsService', () => { }); it('rejects approve when allocation would exceed the invoice total', async () => { - repository.findById.mockResolvedValue(sample); + repository.findById.mockResolvedValue({ + ...sample, + status: Status.create('pending', SALES_PAYMENT_STATUSES), + }); salesInvoicesService.getTotals.mockResolvedValue({ total: Decimal.create('5000'), paid: Decimal.create('0'), diff --git a/src/modules/sales/sales-payments/sales-payments.service.ts b/src/modules/sales/sales-payments/sales-payments.service.ts index 17f69e7..bb110c1 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.ts @@ -20,8 +20,10 @@ import { isValidDocumentNotes, isValidImageDescription, isValidImageUrl, + isAllowedStatusTransition, parseCsvRecord, SALES_PAYMENT_STATUSES, + SALES_PAYMENT_USER_TRANSITIONS, } from '../shared/sales-fields'; import type { CreateSalesPaymentInput, @@ -152,6 +154,15 @@ export class SalesPaymentsService { if (!existing) { throw new NotFoundException('Sales payment not found'); } + if ( + !isAllowedStatusTransition( + existing.status.value, + next.value, + SALES_PAYMENT_USER_TRANSITIONS, + ) + ) { + throw new BadRequestException('Invalid status transition'); + } const becomingApproved = next.value === 'approved' && existing.status.value !== 'approved'; const leavingApproved = @@ -180,13 +191,10 @@ export class SalesPaymentsService { statusRaw: string, userId: string, ): Promise<{ updated: number }> { - const status = this.assertStatus(statusRaw); - const updated = await this.salesPaymentsRepository.bulkUpdateStatus( - ids, - status, - userId, - ); - return { updated }; + for (const id of ids) { + await this.updateStatus(id, statusRaw, userId); + } + return { updated: ids.length }; } async delete(id: string): Promise { diff --git a/src/modules/sales/shared/sales-document-flow.module.ts b/src/modules/sales/shared/sales-document-flow.module.ts new file mode 100644 index 0000000..575310a --- /dev/null +++ b/src/modules/sales/shared/sales-document-flow.module.ts @@ -0,0 +1,16 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { PackingSlipsModule } from '../packing-slips/packing-slips.module'; +import { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module'; +import { SalesOrdersModule } from '../sales-orders/sales-orders.module'; +import { SalesDocumentFlowService } from './sales-document-flow.service'; + +@Module({ + imports: [ + forwardRef(() => SalesOrdersModule), + forwardRef(() => PackingSlipsModule), + forwardRef(() => SalesInvoicesModule), + ], + providers: [SalesDocumentFlowService], + exports: [SalesDocumentFlowService], +}) +export class SalesDocumentFlowModule {} diff --git a/src/modules/sales/shared/sales-document-flow.service.spec.ts b/src/modules/sales/shared/sales-document-flow.service.spec.ts new file mode 100644 index 0000000..204c3b2 --- /dev/null +++ b/src/modules/sales/shared/sales-document-flow.service.spec.ts @@ -0,0 +1,176 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Decimal } from '../../../common/value-objects/decimal/decimal'; +import { Status } from '../../../common/value-objects/status/status'; +import { PackingSlipsRepository } from '../packing-slips/packing-slips.repository'; +import { PackingSlipsService } from '../packing-slips/packing-slips.service'; +import type { PackingSlip } from '../packing-slips/packing-slip'; +import { SalesInvoicesRepository } from '../sales-invoices/sales-invoices.repository'; +import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service'; +import { SalesOrdersService } from '../sales-orders/sales-orders.service'; +import { PACKING_SLIP_STATUSES } from './sales-fields'; +import { SalesDocumentFlowService } from './sales-document-flow.service'; + +describe('SalesDocumentFlowService', () => { + let service: SalesDocumentFlowService; + const salesOrdersService = { + completeFromSystem: jest.fn(), + }; + const packingSlipsService = { + create: jest.fn(), + findById: jest.fn(), + }; + const salesInvoicesService = { + create: jest.fn(), + }; + const packingSlipsRepository = { + findById: jest.fn(), + listBySalesOrderId: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + }; + const salesInvoicesRepository = { + listBySalesOrderId: jest.fn(), + updateStatus: jest.fn(), + }; + + const now = DateTime.fromUnixMs(1_700_000_000_000); + const packing: PackingSlip = { + id: 'ps-1', + code: 'PS-1', + salesOrderId: 'so-1', + salesOrderNumber: 'SO-1', + date: now, + customerId: 'cus-1', + address: 'Jl Sudirman 1', + latitude: null, + longitude: null, + notes: null, + products: [ + { + id: 'line-1', + productId: 'prd-1', + product: { id: 'prd-1', code: 'P1', name: 'Fuel' }, + quantity: Decimal.create('10'), + price: Decimal.create('12500'), + }, + ], + status: Status.create('processed', PACKING_SLIP_STATUSES), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + salesOrder: { id: 'so-1', code: 'SO-1' }, + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + salesInvoicesRepository.listBySalesOrderId.mockResolvedValue([]); + packingSlipsRepository.listBySalesOrderId.mockResolvedValue([]); + packingSlipsRepository.findById.mockResolvedValue(packing); + packingSlipsService.findById.mockResolvedValue({ + id: 'ps-1', + status: 'completed', + }); + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + SalesDocumentFlowService, + { provide: SalesOrdersService, useValue: salesOrdersService }, + { provide: PackingSlipsService, useValue: packingSlipsService }, + { provide: SalesInvoicesService, useValue: salesInvoicesService }, + { provide: PackingSlipsRepository, useValue: packingSlipsRepository }, + { provide: SalesInvoicesRepository, useValue: salesInvoicesRepository }, + ], + }).compile(); + service = moduleRef.get(SalesDocumentFlowService); + }); + + it('process generates an invoice and a processed packing slip', async () => { + await service.onOrderProcessed('so-1', 'user-1', true); + expect(salesInvoicesService.create).toHaveBeenCalledWith({ + salesOrderId: 'so-1', + userId: 'user-1', + }); + expect(packingSlipsService.create).toHaveBeenCalledWith({ + salesOrderId: 'so-1', + status: 'processed', + userId: 'user-1', + }); + expect(salesOrdersService.completeFromSystem).not.toHaveBeenCalled(); + }); + + it('process without packing completes the order after the invoice', async () => { + await service.onOrderProcessed('so-1', 'user-1', false); + expect(salesInvoicesService.create).toHaveBeenCalled(); + expect(packingSlipsService.create).not.toHaveBeenCalled(); + expect(salesOrdersService.completeFromSystem).toHaveBeenCalledWith( + 'so-1', + 'user-1', + ); + }); + + it('rejects process when an invoice already exists', async () => { + salesInvoicesRepository.listBySalesOrderId.mockResolvedValue([ + { id: 'si-1' }, + ]); + await expect( + service.onOrderProcessed('so-1', 'user-1', true), + ).rejects.toBeInstanceOf(BadRequestException); + expect(salesInvoicesService.create).not.toHaveBeenCalled(); + }); + + it('partial packing complete writes remainder onto a new processed slip', async () => { + packingSlipsRepository.listBySalesOrderId.mockResolvedValue([ + { ...packing, status: Status.create('completed', PACKING_SLIP_STATUSES) }, + { + ...packing, + id: 'ps-2', + status: Status.create('processed', PACKING_SLIP_STATUSES), + }, + ]); + await service.completePacking('ps-1', 'user-1', [ + { productId: 'prd-1', quantity: '4' }, + ]); + expect(packingSlipsRepository.update).toHaveBeenCalledWith( + 'ps-1', + expect.objectContaining({ + products: [ + expect.objectContaining({ + productId: 'prd-1', + quantity: expect.objectContaining({ value: '4.0000' }), + }), + ], + }), + ); + expect(packingSlipsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + salesOrderId: 'so-1', + status: 'processed', + products: [ + expect.objectContaining({ + productId: 'prd-1', + quantity: '6.0000', + }), + ], + }), + ); + expect(salesOrdersService.completeFromSystem).not.toHaveBeenCalled(); + }); + + it('full packing complete completes the order when no open packing remains', async () => { + packingSlipsRepository.listBySalesOrderId.mockResolvedValue([ + { ...packing, status: Status.create('completed', PACKING_SLIP_STATUSES) }, + ]); + await service.completePacking('ps-1', 'user-1'); + expect(packingSlipsRepository.update).not.toHaveBeenCalled(); + expect(packingSlipsService.create).not.toHaveBeenCalled(); + expect(salesOrdersService.completeFromSystem).toHaveBeenCalledWith( + 'so-1', + 'user-1', + ); + }); +}); diff --git a/src/modules/sales/shared/sales-document-flow.service.ts b/src/modules/sales/shared/sales-document-flow.service.ts new file mode 100644 index 0000000..6490c0c --- /dev/null +++ b/src/modules/sales/shared/sales-document-flow.service.ts @@ -0,0 +1,220 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, + forwardRef, +} from '@nestjs/common'; +import { Decimal } from '../../../common/value-objects/decimal/decimal'; +import { Status } from '../../../common/value-objects/status/status'; +import { PackingSlipsRepository } from '../packing-slips/packing-slips.repository'; +import { PackingSlipsService } from '../packing-slips/packing-slips.service'; +import type { PackingSlip } from '../packing-slips/packing-slip'; +import { SalesInvoicesRepository } from '../sales-invoices/sales-invoices.repository'; +import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service'; +import { SalesOrdersService } from '../sales-orders/sales-orders.service'; +import { PACKING_SLIP_STATUSES, SALES_INVOICE_STATUSES } from './sales-fields'; + +export type DeliveredLineBody = { + readonly productId: string; + readonly quantity: string; +}; + +@Injectable() +export class SalesDocumentFlowService { + constructor( + @Inject(forwardRef(() => SalesOrdersService)) + private readonly salesOrdersService: SalesOrdersService, + @Inject(forwardRef(() => PackingSlipsService)) + private readonly packingSlipsService: PackingSlipsService, + @Inject(forwardRef(() => SalesInvoicesService)) + private readonly salesInvoicesService: SalesInvoicesService, + private readonly packingSlipsRepository: PackingSlipsRepository, + private readonly salesInvoicesRepository: SalesInvoicesRepository, + ) {} + + async relatedDocumentIds(orderId: string): Promise<{ + packingSlipIds: string[]; + invoiceIds: string[]; + }> { + const [packing, invoices] = await Promise.all([ + this.packingSlipsRepository.listBySalesOrderId(orderId), + this.salesInvoicesRepository.listBySalesOrderId(orderId), + ]); + return { + packingSlipIds: packing.map((item) => item.id), + invoiceIds: invoices.map((item) => item.id), + }; + } + + async onOrderProcessed( + orderId: string, + userId: string, + generatePackingSlip: boolean, + ): Promise { + const existing = + await this.salesInvoicesRepository.listBySalesOrderId(orderId); + if (existing.length > 0) { + throw new BadRequestException('Sales order already has an invoice'); + } + await this.salesInvoicesService.create({ + salesOrderId: orderId, + userId, + }); + if (generatePackingSlip) { + await this.packingSlipsService.create({ + salesOrderId: orderId, + status: 'processed', + userId, + }); + return; + } + await this.salesOrdersService.completeFromSystem(orderId, userId); + } + + async cancelOrderDocuments(orderId: string, userId: string): Promise { + const [packing, invoices] = await Promise.all([ + this.packingSlipsRepository.listBySalesOrderId(orderId), + this.salesInvoicesRepository.listBySalesOrderId(orderId), + ]); + for (const slip of packing) { + if (slip.status.value !== 'cancelled') { + await this.packingSlipsRepository.updateStatus( + slip.id, + Status.create('cancelled', PACKING_SLIP_STATUSES), + userId, + ); + } + } + for (const invoice of invoices) { + if (invoice.status.value !== 'cancelled') { + await this.salesInvoicesRepository.updateStatus( + invoice.id, + Status.create('cancelled', SALES_INVOICE_STATUSES), + userId, + ); + } + } + } + + async completePacking( + id: string, + userId: string, + products?: DeliveredLineBody[], + ): Promise> { + const packing = await this.packingSlipsRepository.findById(id); + if (!packing) { + throw new NotFoundException('Packing slip not found'); + } + const delivered = this.splitDelivered(packing, products); + if (delivered.remaining.length > 0) { + await this.packingSlipsRepository.update(id, { + products: delivered.delivered, + userId, + }); + } + await this.packingSlipsRepository.updateStatus( + id, + Status.create('completed', PACKING_SLIP_STATUSES), + userId, + ); + if (delivered.remaining.length > 0) { + await this.packingSlipsService.create({ + salesOrderId: packing.salesOrderId ?? undefined, + date: packing.date.format(), + customerId: packing.customerId, + address: packing.address, + latitude: packing.latitude, + longitude: packing.longitude, + notes: packing.notes, + products: delivered.remaining.map((line) => ({ + productId: line.productId, + quantity: line.quantity.value, + price: line.price.value, + })), + status: 'processed', + userId, + }); + } + if (packing.salesOrderId) { + await this.completeOrderIfPackingDone(packing.salesOrderId, userId); + } + return this.packingSlipsService.findById(id); + } + + async completeOrderIfPackingDone( + orderId: string, + userId: string, + ): Promise { + const slips = await this.packingSlipsRepository.listBySalesOrderId(orderId); + const blocking = slips.filter( + (item) => + item.status.value !== 'cancelled' && item.status.value !== 'completed', + ); + if (blocking.length === 0) { + await this.salesOrdersService.completeFromSystem(orderId, userId); + } + } + + private splitDelivered( + packing: PackingSlip, + products?: DeliveredLineBody[], + ): { + delivered: Array<{ + productId: string; + quantity: Decimal; + price: Decimal; + }>; + remaining: Array<{ + productId: string; + quantity: Decimal; + price: Decimal; + }>; + } { + const requested = new Map(); + for (const line of products ?? []) { + let quantity: Decimal; + try { + quantity = Decimal.create(line.quantity); + } catch { + throw new BadRequestException('Invalid delivered quantity'); + } + requested.set(line.productId, quantity); + } + for (const productId of requested.keys()) { + if (!packing.products.some((line) => line.productId === productId)) { + throw new BadRequestException('Invalid delivered quantity'); + } + } + const delivered: Array<{ + productId: string; + quantity: Decimal; + price: Decimal; + }> = []; + const remaining: Array<{ + productId: string; + quantity: Decimal; + price: Decimal; + }> = []; + for (const line of packing.products) { + const qty = requested.get(line.productId) ?? line.quantity; + if (!qty.isPositive() || qty.compare(line.quantity) > 0) { + throw new BadRequestException('Invalid delivered quantity'); + } + delivered.push({ + productId: line.productId, + quantity: qty, + price: line.price, + }); + const leftover = line.quantity.subtract(qty); + if (leftover.isPositive()) { + remaining.push({ + productId: line.productId, + quantity: leftover, + price: line.price, + }); + } + } + return { delivered, remaining }; + } +} diff --git a/src/modules/sales/shared/sales-fields.spec.ts b/src/modules/sales/shared/sales-fields.spec.ts index 1629569..c551bae 100644 --- a/src/modules/sales/shared/sales-fields.spec.ts +++ b/src/modules/sales/shared/sales-fields.spec.ts @@ -1,7 +1,13 @@ import { + isAllowedStatusTransition, isValidDocumentCode, isValidImageUrl, isValidLatitude, + PACKING_SLIP_USER_TRANSITIONS, + SALES_INVOICE_USER_TRANSITIONS, + SALES_ORDER_SYSTEM_TRANSITIONS, + SALES_ORDER_USER_TRANSITIONS, + SALES_PAYMENT_USER_TRANSITIONS, } from './sales-fields'; describe('sales fields', () => { @@ -22,4 +28,93 @@ describe('sales fields', () => { expect(isValidLatitude(-6.2)).toBe(true); expect(isValidLatitude(100)).toBe(false); }); + + it('allows sales-order user transitions draft to processed or cancelled', () => { + expect( + isAllowedStatusTransition( + 'draft', + 'processed', + SALES_ORDER_USER_TRANSITIONS, + ), + ).toBe(true); + expect( + isAllowedStatusTransition( + 'draft', + 'cancelled', + SALES_ORDER_USER_TRANSITIONS, + ), + ).toBe(true); + expect( + isAllowedStatusTransition( + 'processed', + 'completed', + SALES_ORDER_USER_TRANSITIONS, + ), + ).toBe(false); + expect( + isAllowedStatusTransition( + 'processed', + 'completed', + SALES_ORDER_SYSTEM_TRANSITIONS, + ), + ).toBe(true); + }); + + it('allows packing processed to completed or cancelled', () => { + expect( + isAllowedStatusTransition( + 'processed', + 'completed', + PACKING_SLIP_USER_TRANSITIONS, + ), + ).toBe(true); + expect( + isAllowedStatusTransition( + 'draft', + 'completed', + PACKING_SLIP_USER_TRANSITIONS, + ), + ).toBe(false); + }); + + it('limits invoice user transitions to cancelled', () => { + expect( + isAllowedStatusTransition( + 'draft', + 'processed', + SALES_INVOICE_USER_TRANSITIONS, + ), + ).toBe(false); + expect( + isAllowedStatusTransition( + 'draft', + 'cancelled', + SALES_INVOICE_USER_TRANSITIONS, + ), + ).toBe(true); + }); + + it('allows payment pending to approved, rejected, or draft', () => { + expect( + isAllowedStatusTransition( + 'draft', + 'pending', + SALES_PAYMENT_USER_TRANSITIONS, + ), + ).toBe(true); + expect( + isAllowedStatusTransition( + 'pending', + 'approved', + SALES_PAYMENT_USER_TRANSITIONS, + ), + ).toBe(true); + expect( + isAllowedStatusTransition( + 'draft', + 'approved', + SALES_PAYMENT_USER_TRANSITIONS, + ), + ).toBe(false); + }); }); diff --git a/src/modules/sales/shared/sales-fields.ts b/src/modules/sales/shared/sales-fields.ts index 548a69d..ae42549 100644 --- a/src/modules/sales/shared/sales-fields.ts +++ b/src/modules/sales/shared/sales-fields.ts @@ -47,6 +47,51 @@ export type PackingSlipStatus = (typeof PACKING_SLIP_STATUSES)[number]; export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number]; export type SalesPaymentStatus = (typeof SALES_PAYMENT_STATUSES)[number]; +export const SALES_ORDER_USER_TRANSITIONS: Record = { + draft: ['processed', 'cancelled'], + processed: ['cancelled'], + completed: [], + cancelled: [], +}; + +export const SALES_ORDER_SYSTEM_TRANSITIONS: Record = + { + processed: ['completed'], + }; + +export const PACKING_SLIP_USER_TRANSITIONS: Record = + { + draft: ['processed', 'cancelled'], + processed: ['completed', 'cancelled'], + completed: [], + cancelled: [], + }; + +export const SALES_INVOICE_USER_TRANSITIONS: Record = + { + draft: ['cancelled'], + processed: ['cancelled'], + partial: ['cancelled'], + completed: ['cancelled'], + cancelled: [], + }; + +export const SALES_PAYMENT_USER_TRANSITIONS: Record = + { + draft: ['pending'], + pending: ['approved', 'rejected', 'draft'], + approved: [], + rejected: [], + }; + +export function isAllowedStatusTransition( + current: string, + next: string, + map: Record, +): boolean { + return (map[current] ?? []).includes(next); +} + export function isValidDocumentCode(raw: string): boolean { return ( typeof raw === 'string' &&