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,381 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
salesPaymentImages,
|
||||
salesPaymentInvoices,
|
||||
salesPayments,
|
||||
type SalesPaymentImageRow,
|
||||
type SalesPaymentInvoiceRow,
|
||||
type SalesPaymentRow,
|
||||
} from '../../../database/sales-payments-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { SALES_PAYMENT_STATUSES } from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesPaymentInput,
|
||||
ListSalesPaymentsFilters,
|
||||
SalesPayment,
|
||||
SalesPaymentAllocationInput,
|
||||
SalesPaymentImageInput,
|
||||
UpdateSalesPaymentInput,
|
||||
} from './sales-payment';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class SalesPaymentsRepository {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly documentCodeService: DocumentCodeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
filters: ListSalesPaymentsFilters,
|
||||
): Promise<{ data: SalesPayment[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(salesPayments)
|
||||
.where(where);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(salesPayments)
|
||||
.where(where)
|
||||
.orderBy(asc(salesPayments.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SalesPayment | null> {
|
||||
const rows: SalesPaymentRow[] = await this.db
|
||||
.select()
|
||||
.from(salesPayments)
|
||||
.where(eq(salesPayments.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const invoices = await this.selectAllocations(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesPaymentInput): Promise<SalesPayment> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
input.status ?? Status.create('draft', SALES_PAYMENT_STATUSES);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const code =
|
||||
input.code ??
|
||||
(await this.documentCodeService.nextCode(
|
||||
DOCUMENT_PREFIXES.salesPayment,
|
||||
input.date,
|
||||
tx,
|
||||
));
|
||||
const inserted = await tx
|
||||
.insert(salesPayments)
|
||||
.values(this.toInsertValues(input, code, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceAllocations(tx, row.id, input.invoices);
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const invoices = await this.selectAllocations(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateSalesPaymentInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const input of inputs) {
|
||||
await this.create(input);
|
||||
}
|
||||
return inputs.length;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateSalesPaymentInput,
|
||||
): Promise<SalesPayment> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(salesPayments)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
date: input.date?.value ?? existing.date.value,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(salesPayments.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
if (input.invoices !== undefined) {
|
||||
await this.replaceAllocations(tx, id, input.invoices);
|
||||
}
|
||||
if (input.images !== undefined) {
|
||||
await this.replaceImages(tx, id, input.images);
|
||||
}
|
||||
const invoices = await this.selectAllocations(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<SalesPayment> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(salesPayments)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(salesPayments.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
const invoices = await this.selectAllocations(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(salesPayments)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(salesPayments.id, ids))
|
||||
.returning({ id: salesPayments.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(salesPayments)
|
||||
.where(eq(salesPayments.id, id))
|
||||
.returning({ id: salesPayments.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(salesPayments)
|
||||
.where(inArray(salesPayments.id, ids))
|
||||
.returning({ id: salesPayments.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private async selectAllocations(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
): Promise<SalesPaymentInvoiceRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesPaymentInvoices)
|
||||
.where(eq(salesPaymentInvoices.salesPaymentId, salesPaymentId));
|
||||
}
|
||||
|
||||
private async selectImages(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
): Promise<SalesPaymentImageRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesPaymentImages)
|
||||
.where(eq(salesPaymentImages.salesPaymentId, salesPaymentId));
|
||||
}
|
||||
|
||||
private async replaceAllocations(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
invoices: readonly SalesPaymentAllocationInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesPaymentInvoices)
|
||||
.where(eq(salesPaymentInvoices.salesPaymentId, salesPaymentId));
|
||||
if (invoices.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesPaymentInvoices).values(
|
||||
invoices.map((line) => ({
|
||||
salesPaymentId,
|
||||
salesInvoiceId: line.invoiceId,
|
||||
amount: line.amount.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceImages(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
images: readonly SalesPaymentImageInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesPaymentImages)
|
||||
.where(eq(salesPaymentImages.salesPaymentId, salesPaymentId));
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesPaymentImages).values(
|
||||
images.map((image) => ({
|
||||
salesPaymentId,
|
||||
url: image.url,
|
||||
description: image.description ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListSalesPaymentsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(salesPayments.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(salesPayments.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(salesPayments.code, `%${filters.search}%`),
|
||||
ilike(salesPayments.notes, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateSalesPaymentInput,
|
||||
code: string,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
date: input.date.value,
|
||||
notes: input.notes ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: SalesPaymentRow,
|
||||
allocationRows: SalesPaymentInvoiceRow[],
|
||||
imageRows: SalesPaymentImageRow[],
|
||||
): SalesPayment {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
notes: row.notes,
|
||||
invoices: allocationRows.map((line) => ({
|
||||
id: line.id,
|
||||
invoiceId: line.salesInvoiceId,
|
||||
amount: Decimal.create(line.amount),
|
||||
})),
|
||||
images: imageRows.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
status: Status.create(row.status, SALES_PAYMENT_STATUSES),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Sales payment code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Related record was not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user