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; @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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return executor .select() .from(salesPaymentInvoices) .where(eq(salesPaymentInvoices.salesPaymentId, salesPaymentId)); } private async selectImages( executor: QueryExecutor, salesPaymentId: string, ): Promise { return executor .select() .from(salesPaymentImages) .where(eq(salesPaymentImages.salesPaymentId, salesPaymentId)); } private async replaceAllocations( executor: QueryExecutor, salesPaymentId: string, invoices: readonly SalesPaymentAllocationInput[], ): Promise { 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 { 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 { 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 { 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 }; } }