Files
trackgo-be/src/modules/sales/sales-invoices/sales-invoices.service.ts
T
shancheas 34d4f2c120 Add products and sales management modules with database schema and validation
- Introduced `ProductsModule` to manage product data, including read and write controllers.
- Created database migrations for the `products`, `sales_requests`, `sales_orders`, `sales_invoices`, and related tables, including constraints and unique indexes.
- Implemented validation for product fields such as code, name, unit, and brand with corresponding utility functions.
- Developed service and repository layers for handling product and sales data operations.
- Added unit tests for the products and sales services, repositories, and controllers to ensure functionality and correctness.
- Updated application module to include the new `ProductsModule` and related sales modules for better organization.
2026-08-25 09:54:16 +07:00

568 lines
18 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { CustomersService } from '../../configuration/customers/customers.service';
import { DivisionsService } from '../../configuration/divisions/divisions.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { ProductsService } from '../../configuration/products/products.service';
import { PackingSlipsService } from '../packing-slips/packing-slips.service';
import { SalesOrdersService } from '../sales-orders/sales-orders.service';
import {
isValidDocumentCode,
isValidDocumentNotes,
parseCsvRecord,
SALES_INVOICE_STATUSES,
} from '../shared/sales-fields';
import type {
CreateSalesInvoiceInput,
SalesInvoice,
SalesInvoiceLineInput,
UpdateSalesInvoiceInput,
} from './sales-invoice';
import { SalesInvoicesRepository } from './sales-invoices.repository';
export type SalesLineBody = {
readonly productId: string;
readonly quantity: string;
readonly price?: string;
};
export type ListSalesInvoicesQuery = {
readonly code?: string;
readonly status?: string;
readonly customerId?: string;
readonly salesPersonId?: string;
readonly branchId?: string;
readonly divisionId?: string;
readonly salesOrderId?: string;
readonly packingSlipId?: string;
readonly search?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
const CSV_REQUIRED_HEADERS = [
'date',
'salesPersonId',
'branchId',
'divisionId',
'customerId',
] as const;
@Injectable()
export class SalesInvoicesService {
constructor(
private readonly salesInvoicesRepository: SalesInvoicesRepository,
private readonly employeesService: EmployeesService,
private readonly branchesService: BranchesService,
private readonly divisionsService: DivisionsService,
private readonly customersService: CustomersService,
private readonly productsService: ProductsService,
private readonly salesOrdersService: SalesOrdersService,
private readonly packingSlipsService: PackingSlipsService,
) {}
async list(
query: ListSalesInvoicesQuery,
): Promise<
PaginationResponse<ReturnType<SalesInvoicesService['toListItem']>>
> {
const page = toListPage(query);
const { data, total } = await this.salesInvoicesRepository.list({
code: query.code,
status: query.status,
customerId: query.customerId,
salesPersonId: query.salesPersonId,
branchId: query.branchId,
divisionId: query.divisionId,
salesOrderId: query.salesOrderId,
packingSlipId: query.packingSlipId,
search: query.search,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toListItem(item)),
total,
};
}
async findById(
id: string,
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
const found = await this.salesInvoicesRepository.findById(id);
if (!found) {
throw new NotFoundException('Sales invoice not found');
}
return this.toDetail(found);
}
async create(input: {
code?: string;
salesOrderId?: string;
packingSlipId?: string;
date?: string;
salesPersonId?: string;
branchId?: string;
divisionId?: string;
customerId?: string;
notes?: string | null;
products?: SalesLineBody[];
status?: string;
userId: string;
}): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
const merged = await this.mergeFromParents(input);
await this.assertRelations(merged);
const created = await this.salesInvoicesRepository.create(
await this.toCreateInput(merged),
);
return this.toDetail(created);
}
async update(
id: string,
input: {
code?: string;
salesOrderId?: string | null;
packingSlipId?: string | null;
date?: string;
salesPersonId?: string;
branchId?: string;
divisionId?: string;
customerId?: string;
notes?: string | null;
products?: SalesLineBody[];
status?: unknown;
userId: string;
},
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
await this.assertRelations(input);
let salesOrderCode: string | null | undefined;
let packingSlipCode: string | null | undefined;
if (input.salesOrderId) {
const order = await this.salesOrdersService.findById(input.salesOrderId);
salesOrderCode = order.code;
} else if (input.salesOrderId === null) {
salesOrderCode = null;
}
if (input.packingSlipId) {
const slip = await this.packingSlipsService.findById(input.packingSlipId);
packingSlipCode = slip.code;
} else if (input.packingSlipId === null) {
packingSlipCode = null;
}
const payload: UpdateSalesInvoiceInput = {
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
salesOrderId: input.salesOrderId,
salesOrderCode,
packingSlipId: input.packingSlipId,
packingSlipCode,
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
salesPersonId: input.salesPersonId,
branchId: input.branchId,
divisionId: input.divisionId,
customerId: input.customerId,
notes:
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
products:
input.products !== undefined
? await this.assertLines(input.products)
: undefined,
userId: input.userId,
};
const updated = await this.salesInvoicesRepository.update(id, payload);
return this.toDetail(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
const status = this.assertStatus(statusRaw);
const updated = await this.salesInvoicesRepository.updateStatus(
id,
status,
userId,
);
return this.toDetail(updated);
}
async applyPaymentEffects(
invoiceId: string,
userId: string,
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
const invoice = await this.salesInvoicesRepository.findById(invoiceId);
if (!invoice) {
throw new NotFoundException('Sales invoice not found');
}
const totals = await this.salesInvoicesRepository.computeTotals(invoiceId);
if (totals.paid.compare(totals.total) > 0) {
throw new BadRequestException('Payment exceeds invoice total');
}
await this.salesInvoicesRepository.refreshStoredBalance(invoiceId);
if (totals.paid.isZero()) {
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),
userId,
);
return this.toDetail(updated);
}
async getTotals(invoiceId: string) {
return this.salesInvoicesRepository.computeTotals(invoiceId);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw);
const updated = await this.salesInvoicesRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.salesInvoicesRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.salesInvoicesRepository.bulkDelete(ids);
return { deleted };
}
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
const rawLines = csv.split(/\r?\n/);
const filled = rawLines
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter((entry) => entry.line.length > 0);
if (filled.length === 0) {
throw new BadRequestException('CSV is empty');
}
if (filled.length > 501) {
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
}
const header = parseCsvRecord(filled[0].line).map((h) =>
h.trim().toLowerCase(),
);
const missing = CSV_REQUIRED_HEADERS.filter(
(h) => header.indexOf(h.toLowerCase()) < 0,
);
if (missing.length > 0) {
throw new BadRequestException('CSV must include required headers');
}
const idx = (key: string) => header.indexOf(key.toLowerCase());
const errors: string[] = [];
const rows: CreateSalesInvoiceInput[] = [];
for (let i = 1; i < filled.length; i++) {
const cols = parseCsvRecord(filled[i].line);
const rowNum = filled[i].lineNo;
try {
rows.push(
await this.toCreateInput({
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
date: cols[idx('date')] ?? '',
salesPersonId: cols[idx('salespersonid')] ?? '',
branchId: cols[idx('branchid')] ?? '',
divisionId: cols[idx('divisionid')] ?? '',
customerId: cols[idx('customerid')] ?? '',
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
products: [],
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
userId,
}),
);
} catch (error) {
const reason =
error instanceof BadRequestException ? error.message : 'invalid data';
errors.push(`row ${rowNum}: ${reason}`);
}
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
await this.salesInvoicesRepository.createMany(rows);
return { imported: rows.length };
}
toListItem(item: SalesInvoice) {
return {
id: item.id,
code: item.code,
salesOrderId: item.salesOrderId,
salesOrderCode: item.salesOrderCode,
packingSlipId: item.packingSlipId,
packingSlipCode: item.packingSlipCode,
date: item.date.value,
salesPersonId: item.salesPersonId,
branchId: item.branchId,
divisionId: item.divisionId,
customerId: item.customerId,
notes: item.notes,
balance: item.balance.value,
status: item.status.value,
createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value,
createdBy: item.createdBy,
updatedBy: item.updatedBy,
};
}
toDetail(item: SalesInvoice) {
return {
...this.toListItem(item),
products: item.products.map((line) => ({
id: line.id,
productId: line.productId,
quantity: line.quantity.value,
price: line.price.value,
})),
};
}
private async mergeFromParents(input: {
code?: string;
salesOrderId?: string;
packingSlipId?: string;
date?: string;
salesPersonId?: string;
branchId?: string;
divisionId?: string;
customerId?: string;
notes?: string | null;
products?: SalesLineBody[];
status?: string;
userId: string;
}) {
let date = input.date ?? '';
let salesPersonId = input.salesPersonId ?? '';
let branchId = input.branchId ?? '';
let divisionId = input.divisionId ?? '';
let customerId = input.customerId ?? '';
let notes = input.notes;
let products = input.products;
let salesOrderCode: string | null = null;
let packingSlipCode: string | null = null;
if (input.salesOrderId) {
const order = await this.salesOrdersService.findById(input.salesOrderId);
salesOrderCode = order.code;
date = date || DateTime.fromUnixMs(order.date).format();
salesPersonId = salesPersonId || order.salesPersonId;
branchId = branchId || order.branchId;
divisionId = divisionId || order.divisionId;
customerId = customerId || order.customerId;
notes = notes !== undefined ? notes : order.notes;
products =
products ??
order.products.map((line) => ({
productId: line.productId,
quantity: line.quantity,
price: line.price,
}));
}
if (input.packingSlipId) {
const slip = await this.packingSlipsService.findById(input.packingSlipId);
packingSlipCode = slip.code;
date = date || DateTime.fromUnixMs(slip.date).format();
customerId = customerId || slip.customerId;
notes = notes !== undefined ? notes : slip.notes;
products =
input.products ??
products ??
slip.products.map((line) => ({
productId: line.productId,
quantity: line.quantity,
price: line.price,
}));
}
return {
...input,
salesOrderId: input.salesOrderId ?? null,
salesOrderCode,
packingSlipId: input.packingSlipId ?? null,
packingSlipCode,
date,
salesPersonId,
branchId,
divisionId,
customerId,
notes: notes ?? null,
products: products ?? [],
};
}
private async toCreateInput(input: {
code?: string;
salesOrderId?: string | null;
salesOrderCode?: string | null;
packingSlipId?: string | null;
packingSlipCode?: string | null;
date: string;
salesPersonId: string;
branchId: string;
divisionId: string;
customerId: string;
notes?: string | null;
products: SalesLineBody[];
status?: string;
userId: string;
}): Promise<CreateSalesInvoiceInput> {
return {
code:
input.code !== undefined && input.code !== ''
? this.assertCode(input.code)
: undefined,
salesOrderId: input.salesOrderId ?? null,
salesOrderCode: input.salesOrderCode ?? null,
packingSlipId: input.packingSlipId ?? null,
packingSlipCode: input.packingSlipCode ?? null,
date: this.assertDate(input.date),
salesPersonId: input.salesPersonId,
branchId: input.branchId,
divisionId: input.divisionId,
customerId: input.customerId,
notes: this.assertNotes(input.notes ?? null),
products: await this.assertLines(input.products),
status: input.status
? this.assertStatus(input.status)
: Status.create('draft', SALES_INVOICE_STATUSES),
userId: input.userId,
};
}
private async assertRelations(input: {
salesPersonId?: string;
branchId?: string;
divisionId?: string;
customerId?: string;
}): Promise<void> {
if (input.salesPersonId) {
await this.employeesService.findById(input.salesPersonId);
}
if (input.branchId) {
await this.branchesService.findById(input.branchId);
}
if (input.divisionId) {
await this.divisionsService.findById(input.divisionId);
}
if (input.customerId) {
await this.customersService.findById(input.customerId);
}
}
private async assertLines(
lines: SalesLineBody[],
): Promise<SalesInvoiceLineInput[]> {
if (!Array.isArray(lines) || lines.length === 0) {
throw new BadRequestException('At least one product line is required');
}
const result: SalesInvoiceLineInput[] = [];
for (const line of lines) {
const product = await this.productsService.findById(line.productId);
const quantity = this.assertPositiveDecimal(line.quantity, 'quantity');
let price: Decimal;
if (line.price === undefined || line.price === '') {
if (product.price === null) {
throw new BadRequestException('Product price is required');
}
price = Decimal.create(product.price);
} else {
price = this.assertNonNegativeDecimal(line.price, 'price');
}
result.push({ productId: product.id, quantity, price });
}
return result;
}
private assertCode(raw: string): string {
const code = raw.trim();
if (!isValidDocumentCode(code)) {
throw new BadRequestException('Invalid document code');
}
return code;
}
private assertDate(raw: string): DateTime {
try {
return DateTime.create(raw);
} catch (error) {
if (error instanceof InvalidDateTimeError) {
throw new BadRequestException('Invalid date');
}
throw error;
}
}
private assertNotes(raw: string | null): string | null {
if (raw === null || raw === '') {
return null;
}
if (!isValidDocumentNotes(raw)) {
throw new BadRequestException('Invalid notes');
}
return raw;
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw, SALES_INVOICE_STATUSES);
} catch {
throw new BadRequestException('Invalid status');
}
}
private assertPositiveDecimal(raw: string, label: string): Decimal {
const value = this.parseDecimal(raw);
if (!value.isPositive()) {
throw new BadRequestException(`Invalid ${label}`);
}
return value;
}
private assertNonNegativeDecimal(raw: string, label: string): Decimal {
const value = this.parseDecimal(raw);
if (value.isNegative()) {
throw new BadRequestException(`Invalid ${label}`);
}
return value;
}
private parseDecimal(raw: string): Decimal {
try {
return Decimal.create(raw);
} catch (error) {
if (error instanceof InvalidDecimalError) {
throw new BadRequestException('Invalid decimal');
}
throw error;
}
}
}