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:
@@ -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() };
|
||||
|
||||
|
||||
@@ -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<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> {
|
||||
for (const id of ids) {
|
||||
await this.salesInvoicesService.findById(id);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -155,7 +155,12 @@ export class PackingSlipsWriteController {
|
||||
@Body() dto: UpdatePackingSlipStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<PackingSlipDto> {
|
||||
return this.packingSlipsService.updateStatus(id, dto.status, userId);
|
||||
return this.packingSlipsService.updateStatus(
|
||||
id,
|
||||
dto.status,
|
||||
userId,
|
||||
dto.products,
|
||||
);
|
||||
}
|
||||
|
||||
@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 { 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 {}
|
||||
|
||||
@@ -92,6 +92,14 @@ export class PackingSlipsRepository {
|
||||
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> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
|
||||
@@ -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' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<PackingSlipsService['toDetail']>> {
|
||||
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<void> {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<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> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReturnType<SalesInvoicesService['toDetail']>> {
|
||||
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<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(
|
||||
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<void> {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<SalesOrderDto> {
|
||||
return this.salesOrdersService.updateStatus(id, dto.status, userId);
|
||||
return this.salesOrdersService.updateStatus(id, dto.status, userId, {
|
||||
generatePackingSlip: dto.generatePackingSlip,
|
||||
});
|
||||
}
|
||||
|
||||
@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 { 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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
|
||||
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<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
}): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
|
||||
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<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
): Promise<Awaited<ReturnType<SalesOrdersService['toDetail']>>> {
|
||||
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<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);
|
||||
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<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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
|
||||
Reference in New Issue
Block a user