- 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.
467 lines
13 KiB
TypeScript
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 };
|
|
}
|
|
}
|