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.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user