- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results. - Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns. - Enhanced `PaginationQueryDto` to support ordering fields in API requests. - Updated various repository and service classes to implement ordering in database queries. - Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior. - Refactored related DTOs to include user and code relations for better data representation in responses.
472 lines
14 KiB
TypeScript
472 lines
14 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 {
|
|
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<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
|
|
|
@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<SalesRequest | null> {
|
|
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<SalesRequest> {
|
|
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<number> {
|
|
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<SalesRequest> {
|
|
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<SalesRequest> {
|
|
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<number> {
|
|
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<void> {
|
|
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<number> {
|
|
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<SalesRequestProductRow[]> {
|
|
return executor
|
|
.select()
|
|
.from(salesRequestProducts)
|
|
.where(eq(salesRequestProducts.salesRequestId, salesRequestId));
|
|
}
|
|
|
|
private async selectImages(
|
|
executor: QueryExecutor,
|
|
salesRequestId: string,
|
|
): Promise<SalesRequestImageRow[]> {
|
|
return executor
|
|
.select()
|
|
.from(salesRequestImages)
|
|
.where(eq(salesRequestImages.salesRequestId, salesRequestId));
|
|
}
|
|
|
|
private async replaceProducts(
|
|
executor: QueryExecutor,
|
|
salesRequestId: string,
|
|
products: readonly SalesRequestLineInput[],
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<SalesRequest[]> {
|
|
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<SalesRequest> {
|
|
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 };
|
|
}
|
|
}
|