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 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() };
|
||||||
|
|
||||||
|
|||||||
@@ -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 (totals.paid.isZero()) {
|
if (invoice.status.value === 'cancelled') {
|
||||||
return this.findById(invoiceId);
|
return this.findById(invoiceId);
|
||||||
}
|
}
|
||||||
const nextStatus =
|
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';
|
totals.paid.compare(totals.total) >= 0 ? 'completed' : 'partial';
|
||||||
|
}
|
||||||
|
if (nextStatus === invoice.status.value) {
|
||||||
|
return this.findById(invoiceId);
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
Reference in New Issue
Block a user