Files
trackgo-be/src/modules/field/plans/plans.repository.ts
T
shancheas c9f9b31abf Add field management module with database schema and validation
- Introduced `FieldModule` to manage cycles and plans, including read and write controllers.
- Created database migrations for `company_settings`, `cycles`, `cycle_weekdays`, `cycle_destinations`, `plans`, `plan_destinations`, `plan_invoices`, and `plan_packing_slips` tables, including constraints and unique indexes.
- Developed service and repository layers for handling cycle and plan data operations.
- Added unit tests for the cycles and plans services, repositories, and controllers to ensure functionality and correctness.
- Updated application module to include the new `FieldModule` for better organization.
2026-08-25 18:20:19 +07:00

467 lines
13 KiB
TypeScript

import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
and,
asc,
count,
eq,
ilike,
inArray,
ne,
or,
sql,
SQL,
} from 'drizzle-orm';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
planDestinations,
planInvoices,
planPackingSlips,
plans,
type PlanDestinationRow,
type PlanInvoiceRow,
type PlanPackingSlipRow,
type PlanRow,
} from '../../../database/plans-table';
import type { FieldPurpose } from '../shared/field-purpose';
import type { RouteGeometry } from '../shared/route-line-string';
import type { ListPlansFilters, Plan } from './plan';
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable()
export class PlansRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListPlansFilters,
): Promise<{ data: Plan[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(plans)
.where(where);
let qb = this.db.select().from(plans).$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(plans.date))
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row, [], [], [])),
total: Number(totalRows[0]?.total ?? 0),
};
}
extendListQuery<T>(qb: T, filters: ListPlansFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<Plan | null> {
const rows: PlanRow[] = await this.db
.select()
.from(plans)
.where(eq(plans.id, id))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
return this.loadWithChildren(this.db, row);
}
async findLiveByKey(
employeeId: string,
purpose: string,
date: number,
excludeId?: string,
): Promise<Plan | null> {
const parts: SQL[] = [
eq(plans.employeeId, employeeId),
eq(plans.purpose, purpose),
eq(plans.date, date),
ne(plans.status, 'archived'),
];
if (excludeId) {
parts.push(ne(plans.id, excludeId));
}
const rows = await this.db
.select()
.from(plans)
.where(and(...parts))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row, [], [], []) : null;
}
async create(input: {
employeeId: string;
purpose: FieldPurpose;
date: DateTime;
startBranchId: string;
endBranchId: string;
routeGeometry: RouteGeometry;
customerIds: readonly string[];
invoiceIds: readonly string[];
packingSlipIds: readonly string[];
status: Status;
userId: string;
}): Promise<Plan> {
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const inserted = await tx
.insert(plans)
.values({
employeeId: input.employeeId,
purpose: input.purpose,
date: input.date.value,
startBranchId: input.startBranchId,
endBranchId: input.endBranchId,
routeGeometry: input.routeGeometry,
status: input.status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const row = inserted[0];
await this.replaceChildren(tx, row.id, input);
return this.loadWithChildren(tx, row);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async update(
id: string,
input: {
employeeId: string;
purpose: FieldPurpose;
date: DateTime;
startBranchId: string;
endBranchId: string;
routeGeometry: RouteGeometry;
customerIds?: readonly string[];
invoiceIds?: readonly string[];
packingSlipIds?: readonly string[];
userId: string;
},
): Promise<Plan> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Plan not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const updated = await tx
.update(plans)
.set({
employeeId: input.employeeId,
purpose: input.purpose,
date: input.date.value,
startBranchId: input.startBranchId,
endBranchId: input.endBranchId,
routeGeometry: input.routeGeometry,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(plans.id, id))
.returning();
const row = updated[0];
await this.replaceChildren(tx, id, {
customerIds: input.customerIds,
invoiceIds: input.invoiceIds,
packingSlipIds: input.packingSlipIds,
});
return this.loadWithChildren(tx, row);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async replaceDestinations(
id: string,
customerIds: readonly string[],
routeGeometry: RouteGeometry,
userId: string,
): Promise<Plan> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Plan not found');
}
const now = DateTime.fromUnixMs(Date.now());
return this.db.transaction(async (tx) => {
await tx
.update(plans)
.set({
routeGeometry,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(plans.id, id));
await tx.delete(planDestinations).where(eq(planDestinations.planId, id));
if (customerIds.length > 0) {
await tx.insert(planDestinations).values(
customerIds.map((customerId, index) => ({
planId: id,
customerId,
sortOrder: index,
})),
);
}
const rows = await tx
.select()
.from(plans)
.where(eq(plans.id, id))
.limit(1);
return this.loadWithChildren(tx, rows[0]);
});
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Plan> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(plans)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(plans.id, id))
.returning({ id: plans.id });
if (updated.length === 0) {
throw new NotFoundException('Plan not found');
}
const found = await this.findById(id);
if (!found) {
throw new NotFoundException('Plan not found');
}
return found;
}
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(plans)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(plans.id, ids))
.returning({ id: plans.id });
return rows.length;
}
async archive(id: string, userId: string): Promise<void> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(plans)
.set({
status: 'archived',
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(plans.id, id))
.returning({ id: plans.id });
if (updated.length === 0) {
throw new NotFoundException('Plan not found');
}
}
async bulkArchive(ids: string[], userId: string): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(plans)
.set({
status: 'archived',
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(plans.id, ids))
.returning({ id: plans.id });
return rows.length;
}
private async loadWithChildren(
executor: QueryExecutor,
row: PlanRow,
): Promise<Plan> {
const destinations = await executor
.select()
.from(planDestinations)
.where(eq(planDestinations.planId, row.id))
.orderBy(asc(planDestinations.sortOrder));
const invoices = await executor
.select()
.from(planInvoices)
.where(eq(planInvoices.planId, row.id));
const packingSlips = await executor
.select()
.from(planPackingSlips)
.where(eq(planPackingSlips.planId, row.id));
return this.toDomain(row, destinations, invoices, packingSlips);
}
private async replaceChildren(
executor: QueryExecutor,
planId: string,
input: {
customerIds?: readonly string[];
invoiceIds?: readonly string[];
packingSlipIds?: readonly string[];
},
): Promise<void> {
if (input.customerIds) {
await executor
.delete(planDestinations)
.where(eq(planDestinations.planId, planId));
if (input.customerIds.length > 0) {
await executor.insert(planDestinations).values(
input.customerIds.map((customerId, index) => ({
planId,
customerId,
sortOrder: index,
})),
);
}
}
if (input.invoiceIds) {
await executor
.delete(planInvoices)
.where(eq(planInvoices.planId, planId));
if (input.invoiceIds.length > 0) {
await executor
.insert(planInvoices)
.values(input.invoiceIds.map((invoiceId) => ({ planId, invoiceId })));
}
}
if (input.packingSlipIds) {
await executor
.delete(planPackingSlips)
.where(eq(planPackingSlips.planId, planId));
if (input.packingSlipIds.length > 0) {
await executor.insert(planPackingSlips).values(
input.packingSlipIds.map((packingSlipId) => ({
planId,
packingSlipId,
})),
);
}
}
}
private buildListWhere(filters: ListPlansFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(plans.employeeId, filters.employeeId));
}
if (filters.purpose) {
parts.push(eq(plans.purpose, filters.purpose));
} else if (filters.purposes && filters.purposes.length > 0) {
parts.push(inArray(plans.purpose, [...filters.purposes]));
}
if (filters.date !== undefined) {
parts.push(eq(plans.date, filters.date));
}
if (filters.status) {
parts.push(eq(plans.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(plans.purpose, `%${filters.search}%`),
sql`${plans.date}::text ilike ${'%' + filters.search + '%'}`,
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(
row: PlanRow,
destinationRows: PlanDestinationRow[],
invoiceRows: PlanInvoiceRow[],
packingSlipRows: PlanPackingSlipRow[],
): Plan {
return {
id: row.id,
employeeId: row.employeeId,
purpose: row.purpose as FieldPurpose,
date: DateTime.fromUnixMs(row.date),
startBranchId: row.startBranchId,
endBranchId: row.endBranchId,
routeGeometry: row.routeGeometry,
destinations: destinationRows.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
sortOrder: destination.sortOrder,
})),
invoiceIds: invoiceRows.map((row) => row.invoiceId),
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
throw new ConflictException('Plan already exists for this employee');
}
if (err.code === '23503') {
throw new ConflictException('Plan references a missing record');
}
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 };
}
}