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.
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user