Files
trackgo-be/src/modules/sales/sales-payments/sales-payments.repository.ts
T
shancheas 6b3ddfcff9 Add date and createdBy filters to sales payments functionality
- Introduced new filters `date` and `createdBy` in the `ListSalesPaymentsFilters` and `ListSalesPaymentsQuery` types to enhance querying capabilities.
- Updated the `SalesPaymentsRepository` to handle filtering based on the new fields.
- Enhanced the `SalesPaymentsService` to process the new filters and ensure proper date handling.
- Added unit tests to validate the integration of the new filters in the service layer.
- Updated DTOs to include validation for the new fields, ensuring correct data formats in API requests.
2026-09-01 15:29:07 +07:00

434 lines
13 KiB
TypeScript

import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import {
codeRelationFromMap,
loadSalesInvoiceRelationMap,
} from '../../../database/load-catalog-refs';
import { attachAuditUsers } from '../../../database/load-user-refs';
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';
const SALES_PAYMENT_ORDER_COLUMNS = {
id: salesPayments.id,
code: salesPayments.code,
date: salesPayments.date,
status: salesPayments.status,
createdAt: salesPayments.createdAt,
updatedAt: salesPayments.updatedAt,
};
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(
...toOrderClauses(SALES_PAYMENT_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: await this.hydrate(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.hydrateOne(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.hydrateOne(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.hydrateOne(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.hydrateOne(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 (filters.date !== undefined) {
parts.push(eq(salesPayments.date, filters.date));
}
if (filters.createdBy) {
parts.push(eq(salesPayments.createdBy, filters.createdBy));
}
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,
invoice: null,
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,
createdByUser: { id: row.createdBy, username: '' },
updatedByUser: { id: row.updatedBy, username: '' },
};
}
private async hydrate(items: SalesPayment[]): Promise<SalesPayment[]> {
const withAudit = await attachAuditUsers(this.db, items);
const invoices = await loadSalesInvoiceRelationMap(
this.db,
withAudit.flatMap((item) => item.invoices.map((line) => line.invoiceId)),
);
return withAudit.map((item) => ({
...item,
invoices: item.invoices.map((line) => ({
...line,
invoice: codeRelationFromMap(invoices, line.invoiceId),
})),
}));
}
private async hydrateOne(
row: SalesPaymentRow,
invoices: SalesPaymentInvoiceRow[],
images: SalesPaymentImageRow[],
): Promise<SalesPayment> {
const [item] = await this.hydrate([this.toDomain(row, invoices, images)]);
return item;
}
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 };
}
}