Enhance sales document flow with new service and related functionalities

- Introduced `SalesDocumentFlowService` to manage the lifecycle of sales documents, including packing slips and invoices.
- Implemented methods for processing orders, generating invoices, and handling packing slips based on order status.
- Updated `SalesOrdersService`, `PackingSlipsService`, and `SalesInvoicesService` to integrate with the new document flow service.
- Added new methods for marking drafts processed and checking if invoices are on sales plans.
- Enhanced existing services and repositories to support new functionalities, including status transitions and related document management.
- Updated DTOs to include new fields for packing slip and invoice IDs in sales order responses.
- Added unit tests for the new service and updated existing tests to cover new functionalities.
This commit is contained in:
shancheas
2026-08-31 09:35:18 +07:00
parent 627aeac4a0
commit afed5ff0f5
24 changed files with 950 additions and 53 deletions
@@ -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 };
}
}