Enhance sales document flow with new service and related functionalities

- Introduced `SalesDocumentFlowService` to manage the lifecycle of sales documents, including packing slips and invoices.
- Implemented methods for processing orders, generating invoices, and handling packing slips based on order status.
- Updated `SalesOrdersService`, `PackingSlipsService`, and `SalesInvoicesService` to integrate with the new document flow service.
- Added new methods for marking drafts processed and checking if invoices are on sales plans.
- Enhanced existing services and repositories to support new functionalities, including status transitions and related document management.
- Updated DTOs to include new fields for packing slip and invoice IDs in sales order responses.
- Added unit tests for the new service and updated existing tests to cover new functionalities.
This commit is contained in:
shancheas
2026-08-31 09:35:18 +07:00
parent 627aeac4a0
commit afed5ff0f5
24 changed files with 950 additions and 53 deletions
@@ -36,7 +36,10 @@ describe('PlansService', () => {
const employeesService = { findById: jest.fn() }; const employeesService = { findById: jest.fn() };
const branchesService = { findById: jest.fn() }; const branchesService = { findById: jest.fn() };
const customersService = { 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 packingSlipsService = { findById: jest.fn() };
const privilegesService = { checkPermission: jest.fn() }; const privilegesService = { checkPermission: jest.fn() };
+29
View File
@@ -136,6 +136,12 @@ export class PlansService {
const created = await this.plansRepository.create( const created = await this.plansRepository.create(
await this.toWritePayload(input), await this.toWritePayload(input),
); );
await this.processAttachedInvoices(
created.purpose,
[],
created.invoiceIds,
input.userId,
);
return this.toItem(created); return this.toItem(created);
} }
@@ -201,6 +207,12 @@ export class PlansService {
packingSlipIds: attachments.packingSlipIds, packingSlipIds: attachments.packingSlipIds,
userId: input.userId, userId: input.userId,
}); });
await this.processAttachedInvoices(
purpose,
existing.invoiceIds,
attachments.invoiceIds,
input.userId,
);
return this.toItem(updated); 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<void> {
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<void> { private async assertInvoices(ids: readonly string[]): Promise<void> {
for (const id of ids) { for (const id of ids) {
await this.salesInvoicesService.findById(id); await this.salesInvoicesService.findById(id);
@@ -168,6 +168,13 @@ export class UpdatePackingSlipStatusDto {
@ApiProperty({ enum: PACKING_SLIP_STATUSES }) @ApiProperty({ enum: PACKING_SLIP_STATUSES })
@IsIn([...PACKING_SLIP_STATUSES]) @IsIn([...PACKING_SLIP_STATUSES])
status!: string; status!: string;
@ApiPropertyOptional({ type: [SalesLineDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SalesLineDto)
products?: SalesLineDto[];
} }
export class BulkIdsDto { export class BulkIdsDto {
@@ -155,7 +155,12 @@ export class PackingSlipsWriteController {
@Body() dto: UpdatePackingSlipStatusDto, @Body() dto: UpdatePackingSlipStatusDto,
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
): Promise<PackingSlipDto> { ): Promise<PackingSlipDto> {
return this.packingSlipsService.updateStatus(id, dto.status, userId); return this.packingSlipsService.updateStatus(
id,
dto.status,
userId,
dto.products,
);
} }
@Patch(':id') @Patch(':id')
@@ -1,17 +1,23 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { CustomersModule } from '../../configuration/customers/customers.module'; import { CustomersModule } from '../../configuration/customers/customers.module';
import { ProductsModule } from '../../configuration/products/products.module'; import { ProductsModule } from '../../configuration/products/products.module';
import { SalesOrdersModule } from '../sales-orders/sales-orders.module'; import { SalesOrdersModule } from '../sales-orders/sales-orders.module';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
import { PackingSlipsReadController } from './packing-slips-read.controller'; import { PackingSlipsReadController } from './packing-slips-read.controller';
import { PackingSlipsWriteController } from './packing-slips-write.controller'; import { PackingSlipsWriteController } from './packing-slips-write.controller';
import { PackingSlipsRepository } from './packing-slips.repository'; import { PackingSlipsRepository } from './packing-slips.repository';
import { PackingSlipsService } from './packing-slips.service'; import { PackingSlipsService } from './packing-slips.service';
@Module({ @Module({
imports: [CustomersModule, ProductsModule, SalesOrdersModule], imports: [
CustomersModule,
ProductsModule,
forwardRef(() => SalesOrdersModule),
forwardRef(() => SalesDocumentFlowModule),
],
controllers: [PackingSlipsReadController, PackingSlipsWriteController], controllers: [PackingSlipsReadController, PackingSlipsWriteController],
providers: [DocumentCodeService, PackingSlipsRepository, PackingSlipsService], providers: [DocumentCodeService, PackingSlipsRepository, PackingSlipsService],
exports: [PackingSlipsService, DocumentCodeService], exports: [PackingSlipsService, PackingSlipsRepository, DocumentCodeService],
}) })
export class PackingSlipsModule {} export class PackingSlipsModule {}
@@ -92,6 +92,14 @@ export class PackingSlipsRepository {
return this.hydrateOne(row, products); return this.hydrateOne(row, products);
} }
async listBySalesOrderId(salesOrderId: string): Promise<PackingSlip[]> {
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<PackingSlip> { async create(input: CreatePackingSlipInput): Promise<PackingSlip> {
const now = DateTime.fromUnixMs(Date.now()); const now = DateTime.fromUnixMs(Date.now());
const status = const status =
@@ -6,6 +6,7 @@ import { Status } from '../../../common/value-objects/status/status';
import { CustomersService } from '../../configuration/customers/customers.service'; import { CustomersService } from '../../configuration/customers/customers.service';
import { ProductsService } from '../../configuration/products/products.service'; import { ProductsService } from '../../configuration/products/products.service';
import { SalesOrdersService } from '../sales-orders/sales-orders.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 { PACKING_SLIP_STATUSES } from '../shared/sales-fields';
import type { PackingSlip } from './packing-slip'; import type { PackingSlip } from './packing-slip';
import { PackingSlipsRepository } from './packing-slips.repository'; import { PackingSlipsRepository } from './packing-slips.repository';
@@ -39,7 +40,10 @@ describe('PackingSlipsService', () => {
}; };
const customersService = { findById: jest.fn() }; const customersService = { findById: jest.fn() };
const productsService = { 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 now = DateTime.fromUnixMs(1_700_000_000_000);
const sample: PackingSlip = { const sample: PackingSlip = {
@@ -95,6 +99,10 @@ describe('PackingSlipsService', () => {
{ provide: CustomersService, useValue: customersService }, { provide: CustomersService, useValue: customersService },
{ provide: ProductsService, useValue: productsService }, { provide: ProductsService, useValue: productsService },
{ provide: SalesOrdersService, useValue: salesOrdersService }, { provide: SalesOrdersService, useValue: salesOrdersService },
{
provide: SalesDocumentFlowService,
useValue: salesDocumentFlowService,
},
], ],
}).compile(); }).compile();
service = moduleRef.get(PackingSlipsService); service = moduleRef.get(PackingSlipsService);
@@ -230,7 +238,11 @@ describe('PackingSlipsService', () => {
}); });
it('updateStatus uses the packing-slip allow-list', async () => { 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'); await service.updateStatus('ps-1', 'processed', 'user-1');
expect(repository.updateStatus).toHaveBeenCalledWith( expect(repository.updateStatus).toHaveBeenCalledWith(
'ps-1', 'ps-1',
@@ -238,4 +250,23 @@ describe('PackingSlipsService', () => {
'user-1', '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' }],
);
});
}); });
@@ -1,7 +1,9 @@
import { import {
BadRequestException, BadRequestException,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; import type { PaginationResponse } from '../../../common/http/response';
import { import {
@@ -25,9 +27,12 @@ import {
isValidDocumentNotes, isValidDocumentNotes,
isValidLatitude, isValidLatitude,
isValidLongitude, isValidLongitude,
isAllowedStatusTransition,
parseCsvRecord, parseCsvRecord,
PACKING_SLIP_STATUSES, PACKING_SLIP_STATUSES,
PACKING_SLIP_USER_TRANSITIONS,
} from '../shared/sales-fields'; } from '../shared/sales-fields';
import { SalesDocumentFlowService } from '../shared/sales-document-flow.service';
import type { import type {
CreatePackingSlipInput, CreatePackingSlipInput,
PackingSlip, PackingSlip,
@@ -63,7 +68,10 @@ export class PackingSlipsService {
private readonly packingSlipsRepository: PackingSlipsRepository, private readonly packingSlipsRepository: PackingSlipsRepository,
private readonly customersService: CustomersService, private readonly customersService: CustomersService,
private readonly productsService: ProductsService, private readonly productsService: ProductsService,
@Inject(forwardRef(() => SalesOrdersService))
private readonly salesOrdersService: SalesOrdersService, private readonly salesOrdersService: SalesOrdersService,
@Inject(forwardRef(() => SalesDocumentFlowService))
private readonly salesDocumentFlowService: SalesDocumentFlowService,
) {} ) {}
async list( async list(
@@ -182,8 +190,37 @@ export class PackingSlipsService {
id: string, id: string,
statusRaw: string, statusRaw: string,
userId: string, userId: string,
products?: SalesLineBody[],
): Promise<ReturnType<PackingSlipsService['toDetail']>> { ): Promise<ReturnType<PackingSlipsService['toDetail']>> {
const current = await this.packingSlipsRepository.findById(id);
if (!current) {
throw new NotFoundException('Packing slip not found');
}
const status = this.assertStatus(statusRaw); 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( const updated = await this.packingSlipsRepository.updateStatus(
id, id,
status, status,
@@ -197,13 +234,10 @@ export class PackingSlipsService {
statusRaw: string, statusRaw: string,
userId: string, userId: string,
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw); for (const id of ids) {
const updated = await this.packingSlipsRepository.bulkUpdateStatus( await this.updateStatus(id, statusRaw, userId);
ids, }
status, return { updated: ids.length };
userId,
);
return { updated };
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { BranchesModule } from '../../configuration/branches/branches.module'; import { BranchesModule } from '../../configuration/branches/branches.module';
import { CustomersModule } from '../../configuration/customers/customers.module'; import { CustomersModule } from '../../configuration/customers/customers.module';
import { DivisionsModule } from '../../configuration/divisions/divisions.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 { PackingSlipsModule } from '../packing-slips/packing-slips.module';
import { SalesOrdersModule } from '../sales-orders/sales-orders.module'; import { SalesOrdersModule } from '../sales-orders/sales-orders.module';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
import { SalesInvoicesReadController } from './sales-invoices-read.controller'; import { SalesInvoicesReadController } from './sales-invoices-read.controller';
import { SalesInvoicesWriteController } from './sales-invoices-write.controller'; import { SalesInvoicesWriteController } from './sales-invoices-write.controller';
import { SalesInvoicesRepository } from './sales-invoices.repository'; import { SalesInvoicesRepository } from './sales-invoices.repository';
@@ -19,8 +20,9 @@ import { SalesInvoicesService } from './sales-invoices.service';
DivisionsModule, DivisionsModule,
CustomersModule, CustomersModule,
ProductsModule, ProductsModule,
SalesOrdersModule, forwardRef(() => SalesOrdersModule),
PackingSlipsModule, forwardRef(() => PackingSlipsModule),
forwardRef(() => SalesDocumentFlowModule),
], ],
controllers: [SalesInvoicesReadController, SalesInvoicesWriteController], controllers: [SalesInvoicesReadController, SalesInvoicesWriteController],
providers: [ providers: [
@@ -28,6 +30,6 @@ import { SalesInvoicesService } from './sales-invoices.service';
SalesInvoicesRepository, SalesInvoicesRepository,
SalesInvoicesService, SalesInvoicesService,
], ],
exports: [SalesInvoicesService, DocumentCodeService], exports: [SalesInvoicesService, SalesInvoicesRepository, DocumentCodeService],
}) })
export class SalesInvoicesModule {} export class SalesInvoicesModule {}
@@ -18,6 +18,7 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { planInvoices, plans } from '../../../database/plans-table';
import { import {
salesInvoiceProducts, salesInvoiceProducts,
salesInvoices, salesInvoices,
@@ -102,6 +103,26 @@ export class SalesInvoicesRepository {
return this.hydrateOne(row, products); return this.hydrateOne(row, products);
} }
async listBySalesOrderId(salesOrderId: string): Promise<SalesInvoice[]> {
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<boolean> {
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<SalesInvoice> { async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> {
const now = DateTime.fromUnixMs(Date.now()); const now = DateTime.fromUnixMs(Date.now());
const status = const status =
@@ -31,6 +31,7 @@ describe('SalesInvoicesService', () => {
| 'bulkDelete' | 'bulkDelete'
| 'computeTotals' | 'computeTotals'
| 'refreshStoredBalance' | 'refreshStoredBalance'
| 'isOnSalesPlan'
> >
> = { > = {
list: jest.fn(), list: jest.fn(),
@@ -44,6 +45,7 @@ describe('SalesInvoicesService', () => {
bulkDelete: jest.fn(), bulkDelete: jest.fn(),
computeTotals: jest.fn(), computeTotals: jest.fn(),
refreshStoredBalance: jest.fn(), refreshStoredBalance: jest.fn(),
isOnSalesPlan: jest.fn(),
}; };
const employeesService = { findById: jest.fn() }; const employeesService = { findById: jest.fn() };
const branchesService = { findById: jest.fn() }; const branchesService = { findById: jest.fn() };
@@ -210,6 +212,31 @@ describe('SalesInvoicesService', () => {
expect(result.status).toBe('partial'); 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 () => { it('applyPaymentEffects rejects overpayment', async () => {
repository.findById.mockResolvedValue(sample); repository.findById.mockResolvedValue(sample);
repository.computeTotals.mockResolvedValue({ repository.computeTotals.mockResolvedValue({
@@ -1,7 +1,9 @@
import { import {
BadRequestException, BadRequestException,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; import type { PaginationResponse } from '../../../common/http/response';
import { import {
@@ -26,8 +28,10 @@ import { SalesOrdersService } from '../sales-orders/sales-orders.service';
import { import {
isValidDocumentCode, isValidDocumentCode,
isValidDocumentNotes, isValidDocumentNotes,
isAllowedStatusTransition,
parseCsvRecord, parseCsvRecord,
SALES_INVOICE_STATUSES, SALES_INVOICE_STATUSES,
SALES_INVOICE_USER_TRANSITIONS,
} from '../shared/sales-fields'; } from '../shared/sales-fields';
import type { import type {
CreateSalesInvoiceInput, CreateSalesInvoiceInput,
@@ -77,7 +81,9 @@ export class SalesInvoicesService {
private readonly divisionsService: DivisionsService, private readonly divisionsService: DivisionsService,
private readonly customersService: CustomersService, private readonly customersService: CustomersService,
private readonly productsService: ProductsService, private readonly productsService: ProductsService,
@Inject(forwardRef(() => SalesOrdersService))
private readonly salesOrdersService: SalesOrdersService, private readonly salesOrdersService: SalesOrdersService,
@Inject(forwardRef(() => PackingSlipsService))
private readonly packingSlipsService: PackingSlipsService, private readonly packingSlipsService: PackingSlipsService,
) {} ) {}
@@ -203,7 +209,21 @@ export class SalesInvoicesService {
statusRaw: string, statusRaw: string,
userId: string, userId: string,
): Promise<ReturnType<SalesInvoicesService['toDetail']>> { ): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
const current = await this.salesInvoicesRepository.findById(id);
if (!current) {
throw new NotFoundException('Sales invoice not found');
}
const status = this.assertStatus(statusRaw); 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( const updated = await this.salesInvoicesRepository.updateStatus(
id, id,
status, status,
@@ -212,6 +232,20 @@ export class SalesInvoicesService {
return this.toDetail(updated); return this.toDetail(updated);
} }
async markDraftsProcessed(ids: string[], userId: string): Promise<void> {
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( async applyPaymentEffects(
invoiceId: string, invoiceId: string,
userId: string, userId: string,
@@ -225,11 +259,21 @@ export class SalesInvoicesService {
throw new BadRequestException('Payment exceeds invoice total'); throw new BadRequestException('Payment exceeds invoice total');
} }
await this.salesInvoicesRepository.refreshStoredBalance(invoiceId); await this.salesInvoicesRepository.refreshStoredBalance(invoiceId);
if (invoice.status.value === 'cancelled') {
return this.findById(invoiceId);
}
let nextStatus: string;
if (totals.paid.isZero()) { 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); return this.findById(invoiceId);
} }
const nextStatus =
totals.paid.compare(totals.total) >= 0 ? 'completed' : 'partial';
const updated = await this.salesInvoicesRepository.updateStatus( const updated = await this.salesInvoicesRepository.updateStatus(
invoiceId, invoiceId,
Status.create(nextStatus, SALES_INVOICE_STATUSES), Status.create(nextStatus, SALES_INVOICE_STATUSES),
@@ -247,13 +291,10 @@ export class SalesInvoicesService {
statusRaw: string, statusRaw: string,
userId: string, userId: string,
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw); for (const id of ids) {
const updated = await this.salesInvoicesRepository.bulkUpdateStatus( await this.updateStatus(id, statusRaw, userId);
ids, }
status, return { updated: ids.length };
userId,
);
return { updated };
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import { import {
ArrayNotEmpty, ArrayNotEmpty,
IsArray, IsArray,
IsBoolean,
IsIn, IsIn,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
@@ -213,6 +214,11 @@ export class UpdateSalesOrderStatusDto {
@ApiProperty({ enum: SALES_ORDER_STATUSES }) @ApiProperty({ enum: SALES_ORDER_STATUSES })
@IsIn([...SALES_ORDER_STATUSES]) @IsIn([...SALES_ORDER_STATUSES])
status!: string; status!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
generatePackingSlip?: boolean;
} }
export class BulkIdsDto { export class BulkIdsDto {
@@ -233,6 +239,11 @@ export class BulkStatusDto {
@ApiProperty({ enum: SALES_ORDER_STATUSES }) @ApiProperty({ enum: SALES_ORDER_STATUSES })
@IsIn([...SALES_ORDER_STATUSES]) @IsIn([...SALES_ORDER_STATUSES])
status!: string; status!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
generatePackingSlip?: boolean;
} }
export class ListSalesOrdersQueryDto extends PaginationQueryDto { export class ListSalesOrdersQueryDto extends PaginationQueryDto {
@@ -307,4 +318,8 @@ export class SalesOrderDto {
createdBy!: UserRelationDto; createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto }) @ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto; updatedBy!: UserRelationDto;
@ApiPropertyOptional({ type: [String] })
packingSlipIds?: string[];
@ApiPropertyOptional({ type: [String] })
invoiceIds?: string[];
} }
@@ -114,6 +114,7 @@ export class SalesOrdersWriteController {
dto.ids, dto.ids,
dto.status, dto.status,
userId, userId,
{ generatePackingSlip: dto.generatePackingSlip },
); );
} }
@@ -158,7 +159,9 @@ export class SalesOrdersWriteController {
@Body() dto: UpdateSalesOrderStatusDto, @Body() dto: UpdateSalesOrderStatusDto,
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
): Promise<SalesOrderDto> { ): Promise<SalesOrderDto> {
return this.salesOrdersService.updateStatus(id, dto.status, userId); return this.salesOrdersService.updateStatus(id, dto.status, userId, {
generatePackingSlip: dto.generatePackingSlip,
});
} }
@Patch(':id') @Patch(':id')
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { BranchesModule } from '../../configuration/branches/branches.module'; import { BranchesModule } from '../../configuration/branches/branches.module';
import { CustomersModule } from '../../configuration/customers/customers.module'; import { CustomersModule } from '../../configuration/customers/customers.module';
import { DivisionsModule } from '../../configuration/divisions/divisions.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 { ProductsModule } from '../../configuration/products/products.module';
import { SalesRequestsModule } from '../sales-requests/sales-requests.module'; import { SalesRequestsModule } from '../sales-requests/sales-requests.module';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
import { SalesOrdersReadController } from './sales-orders-read.controller'; import { SalesOrdersReadController } from './sales-orders-read.controller';
import { SalesOrdersWriteController } from './sales-orders-write.controller'; import { SalesOrdersWriteController } from './sales-orders-write.controller';
import { SalesOrdersRepository } from './sales-orders.repository'; import { SalesOrdersRepository } from './sales-orders.repository';
@@ -19,9 +20,10 @@ import { SalesOrdersService } from './sales-orders.service';
CustomersModule, CustomersModule,
ProductsModule, ProductsModule,
SalesRequestsModule, SalesRequestsModule,
forwardRef(() => SalesDocumentFlowModule),
], ],
controllers: [SalesOrdersReadController, SalesOrdersWriteController], controllers: [SalesOrdersReadController, SalesOrdersWriteController],
providers: [DocumentCodeService, SalesOrdersRepository, SalesOrdersService], providers: [DocumentCodeService, SalesOrdersRepository, SalesOrdersService],
exports: [SalesOrdersService, DocumentCodeService], exports: [SalesOrdersService, SalesOrdersRepository, DocumentCodeService],
}) })
export class SalesOrdersModule {} export class SalesOrdersModule {}
@@ -9,6 +9,7 @@ import { DivisionsService } from '../../configuration/divisions/divisions.servic
import { EmployeesService } from '../../configuration/employees/employees.service'; import { EmployeesService } from '../../configuration/employees/employees.service';
import { ProductsService } from '../../configuration/products/products.service'; import { ProductsService } from '../../configuration/products/products.service';
import { SalesRequestsService } from '../sales-requests/sales-requests.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 { SALES_ORDER_STATUSES } from '../shared/sales-fields';
import type { SalesOrder } from './sales-order'; import type { SalesOrder } from './sales-order';
import { SalesOrdersRepository } from './sales-orders.repository'; import { SalesOrdersRepository } from './sales-orders.repository';
@@ -46,6 +47,14 @@ describe('SalesOrdersService', () => {
const customersService = { findById: jest.fn() }; const customersService = { findById: jest.fn() };
const productsService = { findById: jest.fn() }; const productsService = { findById: jest.fn() };
const salesRequestsService = { 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 now = DateTime.fromUnixMs(1_700_000_000_000);
const sample: SalesOrder = { const sample: SalesOrder = {
@@ -116,6 +125,10 @@ describe('SalesOrdersService', () => {
{ provide: CustomersService, useValue: customersService }, { provide: CustomersService, useValue: customersService },
{ provide: ProductsService, useValue: productsService }, { provide: ProductsService, useValue: productsService },
{ provide: SalesRequestsService, useValue: salesRequestsService }, { provide: SalesRequestsService, useValue: salesRequestsService },
{
provide: SalesDocumentFlowService,
useValue: salesDocumentFlowService,
},
], ],
}).compile(); }).compile();
service = moduleRef.get(SalesOrdersService); service = moduleRef.get(SalesOrdersService);
@@ -150,13 +163,33 @@ describe('SalesOrdersService', () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it('updateStatus uses the sales-request allow-list', async () => { it('updateStatus uses the sales-order allow-list and processes the order', async () => {
repository.updateStatus.mockResolvedValue(sample); repository.findById.mockResolvedValue(sample);
repository.updateStatus.mockResolvedValue({
...sample,
status: Status.create('processed', SALES_ORDER_STATUSES),
});
await service.updateStatus('sr-1', 'processed', 'user-1'); await service.updateStatus('sr-1', 'processed', 'user-1');
expect(repository.updateStatus).toHaveBeenCalledWith( expect(repository.updateStatus).toHaveBeenCalledWith(
'sr-1', 'sr-1',
expect.objectContaining({ value: 'processed' }), expect.objectContaining({ value: 'processed' }),
'user-1', '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();
}); });
}); });
@@ -1,7 +1,9 @@
import { import {
BadRequestException, BadRequestException,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; import type { PaginationResponse } from '../../../common/http/response';
import { import {
@@ -30,9 +32,13 @@ import {
isValidImageUrl, isValidImageUrl,
isValidLatitude, isValidLatitude,
isValidLongitude, isValidLongitude,
isAllowedStatusTransition,
parseCsvRecord, parseCsvRecord,
SALES_ORDER_STATUSES, SALES_ORDER_STATUSES,
SALES_ORDER_SYSTEM_TRANSITIONS,
SALES_ORDER_USER_TRANSITIONS,
} from '../shared/sales-fields'; } from '../shared/sales-fields';
import { SalesDocumentFlowService } from '../shared/sales-document-flow.service';
import type { import type {
CreateSalesOrderInput, CreateSalesOrderInput,
SalesOrder, SalesOrder,
@@ -87,6 +93,8 @@ export class SalesOrdersService {
private readonly customersService: CustomersService, private readonly customersService: CustomersService,
private readonly productsService: ProductsService, private readonly productsService: ProductsService,
private readonly salesRequestsService: SalesRequestsService, private readonly salesRequestsService: SalesRequestsService,
@Inject(forwardRef(() => SalesDocumentFlowService))
private readonly salesDocumentFlowService: SalesDocumentFlowService,
) {} ) {}
async list( async list(
@@ -114,7 +122,7 @@ export class SalesOrdersService {
async findById( async findById(
id: string, id: string,
): Promise<ReturnType<SalesOrdersService['toDetail']>> { ): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
const found = await this.salesOrdersRepository.findById(id); const found = await this.salesOrdersRepository.findById(id);
if (!found) { if (!found) {
throw new NotFoundException('Sales order not found'); throw new NotFoundException('Sales order not found');
@@ -138,7 +146,7 @@ export class SalesOrdersService {
images?: SalesImageBody[]; images?: SalesImageBody[];
status?: string; status?: string;
userId: string; userId: string;
}): Promise<ReturnType<SalesOrdersService['toDetail']>> { }): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
const merged = await this.mergeFromSalesRequest(input); const merged = await this.mergeFromSalesRequest(input);
await this.assertRelations(merged); await this.assertRelations(merged);
const created = await this.salesOrdersRepository.create( const created = await this.salesOrdersRepository.create(
@@ -165,7 +173,7 @@ export class SalesOrdersService {
status?: unknown; status?: unknown;
userId: string; userId: string;
}, },
): Promise<ReturnType<SalesOrdersService['toDetail']>> { ): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
if (input.status !== undefined) { if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH'); throw new BadRequestException('status cannot be updated via PATCH');
} }
@@ -209,13 +217,66 @@ export class SalesOrdersService {
id: string, id: string,
statusRaw: string, statusRaw: string,
userId: string, userId: string,
): Promise<ReturnType<SalesOrdersService['toDetail']>> { options?: { generatePackingSlip?: boolean },
): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
const current = await this.salesOrdersRepository.findById(id);
if (!current) {
throw new NotFoundException('Sales order not found');
}
const status = this.assertStatus(statusRaw); 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( const updated = await this.salesOrdersRepository.updateStatus(
id, id,
status, status,
userId, 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<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
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); return this.toDetail(updated);
} }
@@ -223,14 +284,12 @@ export class SalesOrdersService {
ids: string[], ids: string[],
statusRaw: string, statusRaw: string,
userId: string, userId: string,
options?: { generatePackingSlip?: boolean },
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw); for (const id of ids) {
const updated = await this.salesOrdersRepository.bulkUpdateStatus( await this.updateStatus(id, statusRaw, userId, options);
ids, }
status, return { updated: ids.length };
userId,
);
return { updated };
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
@@ -331,7 +390,10 @@ export class SalesOrdersService {
}; };
} }
toDetail(item: SalesOrder) { async toDetail(item: SalesOrder) {
const related = await this.salesDocumentFlowService.relatedDocumentIds(
item.id,
);
return { return {
...this.toListItem(item), ...this.toListItem(item),
products: item.products.map((line) => ({ products: item.products.map((line) => ({
@@ -345,6 +407,8 @@ export class SalesOrdersService {
url: image.url, url: image.url,
description: image.description, description: image.description,
})), })),
packingSlipIds: related.packingSlipIds,
invoiceIds: related.invoiceIds,
}; };
} }
@@ -106,7 +106,10 @@ describe('SalesPaymentsService', () => {
}); });
it('approving a payment recomputes referenced invoices', async () => { 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({ salesInvoicesService.getTotals.mockResolvedValue({
total: Decimal.create('25000'), total: Decimal.create('25000'),
paid: Decimal.create('0'), paid: Decimal.create('0'),
@@ -124,7 +127,10 @@ describe('SalesPaymentsService', () => {
}); });
it('rejects approve when allocation would exceed the invoice total', async () => { 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({ salesInvoicesService.getTotals.mockResolvedValue({
total: Decimal.create('5000'), total: Decimal.create('5000'),
paid: Decimal.create('0'), paid: Decimal.create('0'),
@@ -20,8 +20,10 @@ import {
isValidDocumentNotes, isValidDocumentNotes,
isValidImageDescription, isValidImageDescription,
isValidImageUrl, isValidImageUrl,
isAllowedStatusTransition,
parseCsvRecord, parseCsvRecord,
SALES_PAYMENT_STATUSES, SALES_PAYMENT_STATUSES,
SALES_PAYMENT_USER_TRANSITIONS,
} from '../shared/sales-fields'; } from '../shared/sales-fields';
import type { import type {
CreateSalesPaymentInput, CreateSalesPaymentInput,
@@ -152,6 +154,15 @@ export class SalesPaymentsService {
if (!existing) { if (!existing) {
throw new NotFoundException('Sales payment not found'); 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 = const becomingApproved =
next.value === 'approved' && existing.status.value !== 'approved'; next.value === 'approved' && existing.status.value !== 'approved';
const leavingApproved = const leavingApproved =
@@ -180,13 +191,10 @@ export class SalesPaymentsService {
statusRaw: string, statusRaw: string,
userId: string, userId: string,
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw); for (const id of ids) {
const updated = await this.salesPaymentsRepository.bulkUpdateStatus( await this.updateStatus(id, statusRaw, userId);
ids, }
status, return { updated: ids.length };
userId,
);
return { updated };
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
@@ -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 {}
@@ -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',
);
});
});
@@ -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<void> {
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<void> {
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<ReturnType<PackingSlipsService['toDetail']>> {
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<void> {
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<string, Decimal>();
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 };
}
}
@@ -1,7 +1,13 @@
import { import {
isAllowedStatusTransition,
isValidDocumentCode, isValidDocumentCode,
isValidImageUrl, isValidImageUrl,
isValidLatitude, isValidLatitude,
PACKING_SLIP_USER_TRANSITIONS,
SALES_INVOICE_USER_TRANSITIONS,
SALES_ORDER_SYSTEM_TRANSITIONS,
SALES_ORDER_USER_TRANSITIONS,
SALES_PAYMENT_USER_TRANSITIONS,
} from './sales-fields'; } from './sales-fields';
describe('sales fields', () => { describe('sales fields', () => {
@@ -22,4 +28,93 @@ describe('sales fields', () => {
expect(isValidLatitude(-6.2)).toBe(true); expect(isValidLatitude(-6.2)).toBe(true);
expect(isValidLatitude(100)).toBe(false); 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);
});
}); });
+45
View File
@@ -47,6 +47,51 @@ export type PackingSlipStatus = (typeof PACKING_SLIP_STATUSES)[number];
export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number]; export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number];
export type SalesPaymentStatus = (typeof SALES_PAYMENT_STATUSES)[number]; export type SalesPaymentStatus = (typeof SALES_PAYMENT_STATUSES)[number];
export const SALES_ORDER_USER_TRANSITIONS: Record<string, readonly string[]> = {
draft: ['processed', 'cancelled'],
processed: ['cancelled'],
completed: [],
cancelled: [],
};
export const SALES_ORDER_SYSTEM_TRANSITIONS: Record<string, readonly string[]> =
{
processed: ['completed'],
};
export const PACKING_SLIP_USER_TRANSITIONS: Record<string, readonly string[]> =
{
draft: ['processed', 'cancelled'],
processed: ['completed', 'cancelled'],
completed: [],
cancelled: [],
};
export const SALES_INVOICE_USER_TRANSITIONS: Record<string, readonly string[]> =
{
draft: ['cancelled'],
processed: ['cancelled'],
partial: ['cancelled'],
completed: ['cancelled'],
cancelled: [],
};
export const SALES_PAYMENT_USER_TRANSITIONS: Record<string, readonly string[]> =
{
draft: ['pending'],
pending: ['approved', 'rejected', 'draft'],
approved: [],
rejected: [],
};
export function isAllowedStatusTransition(
current: string,
next: string,
map: Record<string, readonly string[]>,
): boolean {
return (map[current] ?? []).includes(next);
}
export function isValidDocumentCode(raw: string): boolean { export function isValidDocumentCode(raw: string): boolean {
return ( return (
typeof raw === 'string' && typeof raw === 'string' &&