- 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.
221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
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 };
|
|
}
|
|
}
|