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,396 @@
|
||||
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 { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
|
||||
import {
|
||||
isValidDocumentCode,
|
||||
isValidDocumentNotes,
|
||||
isValidImageDescription,
|
||||
isValidImageUrl,
|
||||
parseCsvRecord,
|
||||
SALES_PAYMENT_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesPaymentInput,
|
||||
SalesPayment,
|
||||
SalesPaymentAllocationInput,
|
||||
SalesPaymentImageInput,
|
||||
UpdateSalesPaymentInput,
|
||||
} from './sales-payment';
|
||||
import { SalesPaymentsRepository } from './sales-payments.repository';
|
||||
|
||||
export type PaymentAllocationBody = {
|
||||
readonly invoiceId: string;
|
||||
readonly amount: string;
|
||||
};
|
||||
|
||||
export type SalesImageBody = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type ListSalesPaymentsQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['date'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SalesPaymentsService {
|
||||
constructor(
|
||||
private readonly salesPaymentsRepository: SalesPaymentsRepository,
|
||||
private readonly salesInvoicesService: SalesInvoicesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListSalesPaymentsQuery,
|
||||
): Promise<
|
||||
PaginationResponse<ReturnType<SalesPaymentsService['toListItem']>>
|
||||
> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.salesPaymentsRepository.list({
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const found = await this.salesPaymentsRepository.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
return this.toDetail(found);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code?: string;
|
||||
date: string;
|
||||
notes?: string | null;
|
||||
invoices: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const created = await this.salesPaymentsRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
date?: string;
|
||||
notes?: string | null;
|
||||
invoices?: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const payload: UpdateSalesPaymentInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
|
||||
notes:
|
||||
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
|
||||
invoices:
|
||||
input.invoices !== undefined
|
||||
? await this.assertAllocations(input.invoices)
|
||||
: undefined,
|
||||
images:
|
||||
input.images !== undefined
|
||||
? input.images.map((image) => this.assertImage(image))
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.salesPaymentsRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const next = this.assertStatus(statusRaw);
|
||||
const existing = await this.salesPaymentsRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
const becomingApproved =
|
||||
next.value === 'approved' && existing.status.value !== 'approved';
|
||||
const leavingApproved =
|
||||
existing.status.value === 'approved' && next.value !== 'approved';
|
||||
if (becomingApproved) {
|
||||
await this.assertAllocationsFit(existing.invoices);
|
||||
}
|
||||
const updated = await this.salesPaymentsRepository.updateStatus(
|
||||
id,
|
||||
next,
|
||||
userId,
|
||||
);
|
||||
if (becomingApproved || leavingApproved) {
|
||||
const invoiceIds = [
|
||||
...new Set(updated.invoices.map((line) => line.invoiceId)),
|
||||
];
|
||||
for (const invoiceId of invoiceIds) {
|
||||
await this.salesInvoicesService.applyPaymentEffects(invoiceId, userId);
|
||||
}
|
||||
}
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesPaymentsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.salesPaymentsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.salesPaymentsRepository.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: CreateSalesPaymentInput[] = [];
|
||||
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')] ?? '',
|
||||
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
|
||||
invoices: [],
|
||||
images: [],
|
||||
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.salesPaymentsRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(item: SalesPayment) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
date: item.date.value,
|
||||
notes: item.notes,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(item: SalesPayment) {
|
||||
return {
|
||||
...this.toListItem(item),
|
||||
invoices: item.invoices.map((line) => ({
|
||||
id: line.id,
|
||||
invoiceId: line.invoiceId,
|
||||
amount: line.amount.value,
|
||||
})),
|
||||
images: item.images.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code?: string;
|
||||
date: string;
|
||||
notes?: string | null;
|
||||
invoices: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<CreateSalesPaymentInput> {
|
||||
return {
|
||||
code:
|
||||
input.code !== undefined && input.code !== ''
|
||||
? this.assertCode(input.code)
|
||||
: undefined,
|
||||
date: this.assertDate(input.date),
|
||||
notes: this.assertNotes(input.notes ?? null),
|
||||
invoices: await this.assertAllocations(input.invoices),
|
||||
images: (input.images ?? []).map((image) => this.assertImage(image)),
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create('draft', SALES_PAYMENT_STATUSES),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertAllocationsFit(
|
||||
allocations: readonly SalesPayment['invoices'][number][],
|
||||
): Promise<void> {
|
||||
const extras = new Map<string, Decimal>();
|
||||
for (const line of allocations) {
|
||||
const current = extras.get(line.invoiceId) ?? Decimal.create('0');
|
||||
extras.set(line.invoiceId, current.add(line.amount));
|
||||
}
|
||||
for (const [invoiceId, extra] of extras) {
|
||||
const totals = await this.salesInvoicesService.getTotals(invoiceId);
|
||||
if (totals.paid.add(extra).compare(totals.total) > 0) {
|
||||
throw new BadRequestException('Payment exceeds invoice total');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAllocations(
|
||||
lines: PaymentAllocationBody[],
|
||||
): Promise<SalesPaymentAllocationInput[]> {
|
||||
if (!Array.isArray(lines)) {
|
||||
throw new BadRequestException(
|
||||
'At least one invoice allocation is required',
|
||||
);
|
||||
}
|
||||
const result: SalesPaymentAllocationInput[] = [];
|
||||
for (const line of lines) {
|
||||
await this.salesInvoicesService.findById(line.invoiceId);
|
||||
const amount = this.parseDecimal(line.amount);
|
||||
if (!amount.isPositive()) {
|
||||
throw new BadRequestException('Invalid amount');
|
||||
}
|
||||
result.push({ invoiceId: line.invoiceId, amount });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertImage(image: SalesImageBody): SalesPaymentImageInput {
|
||||
if (!isValidImageUrl(image.url)) {
|
||||
throw new BadRequestException('Invalid image URL');
|
||||
}
|
||||
const description = image.description ?? null;
|
||||
if (description !== null && !isValidImageDescription(description)) {
|
||||
throw new BadRequestException('Invalid image description');
|
||||
}
|
||||
return { url: image.url, description };
|
||||
}
|
||||
|
||||
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_PAYMENT_STATUSES);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
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