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 { catalogRelationFromMap, loadProductRelationMap, } from '../../../database/load-catalog-refs'; import { attachSalesHeaderRelations } from '../shared/attach-sales-relations'; 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 { salesRequestImages, salesRequestProducts, salesRequests, type SalesRequestImageRow, type SalesRequestProductRow, type SalesRequestRow, } from '../../../database/sales-requests-table'; import { DocumentCodeService } from '../shared/document-code.service'; import { DOCUMENT_PREFIXES } from '../shared/document-prefixes'; import { SALES_REQUEST_STATUSES } from '../shared/sales-fields'; import type { CreateSalesRequestInput, ListSalesRequestsFilters, SalesRequest, SalesRequestImageInput, SalesRequestLineInput, UpdateSalesRequestInput, } from './sales-request'; const SALES_REQUEST_ORDER_COLUMNS = { id: salesRequests.id, code: salesRequests.code, date: salesRequests.date, status: salesRequests.status, createdAt: salesRequests.createdAt, updatedAt: salesRequests.updatedAt, }; type QueryExecutor = Pick; @Injectable() export class SalesRequestsRepository { constructor( @Inject(DRIZZLE) private readonly db: DrizzleDB, private readonly documentCodeService: DocumentCodeService, ) {} async list( filters: ListSalesRequestsFilters, ): Promise<{ data: SalesRequest[]; total: number }> { const where = this.buildListWhere(filters); const totalRows = await this.db .select({ total: count() }) .from(salesRequests) .where(where); const rows = await this.db .select() .from(salesRequests) .where(where) .orderBy( ...toOrderClauses(SALES_REQUEST_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: SalesRequestRow[] = await this.db .select() .from(salesRequests) .where(eq(salesRequests.id, id)) .limit(1); const row = rows[0]; if (!row) { return null; } const products = await this.selectProducts(this.db, id); const images = await this.selectImages(this.db, id); return this.hydrateOne(row, products, images); } async create(input: CreateSalesRequestInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = input.status ?? Status.create('draft', SALES_REQUEST_STATUSES); try { return await this.db.transaction(async (tx) => { const code = input.code ?? (await this.documentCodeService.nextCode( DOCUMENT_PREFIXES.salesRequest, input.date, tx, )); const inserted = await tx .insert(salesRequests) .values(this.toInsertValues(input, code, status, now, input.userId)) .returning(); const row = inserted[0]; await this.replaceProducts(tx, row.id, input.products); await this.replaceImages(tx, row.id, input.images ?? []); const products = await this.selectProducts(tx, row.id); const images = await this.selectImages(tx, row.id); return this.hydrateOne(row, products, images); }); } catch (error) { this.rethrowConstraintViolation(error); } } async createMany(inputs: CreateSalesRequestInput[]): Promise { if (inputs.length === 0) { return 0; } for (const input of inputs) { await this.create(input); } return inputs.length; } async update( id: string, input: UpdateSalesRequestInput, ): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Sales request not found'); } const now = DateTime.fromUnixMs(Date.now()); try { return await this.db.transaction(async (tx) => { const updated = await tx .update(salesRequests) .set({ code: input.code ?? existing.code, date: input.date?.value ?? existing.date.value, salesPersonId: input.salesPersonId ?? existing.salesPersonId, branchId: input.branchId ?? existing.branchId, divisionId: input.divisionId ?? existing.divisionId, customerId: input.customerId ?? existing.customerId, address: input.address ?? existing.address, latitude: input.latitude !== undefined ? input.latitude : existing.latitude, longitude: input.longitude !== undefined ? input.longitude : existing.longitude, notes: input.notes !== undefined ? input.notes : existing.notes, updatedAt: now.value, updatedBy: input.userId, }) .where(eq(salesRequests.id, id)) .returning(); const row = updated[0]; if (!row) { throw new NotFoundException('Sales request not found'); } if (input.products !== undefined) { await this.replaceProducts(tx, id, input.products); } if (input.images !== undefined) { await this.replaceImages(tx, id, input.images); } const products = await this.selectProducts(tx, id); const images = await this.selectImages(tx, id); return this.hydrateOne(row, products, 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(salesRequests) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(eq(salesRequests.id, id)) .returning(); const row = updated[0]; if (!row) { throw new NotFoundException('Sales request not found'); } const products = await this.selectProducts(this.db, id); const images = await this.selectImages(this.db, id); return this.hydrateOne(row, products, 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(salesRequests) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(inArray(salesRequests.id, ids)) .returning({ id: salesRequests.id }); return rows.length; } async delete(id: string): Promise { const deleted = await this.db .delete(salesRequests) .where(eq(salesRequests.id, id)) .returning({ id: salesRequests.id }); if (deleted.length === 0) { throw new NotFoundException('Sales request not found'); } } async bulkDelete(ids: string[]): Promise { if (ids.length === 0) { return 0; } const deleted = await this.db .delete(salesRequests) .where(inArray(salesRequests.id, ids)) .returning({ id: salesRequests.id }); return deleted.length; } private async selectProducts( executor: QueryExecutor, salesRequestId: string, ): Promise { return executor .select() .from(salesRequestProducts) .where(eq(salesRequestProducts.salesRequestId, salesRequestId)); } private async selectImages( executor: QueryExecutor, salesRequestId: string, ): Promise { return executor .select() .from(salesRequestImages) .where(eq(salesRequestImages.salesRequestId, salesRequestId)); } private async replaceProducts( executor: QueryExecutor, salesRequestId: string, products: readonly SalesRequestLineInput[], ): Promise { await executor .delete(salesRequestProducts) .where(eq(salesRequestProducts.salesRequestId, salesRequestId)); if (products.length === 0) { return; } await executor.insert(salesRequestProducts).values( products.map((line) => ({ salesRequestId, productId: line.productId, quantity: line.quantity.value, price: line.price.value, })), ); } private async replaceImages( executor: QueryExecutor, salesRequestId: string, images: readonly SalesRequestImageInput[], ): Promise { await executor .delete(salesRequestImages) .where(eq(salesRequestImages.salesRequestId, salesRequestId)); if (images.length === 0) { return; } await executor.insert(salesRequestImages).values( images.map((image) => ({ salesRequestId, url: image.url, description: image.description ?? null, })), ); } private buildListWhere(filters: ListSalesRequestsFilters): SQL | undefined { const parts: SQL[] = []; if (filters.code) { parts.push(ilike(salesRequests.code, `%${filters.code}%`)); } if (filters.status) { parts.push(eq(salesRequests.status, filters.status)); } if (filters.customerId) { parts.push(eq(salesRequests.customerId, filters.customerId)); } if (filters.salesPersonId) { parts.push(eq(salesRequests.salesPersonId, filters.salesPersonId)); } if (filters.branchId) { parts.push(eq(salesRequests.branchId, filters.branchId)); } if (filters.divisionId) { parts.push(eq(salesRequests.divisionId, filters.divisionId)); } if (filters.search) { const search = or( ilike(salesRequests.code, `%${filters.search}%`), ilike(salesRequests.address, `%${filters.search}%`), ilike(salesRequests.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: CreateSalesRequestInput, code: string, status: Status, now: DateTime, userId: string, ) { return { code, date: input.date.value, salesPersonId: input.salesPersonId, branchId: input.branchId, divisionId: input.divisionId, customerId: input.customerId, address: input.address, latitude: input.latitude ?? null, longitude: input.longitude ?? null, notes: input.notes ?? null, status: status.value, createdAt: now.value, updatedAt: now.value, createdBy: userId, updatedBy: userId, }; } private toDomain( row: SalesRequestRow, productRows: SalesRequestProductRow[], imageRows: SalesRequestImageRow[], ): SalesRequest { return { id: row.id, code: row.code, date: DateTime.fromUnixMs(row.date), salesPersonId: row.salesPersonId, branchId: row.branchId, divisionId: row.divisionId, customerId: row.customerId, address: row.address, latitude: row.latitude, longitude: row.longitude, notes: row.notes, products: productRows.map((line) => ({ id: line.id, productId: line.productId, product: null, quantity: Decimal.create(line.quantity), price: Decimal.create(line.price), })), images: imageRows.map((image) => ({ id: image.id, url: image.url, description: image.description, })), status: Status.create(row.status, SALES_REQUEST_STATUSES), createdAt: DateTime.fromUnixMs(row.createdAt), updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, salesPerson: null, branch: null, division: null, customer: null, createdByUser: { id: row.createdBy, username: '' }, updatedByUser: { id: row.updatedBy, username: '' }, }; } private async hydrate(items: SalesRequest[]): Promise { const withHeader = await attachSalesHeaderRelations(this.db, items); const productIds = withHeader.flatMap((item) => item.products.map((line) => line.productId), ); const products = await loadProductRelationMap(this.db, productIds); return withHeader.map((item) => ({ ...item, products: item.products.map((line) => ({ ...line, product: catalogRelationFromMap(products, line.productId), })), })); } private async hydrateOne( row: SalesRequestRow, products: SalesRequestProductRow[], images: SalesRequestImageRow[], ): Promise { const [item] = await this.hydrate([this.toDomain(row, products, 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 request 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 }; } }