From c9f9b31abfbfeae770dbaa5b0c234655e1ba0f31 Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 25 Aug 2026 18:20:19 +0700 Subject: [PATCH] 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. --- drizzle/migrations/0011_field.sql | 112 ++++ drizzle/migrations/meta/_journal.json | 7 + src/app.module.ts | 2 + .../value-objects/date-time/date-time.spec.ts | 98 ++- .../value-objects/date-time/date-time.ts | 85 ++- src/database/company-settings-table.ts | 15 + src/database/cycles-table.ts | 83 +++ src/database/plans-table.ts | 111 ++++ src/database/schema.ts | 26 + src/main.ts | 1 + .../branches/branches.service.ts | 10 + .../customers/customers.repository.ts | 14 + .../customers/customers.service.ts | 10 + .../employees/employees.service.ts | 10 + src/modules/field/cycles/cycle.ts | 69 ++ .../cycles/cycles-read.controller.spec.ts | 37 ++ .../field/cycles/cycles-read.controller.ts | 77 +++ .../cycles/cycles-write.controller.spec.ts | 62 ++ .../field/cycles/cycles-write.controller.ts | 193 ++++++ .../field/cycles/cycles.repository.spec.ts | 77 +++ src/modules/field/cycles/cycles.repository.ts | 451 +++++++++++++ .../field/cycles/cycles.service.spec.ts | 180 +++++ src/modules/field/cycles/cycles.service.ts | 537 +++++++++++++++ src/modules/field/cycles/dto/cycle.dto.ts | 205 ++++++ src/modules/field/field.module.ts | 48 ++ src/modules/field/plans/dto/plan.dto.ts | 256 +++++++ src/modules/field/plans/plan.ts | 43 ++ .../field/plans/plans-read.controller.spec.ts | 37 ++ .../field/plans/plans-read.controller.ts | 77 +++ .../plans/plans-write.controller.spec.ts | 68 ++ .../field/plans/plans-write.controller.ts | 174 +++++ src/modules/field/plans/plans.repository.ts | 466 +++++++++++++ src/modules/field/plans/plans.service.spec.ts | 213 ++++++ src/modules/field/plans/plans.service.ts | 624 ++++++++++++++++++ src/modules/field/settings/company-setting.ts | 17 + .../settings/company-settings.controller.ts | 52 ++ .../settings/company-settings.repository.ts | 68 ++ .../settings/company-settings.service.spec.ts | 64 ++ .../settings/company-settings.service.ts | 67 ++ .../field/settings/dto/company-setting.dto.ts | 35 + src/modules/field/shared/field-fields.spec.ts | 37 ++ src/modules/field/shared/field-fields.ts | 93 +++ .../field/shared/field-privilege.decorator.ts | 19 + .../shared/field-privilege.guard.spec.ts | 132 ++++ .../field/shared/field-privilege.guard.ts | 92 +++ .../field/shared/field-purpose.spec.ts | 37 ++ src/modules/field/shared/field-purpose.ts | 58 ++ .../field/shared/route-geometry.dto.ts | 21 + .../field/shared/route-line-string.spec.ts | 47 ++ src/modules/field/shared/route-line-string.ts | 44 ++ test/company-settings.e2e-spec.ts | 120 ++++ test/cycles.e2e-spec.ts | 238 +++++++ test/plans.e2e-spec.ts | 276 ++++++++ 53 files changed, 5990 insertions(+), 5 deletions(-) create mode 100644 drizzle/migrations/0011_field.sql create mode 100644 src/database/company-settings-table.ts create mode 100644 src/database/cycles-table.ts create mode 100644 src/database/plans-table.ts create mode 100644 src/modules/field/cycles/cycle.ts create mode 100644 src/modules/field/cycles/cycles-read.controller.spec.ts create mode 100644 src/modules/field/cycles/cycles-read.controller.ts create mode 100644 src/modules/field/cycles/cycles-write.controller.spec.ts create mode 100644 src/modules/field/cycles/cycles-write.controller.ts create mode 100644 src/modules/field/cycles/cycles.repository.spec.ts create mode 100644 src/modules/field/cycles/cycles.repository.ts create mode 100644 src/modules/field/cycles/cycles.service.spec.ts create mode 100644 src/modules/field/cycles/cycles.service.ts create mode 100644 src/modules/field/cycles/dto/cycle.dto.ts create mode 100644 src/modules/field/field.module.ts create mode 100644 src/modules/field/plans/dto/plan.dto.ts create mode 100644 src/modules/field/plans/plan.ts create mode 100644 src/modules/field/plans/plans-read.controller.spec.ts create mode 100644 src/modules/field/plans/plans-read.controller.ts create mode 100644 src/modules/field/plans/plans-write.controller.spec.ts create mode 100644 src/modules/field/plans/plans-write.controller.ts create mode 100644 src/modules/field/plans/plans.repository.ts create mode 100644 src/modules/field/plans/plans.service.spec.ts create mode 100644 src/modules/field/plans/plans.service.ts create mode 100644 src/modules/field/settings/company-setting.ts create mode 100644 src/modules/field/settings/company-settings.controller.ts create mode 100644 src/modules/field/settings/company-settings.repository.ts create mode 100644 src/modules/field/settings/company-settings.service.spec.ts create mode 100644 src/modules/field/settings/company-settings.service.ts create mode 100644 src/modules/field/settings/dto/company-setting.dto.ts create mode 100644 src/modules/field/shared/field-fields.spec.ts create mode 100644 src/modules/field/shared/field-fields.ts create mode 100644 src/modules/field/shared/field-privilege.decorator.ts create mode 100644 src/modules/field/shared/field-privilege.guard.spec.ts create mode 100644 src/modules/field/shared/field-privilege.guard.ts create mode 100644 src/modules/field/shared/field-purpose.spec.ts create mode 100644 src/modules/field/shared/field-purpose.ts create mode 100644 src/modules/field/shared/route-geometry.dto.ts create mode 100644 src/modules/field/shared/route-line-string.spec.ts create mode 100644 src/modules/field/shared/route-line-string.ts create mode 100644 test/company-settings.e2e-spec.ts create mode 100644 test/cycles.e2e-spec.ts create mode 100644 test/plans.e2e-spec.ts diff --git a/drizzle/migrations/0011_field.sql b/drizzle/migrations/0011_field.sql new file mode 100644 index 0000000..54c40a1 --- /dev/null +++ b/drizzle/migrations/0011_field.sql @@ -0,0 +1,112 @@ +CREATE TABLE "company_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "cycle_start_date" bigint NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + "created_by" uuid NOT NULL, + "updated_by" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cycles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "employee_id" uuid NOT NULL, + "purpose" text NOT NULL, + "cycle_number" integer NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + "created_by" uuid NOT NULL, + "updated_by" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cycle_weekdays" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "cycle_id" uuid NOT NULL, + "weekday" text NOT NULL, + "start_branch_id" uuid NOT NULL, + "end_branch_id" uuid NOT NULL, + "route_geometry" jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cycle_destinations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "cycle_weekday_id" uuid NOT NULL, + "customer_id" uuid NOT NULL, + "sort_order" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plans" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "employee_id" uuid NOT NULL, + "purpose" text NOT NULL, + "date" bigint NOT NULL, + "start_branch_id" uuid NOT NULL, + "end_branch_id" uuid NOT NULL, + "route_geometry" jsonb NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + "created_by" uuid NOT NULL, + "updated_by" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plan_destinations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plan_id" uuid NOT NULL, + "customer_id" uuid NOT NULL, + "sort_order" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plan_invoices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plan_id" uuid NOT NULL, + "invoice_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plan_packing_slips" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plan_id" uuid NOT NULL, + "packing_slip_id" uuid NOT NULL +); +--> statement-breakpoint +ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycles" ADD CONSTRAINT "cycles_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycles" ADD CONSTRAINT "cycles_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycles" ADD CONSTRAINT "cycles_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_cycle_id_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_cycle_weekday_id_cycle_weekdays_id_fk" FOREIGN KEY ("cycle_weekday_id") REFERENCES "public"."cycle_weekdays"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plans" ADD CONSTRAINT "plans_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plans" ADD CONSTRAINT "plans_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plans" ADD CONSTRAINT "plans_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plans" ADD CONSTRAINT "plans_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plans" ADD CONSTRAINT "plans_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "cycles_employee_purpose_number_live_unique" ON "cycles" USING btree ("employee_id","purpose","cycle_number") WHERE "status" <> 'archived';--> statement-breakpoint +CREATE INDEX "cycles_employee_id_idx" ON "cycles" USING btree ("employee_id");--> statement-breakpoint +CREATE UNIQUE INDEX "cycle_weekdays_cycle_weekday_unique" ON "cycle_weekdays" USING btree ("cycle_id","weekday");--> statement-breakpoint +CREATE INDEX "cycle_weekdays_cycle_id_idx" ON "cycle_weekdays" USING btree ("cycle_id");--> statement-breakpoint +CREATE INDEX "cycle_destinations_weekday_id_idx" ON "cycle_destinations" USING btree ("cycle_weekday_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plans_employee_purpose_date_live_unique" ON "plans" USING btree ("employee_id","purpose","date") WHERE "status" <> 'archived';--> statement-breakpoint +CREATE INDEX "plans_employee_id_idx" ON "plans" USING btree ("employee_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plan_destinations_plan_customer_unique" ON "plan_destinations" USING btree ("plan_id","customer_id");--> statement-breakpoint +CREATE INDEX "plan_destinations_plan_id_idx" ON "plan_destinations" USING btree ("plan_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plan_invoices_plan_invoice_unique" ON "plan_invoices" USING btree ("plan_id","invoice_id");--> statement-breakpoint +CREATE INDEX "plan_invoices_plan_id_idx" ON "plan_invoices" USING btree ("plan_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plan_packing_slips_plan_slip_unique" ON "plan_packing_slips" USING btree ("plan_id","packing_slip_id");--> statement-breakpoint +CREATE INDEX "plan_packing_slips_plan_id_idx" ON "plan_packing_slips" USING btree ("plan_id");--> statement-breakpoint +INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES + ('CONFIGURATION.SETTING', 'Company settings', 13), + ('SALES.CYCLE', 'Sales cycles', 14), + ('SALES.PLAN', 'Sales plans', 15), + ('LOGISTICS.CYCLE', 'Logistics cycles', 16), + ('LOGISTICS.PLAN', 'Logistics plans', 17); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 524bcba..7f482b2 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1787558000000, "tag": "0010_phase2_shape", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1787559000000, + "tag": "0011_field", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 480643a..6e386bd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import loadEnv from './config/env'; import { DatabaseModule } from './database/database.module'; import { AuthModule } from './modules/auth/auth.module'; import { ConfigurationModule } from './modules/configuration/configuration.module'; +import { FieldModule } from './modules/field/field.module'; import { SalesModule } from './modules/sales/sales.module'; import { PrivilegesModule } from './modules/privileges/privileges.module'; import { UsersModule } from './modules/users/users.module'; @@ -22,6 +23,7 @@ import { UsersModule } from './modules/users/users.module'; PrivilegesModule, ConfigurationModule, SalesModule, + FieldModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/value-objects/date-time/date-time.spec.ts b/src/common/value-objects/date-time/date-time.spec.ts index c7dba3d..b6fd003 100644 --- a/src/common/value-objects/date-time/date-time.spec.ts +++ b/src/common/value-objects/date-time/date-time.spec.ts @@ -92,8 +92,24 @@ describe('DateTime', () => { expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); }); - it('rejects date-only strings', () => { - expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError); + it('parses date-only strings as start of day in DEFAULT_TIMEZONE (GMT+7)', () => { + delete process.env.DEFAULT_TIMEZONE; + + const dt = DateTime.create('2026-08-20'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0)); + }); + + it('parses date-only strings using a custom DEFAULT_TIMEZONE', () => { + process.env.DEFAULT_TIMEZONE = 'UTC+0'; + + const dt = DateTime.create('2026-08-20'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 0, 0, 0, 0)); + }); + + it('rejects invalid calendar dates on date-only strings', () => { + expect(() => DateTime.create('2026-02-30')).toThrow(InvalidDateTimeError); }); it('rejects empty string', () => { @@ -299,4 +315,82 @@ describe('DateTime', () => { ).toThrow(); }); }); + + describe('startOfDay', () => { + it('returns midnight of the same calendar day in DEFAULT_TIMEZONE', () => { + delete process.env.DEFAULT_TIMEZONE; + const dt = DateTime.create('2026-08-20T17:30:00+07:00'); + + const start = dt.startOfDay(); + + expect(start.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0)); + expect(start.equals(DateTime.create('2026-08-20'))).toBe(true); + }); + + it('does not mutate the original instant', () => { + const dt = DateTime.create('2026-08-20T10:00:00Z'); + const before = dt.value; + + dt.startOfDay(); + + expect(dt.value).toBe(before); + }); + }); + + describe('weekdayName', () => { + it('returns monday through sunday in DEFAULT_TIMEZONE', () => { + delete process.env.DEFAULT_TIMEZONE; + + expect(DateTime.create('2026-08-24').weekdayName()).toBe('monday'); + expect(DateTime.create('2026-08-25').weekdayName()).toBe('tuesday'); + expect(DateTime.create('2026-08-26').weekdayName()).toBe('wednesday'); + expect(DateTime.create('2026-08-27').weekdayName()).toBe('thursday'); + expect(DateTime.create('2026-08-28').weekdayName()).toBe('friday'); + expect(DateTime.create('2026-08-29').weekdayName()).toBe('saturday'); + expect(DateTime.create('2026-08-30').weekdayName()).toBe('sunday'); + }); + + it('uses the calendar day in DEFAULT_TIMEZONE, not UTC', () => { + delete process.env.DEFAULT_TIMEZONE; + // 2026-08-24 00:30 GMT+7 is still Sunday UTC + const dt = DateTime.create('2026-08-24T00:30:00+07:00'); + + expect(dt.weekdayName()).toBe('monday'); + }); + }); + + describe('wholeWeeksSince', () => { + it('returns 0 on the epoch day and through the next 6 days', () => { + delete process.env.DEFAULT_TIMEZONE; + const epoch = DateTime.create('2026-01-05'); + + expect(DateTime.create('2026-01-05').wholeWeeksSince(epoch)).toBe(0); + expect(DateTime.create('2026-01-11').wholeWeeksSince(epoch)).toBe(0); + }); + + it('returns 1 at +7 days (next week)', () => { + delete process.env.DEFAULT_TIMEZONE; + const epoch = DateTime.create('2026-01-05'); + + expect(DateTime.create('2026-01-12').wholeWeeksSince(epoch)).toBe(1); + }); + + it('wraps so remainder 0 of a 1-based week index is the last cycle', () => { + delete process.env.DEFAULT_TIMEZONE; + const epoch = DateTime.create('2026-01-05'); + const totalCycles = 3; + const week2 = DateTime.create('2026-01-19'); // wholeWeeks = 2 + const cycleNumber = (week2.wholeWeeksSince(epoch) % totalCycles) + 1; + + expect(week2.wholeWeeksSince(epoch)).toBe(2); + expect(cycleNumber).toBe(3); + }); + + it('returns a negative count when the date is before the epoch', () => { + delete process.env.DEFAULT_TIMEZONE; + const epoch = DateTime.create('2026-01-05'); + + expect(DateTime.create('2025-12-29').wholeWeeksSince(epoch)).toBe(-1); + }); + }); }); diff --git a/src/common/value-objects/date-time/date-time.ts b/src/common/value-objects/date-time/date-time.ts index 661950b..2bb933c 100644 --- a/src/common/value-objects/date-time/date-time.ts +++ b/src/common/value-objects/date-time/date-time.ts @@ -9,6 +9,24 @@ const MAX_UNIX_MS = 8_640_000_000_000_000; const ISO_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i; +/** Calendar date (YYYY-MM-DD), interpreted as 00:00:00 in DEFAULT_TIMEZONE. */ +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +const WEEKDAY_NAMES = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] as const; + +const MS_PER_DAY = 86_400_000; +const MS_PER_WEEK = 7 * MS_PER_DAY; + +export type WeekdayName = (typeof WEEKDAY_NAMES)[number]; + /** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */ const TZ_OFFSET = /^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i; @@ -149,6 +167,20 @@ export class DateTime { } const trimmed = raw.trim(); + const dateOnly = ISO_DATE.exec(trimmed); + if (dateOnly) { + return DateTime.fromParts( + Number.parseInt(dateOnly[1], 10), + Number.parseInt(dateOnly[2], 10), + Number.parseInt(dateOnly[3], 10), + 0, + 0, + 0, + 0, + resolveDefaultOffsetMinutes(), + ); + } + const match = ISO_DATETIME.exec(trimmed); if (!match) { throw new InvalidDateTimeError(); @@ -175,7 +207,7 @@ export class DateTime { offsetMinutes = resolveDefaultOffsetMinutes(); } - const utcMs = utcMsFromParts( + return DateTime.fromParts( year, month, day, @@ -185,8 +217,6 @@ export class DateTime { ms, offsetMinutes, ); - - return new DateTime(utcMs, DateTime.createToken); } static fromUnixMs(ms: number): DateTime { @@ -217,6 +247,32 @@ export class DateTime { return formatInOffset(this.unixMs, offsetMinutes); } + startOfDay(): DateTime { + const offsetMinutes = resolveDefaultOffsetMinutes(); + const shifted = new Date(this.unixMs + offsetMinutes * 60_000); + return DateTime.fromParts( + shifted.getUTCFullYear(), + shifted.getUTCMonth() + 1, + shifted.getUTCDate(), + 0, + 0, + 0, + 0, + offsetMinutes, + ); + } + + weekdayName(): WeekdayName { + const offsetMinutes = resolveDefaultOffsetMinutes(); + const shifted = new Date(this.unixMs + offsetMinutes * 60_000); + return WEEKDAY_NAMES[shifted.getUTCDay()]; + } + + wholeWeeksSince(epoch: DateTime): number { + const diff = this.startOfDay().value - epoch.startOfDay().value; + return Math.trunc(diff / MS_PER_WEEK); + } + toString(): string { return this.format(); } @@ -224,4 +280,27 @@ export class DateTime { toJSON(): number { return this.unixMs; } + + private static fromParts( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + ms: number, + offsetMinutes: number, + ): DateTime { + const utcMs = utcMsFromParts( + year, + month, + day, + hour, + minute, + second, + ms, + offsetMinutes, + ); + return new DateTime(utcMs, DateTime.createToken); + } } diff --git a/src/database/company-settings-table.ts b/src/database/company-settings-table.ts new file mode 100644 index 0000000..7bf4004 --- /dev/null +++ b/src/database/company-settings-table.ts @@ -0,0 +1,15 @@ +import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core'; +import { primaryEntityColumns } from './primary-entity-columns'; +import { users } from './schema'; + +/** + * Company-wide operational settings (singleton aggregate). + */ +export const companySettings = pgTable('company_settings', { + id: uuid('id').defaultRandom().notNull().primaryKey(), + cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(), + ...primaryEntityColumns(users), +}); + +export type CompanySettingsRow = typeof companySettings.$inferSelect; +export type NewCompanySettingsRow = typeof companySettings.$inferInsert; diff --git a/src/database/cycles-table.ts b/src/database/cycles-table.ts new file mode 100644 index 0000000..318af0e --- /dev/null +++ b/src/database/cycles-table.ts @@ -0,0 +1,83 @@ +import { sql } from 'drizzle-orm'; +import { + index, + integer, + jsonb, + pgTable, + text, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; +import { branches } from './branches-table'; +import { customers } from './customers-table'; +import { employees } from './employees-table'; +import { primaryEntityColumns } from './primary-entity-columns'; +import { users } from './schema'; + +export type StoredRouteGeometry = { + readonly type: 'LineString'; + readonly coordinates: readonly (readonly [number, number])[]; +}; + +export const cycles = pgTable( + 'cycles', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + employeeId: uuid('employee_id') + .notNull() + .references(() => employees.id, { onDelete: 'restrict' }), + purpose: text('purpose').notNull(), + cycleNumber: integer('cycle_number').notNull(), + ...primaryEntityColumns(users), + }, + (t) => [ + uniqueIndex('cycles_employee_purpose_number_live_unique') + .on(t.employeeId, t.purpose, t.cycleNumber) + .where(sql`${t.status} <> 'archived'`), + index('cycles_employee_id_idx').on(t.employeeId), + ], +); + +export const cycleWeekdays = pgTable( + 'cycle_weekdays', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + cycleId: uuid('cycle_id') + .notNull() + .references(() => cycles.id, { onDelete: 'cascade' }), + weekday: text('weekday').notNull(), + startBranchId: uuid('start_branch_id') + .notNull() + .references(() => branches.id, { onDelete: 'restrict' }), + endBranchId: uuid('end_branch_id') + .notNull() + .references(() => branches.id, { onDelete: 'restrict' }), + routeGeometry: jsonb('route_geometry') + .$type() + .notNull(), + }, + (t) => [ + uniqueIndex('cycle_weekdays_cycle_weekday_unique').on(t.cycleId, t.weekday), + index('cycle_weekdays_cycle_id_idx').on(t.cycleId), + ], +); + +export const cycleDestinations = pgTable( + 'cycle_destinations', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + cycleWeekdayId: uuid('cycle_weekday_id') + .notNull() + .references(() => cycleWeekdays.id, { onDelete: 'cascade' }), + customerId: uuid('customer_id') + .notNull() + .references(() => customers.id, { onDelete: 'restrict' }), + sortOrder: integer('sort_order').notNull(), + }, + (t) => [index('cycle_destinations_weekday_id_idx').on(t.cycleWeekdayId)], +); + +export type CycleRow = typeof cycles.$inferSelect; +export type NewCycleRow = typeof cycles.$inferInsert; +export type CycleWeekdayRow = typeof cycleWeekdays.$inferSelect; +export type CycleDestinationRow = typeof cycleDestinations.$inferSelect; diff --git a/src/database/plans-table.ts b/src/database/plans-table.ts new file mode 100644 index 0000000..e535841 --- /dev/null +++ b/src/database/plans-table.ts @@ -0,0 +1,111 @@ +import { sql } from 'drizzle-orm'; +import { + bigint, + index, + integer, + jsonb, + pgTable, + text, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; +import { branches } from './branches-table'; +import { customers } from './customers-table'; +import { employees } from './employees-table'; +import { packingSlips } from './packing-slips-table'; +import { primaryEntityColumns } from './primary-entity-columns'; +import { salesInvoices } from './sales-invoices-table'; +import { users } from './schema'; +import type { StoredRouteGeometry } from './cycles-table'; + +export const plans = pgTable( + 'plans', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + employeeId: uuid('employee_id') + .notNull() + .references(() => employees.id, { onDelete: 'restrict' }), + purpose: text('purpose').notNull(), + date: bigint('date', { mode: 'number' }).notNull(), + startBranchId: uuid('start_branch_id') + .notNull() + .references(() => branches.id, { onDelete: 'restrict' }), + endBranchId: uuid('end_branch_id') + .notNull() + .references(() => branches.id, { onDelete: 'restrict' }), + routeGeometry: jsonb('route_geometry') + .$type() + .notNull(), + ...primaryEntityColumns(users), + }, + (t) => [ + uniqueIndex('plans_employee_purpose_date_live_unique') + .on(t.employeeId, t.purpose, t.date) + .where(sql`${t.status} <> 'archived'`), + index('plans_employee_id_idx').on(t.employeeId), + ], +); + +export const planDestinations = pgTable( + 'plan_destinations', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + planId: uuid('plan_id') + .notNull() + .references(() => plans.id, { onDelete: 'cascade' }), + customerId: uuid('customer_id') + .notNull() + .references(() => customers.id, { onDelete: 'restrict' }), + sortOrder: integer('sort_order').notNull(), + }, + (t) => [ + uniqueIndex('plan_destinations_plan_customer_unique').on( + t.planId, + t.customerId, + ), + index('plan_destinations_plan_id_idx').on(t.planId), + ], +); + +export const planInvoices = pgTable( + 'plan_invoices', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + planId: uuid('plan_id') + .notNull() + .references(() => plans.id, { onDelete: 'cascade' }), + invoiceId: uuid('invoice_id') + .notNull() + .references(() => salesInvoices.id, { onDelete: 'restrict' }), + }, + (t) => [ + uniqueIndex('plan_invoices_plan_invoice_unique').on(t.planId, t.invoiceId), + index('plan_invoices_plan_id_idx').on(t.planId), + ], +); + +export const planPackingSlips = pgTable( + 'plan_packing_slips', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + planId: uuid('plan_id') + .notNull() + .references(() => plans.id, { onDelete: 'cascade' }), + packingSlipId: uuid('packing_slip_id') + .notNull() + .references(() => packingSlips.id, { onDelete: 'restrict' }), + }, + (t) => [ + uniqueIndex('plan_packing_slips_plan_slip_unique').on( + t.planId, + t.packingSlipId, + ), + index('plan_packing_slips_plan_id_idx').on(t.planId), + ], +); + +export type PlanRow = typeof plans.$inferSelect; +export type NewPlanRow = typeof plans.$inferInsert; +export type PlanDestinationRow = typeof planDestinations.$inferSelect; +export type PlanInvoiceRow = typeof planInvoices.$inferSelect; +export type PlanPackingSlipRow = typeof planPackingSlips.$inferSelect; diff --git a/src/database/schema.ts b/src/database/schema.ts index ad37520..bec2507 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -210,3 +210,29 @@ export { type SalesPaymentInvoiceRow, type SalesPaymentRow, } from './sales-payments-table'; + +export { + companySettings, + type CompanySettingsRow, + type NewCompanySettingsRow, +} from './company-settings-table'; +export { + cycleDestinations, + cycleWeekdays, + cycles, + type CycleDestinationRow, + type CycleRow, + type CycleWeekdayRow, + type NewCycleRow, +} from './cycles-table'; +export { + planDestinations, + planInvoices, + planPackingSlips, + plans, + type NewPlanRow, + type PlanDestinationRow, + type PlanInvoiceRow, + type PlanPackingSlipRow, + type PlanRow, +} from './plans-table'; diff --git a/src/main.ts b/src/main.ts index c3275a5..5253766 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ async function bootstrap() { const env = loadEnv(); const app = await NestFactory.create(AppModule); configureApp(app); + app.enableCors(); await app.listen(env.PORT); } void bootstrap(); diff --git a/src/modules/configuration/branches/branches.service.ts b/src/modules/configuration/branches/branches.service.ts index 7cd44b6..5930c58 100644 --- a/src/modules/configuration/branches/branches.service.ts +++ b/src/modules/configuration/branches/branches.service.ts @@ -113,6 +113,16 @@ export class BranchesService { return this.toListItem(branch); } + async findByCode( + code: string, + ): Promise> { + const branch = await this.branchesRepository.findByCode(code); + if (!branch) { + throw new NotFoundException('Branch not found'); + } + return this.toListItem(branch); + } + async create(input: { code: string; name: string; diff --git a/src/modules/configuration/customers/customers.repository.ts b/src/modules/configuration/customers/customers.repository.ts index abf780e..8efff61 100644 --- a/src/modules/configuration/customers/customers.repository.ts +++ b/src/modules/configuration/customers/customers.repository.ts @@ -78,6 +78,20 @@ export class CustomersRepository { return this.toDomain(row, contacts); } + async findByCode(code: string): Promise { + const rows: CustomerRow[] = await this.db + .select() + .from(customers) + .where(eq(customers.code, code)) + .limit(1); + const row = rows[0]; + if (!row) { + return null; + } + const contacts = await this.selectContacts(this.db, row.id); + return this.toDomain(row, contacts); + } + async create(input: CreateCustomerInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = input.status ?? Status.create(Status.DEFAULT); diff --git a/src/modules/configuration/customers/customers.service.ts b/src/modules/configuration/customers/customers.service.ts index 21853f9..702ff08 100644 --- a/src/modules/configuration/customers/customers.service.ts +++ b/src/modules/configuration/customers/customers.service.ts @@ -104,6 +104,16 @@ export class CustomersService { return this.toDetail(customer); } + async findByCode( + code: string, + ): Promise> { + const customer = await this.customersRepository.findByCode(code); + if (!customer) { + throw new NotFoundException('Customer not found'); + } + return this.toDetail(customer); + } + async create(input: { code: string; name: string; diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts index 72adc2d..0069da9 100644 --- a/src/modules/configuration/employees/employees.service.ts +++ b/src/modules/configuration/employees/employees.service.ts @@ -83,6 +83,16 @@ export class EmployeesService { return this.toListItem(employee); } + async findByCode( + code: string, + ): Promise> { + const employee = await this.employeesRepository.findByCode(code); + if (!employee) { + throw new NotFoundException('Employee not found'); + } + return this.toListItem(employee); + } + async create(input: { code: string; name: string; diff --git a/src/modules/field/cycles/cycle.ts b/src/modules/field/cycles/cycle.ts new file mode 100644 index 0000000..8ab5c5c --- /dev/null +++ b/src/modules/field/cycles/cycle.ts @@ -0,0 +1,69 @@ +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; +import type { FieldPurpose, WeekdayName } from '../shared/field-purpose'; +import type { RouteGeometry } from '../shared/route-line-string'; +import type { WeekdaysInput } from '../shared/field-fields'; + +export type CycleDestination = { + readonly id: string; + readonly customerId: string; + readonly sortOrder: number; +}; + +export type CycleWeekday = { + readonly id: string; + readonly weekday: WeekdayName; + readonly startBranchId: string; + readonly endBranchId: string; + readonly routeGeometry: RouteGeometry; + readonly destinations: readonly CycleDestination[]; +}; + +export type Cycle = { + readonly id: string; + readonly employeeId: string; + readonly purpose: FieldPurpose; + readonly cycleNumber: number; + readonly weekdays: readonly CycleWeekday[]; + readonly status: Status; + readonly createdAt: DateTime; + readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; +}; + +export type CreateCycleInput = { + readonly employeeId: string; + readonly purpose: FieldPurpose; + readonly cycleNumber: number; + readonly weekdays: WeekdaysInput; + readonly status?: Status; + readonly userId: string; +}; + +export type UpdateCycleInput = { + readonly employeeId?: string; + readonly purpose?: FieldPurpose; + readonly cycleNumber?: number; + readonly weekdays?: WeekdaysInput; + readonly userId: string; +}; + +export type ListCyclesFilters = { + readonly employeeId?: string; + readonly purpose?: string; + readonly cycleNumber?: number; + readonly status?: string; + readonly search?: string; + readonly purposes?: readonly string[]; + readonly limit: number; + readonly offset: number; +}; + +export type PersistableWeekday = { + readonly weekday: WeekdayName; + readonly startBranchId: string; + readonly endBranchId: string; + readonly routeGeometry: RouteGeometry; + readonly customerIds: readonly string[]; +}; diff --git a/src/modules/field/cycles/cycles-read.controller.spec.ts b/src/modules/field/cycles/cycles-read.controller.spec.ts new file mode 100644 index 0000000..d522cb8 --- /dev/null +++ b/src/modules/field/cycles/cycles-read.controller.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { CyclesReadController } from './cycles-read.controller'; +import { CyclesService } from './cycles.service'; + +describe('CyclesReadController', () => { + let controller: CyclesReadController; + const service = { list: jest.fn(), findById: jest.fn() }; + const user = { id: 'user-1', isSuperadmin: true }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [CyclesReadController], + providers: [{ provide: CyclesService, useValue: service }], + }) + .overrideGuard(FieldPrivilegeGuard) + .useValue({ canActivate: () => true }) + .compile(); + controller = moduleRef.get(CyclesReadController); + }); + + it('list delegates to the service', async () => { + service.list.mockResolvedValue({ data: [], total: 0 }); + await expect(controller.list({ page: 1 }, user as never)).resolves.toEqual({ + data: [], + total: 0, + }); + }); + + it('findOne delegates to the service', async () => { + service.findById.mockResolvedValue({ id: 'cyc-1' }); + await expect(controller.findOne('cyc-1', user as never)).resolves.toEqual({ + id: 'cyc-1', + }); + }); +}); diff --git a/src/modules/field/cycles/cycles-read.controller.ts b/src/modules/field/cycles/cycles-read.controller.ts new file mode 100644 index 0000000..1159225 --- /dev/null +++ b/src/modules/field/cycles/cycles-read.controller.ts @@ -0,0 +1,77 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import { + Pagination, + type PaginationResponse, + PaginationMetaDto, +} from '../../../common/http/response'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { RequireFieldPrivilege } from '../shared/field-privilege.decorator'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { CyclesService } from './cycles.service'; +import { CycleDto, ListCyclesQueryDto } from './dto/cycle.dto'; + +@ApiTags('cycles') +@ApiBearerAuth(BEARER_AUTH_NAME) +@UseGuards(FieldPrivilegeGuard) +@Controller('cycles') +export class CyclesReadController { + constructor(private readonly cyclesService: CyclesService) {} + + @Get() + @Pagination() + @RequireFieldPrivilege('cycle', 'view') + @ApiOperation({ summary: 'List cycles' }) + @ApiOkResponse({ + schema: { + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/CycleDto' }, + }, + meta: { $ref: '#/components/schemas/PaginationMetaDto' }, + }, + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + list( + @Query() query: ListCyclesQueryDto, + @CurrentUser() user: AuthUser, + ): Promise> { + return this.cyclesService.list(query, user); + } + + @Get(':id') + @RequireFieldPrivilege('cycle', 'view') + @ApiOperation({ summary: 'Get cycle detail' }) + @ApiOkResponse({ type: CycleDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.cyclesService.findById(id, user); + } +} + +void PaginationMetaDto; diff --git a/src/modules/field/cycles/cycles-write.controller.spec.ts b/src/modules/field/cycles/cycles-write.controller.spec.ts new file mode 100644 index 0000000..a36e4d7 --- /dev/null +++ b/src/modules/field/cycles/cycles-write.controller.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { CyclesWriteController } from './cycles-write.controller'; +import { CyclesService } from './cycles.service'; + +describe('CyclesWriteController', () => { + let controller: CyclesWriteController; + const service = { + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + delete: jest.fn(), + bulkDelete: jest.fn(), + bulkUpdateStatus: jest.fn(), + importCsv: jest.fn(), + }; + const user = { + id: 'user-1', + username: 'alice', + jti: 'j', + isSuperadmin: true, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [CyclesWriteController], + providers: [{ provide: CyclesService, useValue: service }], + }) + .overrideGuard(FieldPrivilegeGuard) + .useValue({ canActivate: () => true }) + .compile(); + controller = moduleRef.get(CyclesWriteController); + }); + + it('create passes dto fields and user id', async () => { + service.create.mockResolvedValue({ id: 'cyc-1' }); + await controller.create( + { + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: {}, + }, + 'user-1', + ); + expect(service.create).toHaveBeenCalledWith({ + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: {}, + status: undefined, + userId: 'user-1', + }); + }); + + it('delete archives through the service', async () => { + service.delete.mockResolvedValue(undefined); + await controller.delete('cyc-1', user as never); + expect(service.delete).toHaveBeenCalledWith('cyc-1', user); + }); +}); diff --git a/src/modules/field/cycles/cycles-write.controller.ts b/src/modules/field/cycles/cycles-write.controller.ts new file mode 100644 index 0000000..dbef167 --- /dev/null +++ b/src/modules/field/cycles/cycles-write.controller.ts @@ -0,0 +1,193 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { isAllowedCsvUpload } from '../shared/field-fields'; +import { RequireFieldPrivilege } from '../shared/field-privilege.decorator'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { CyclesService } from './cycles.service'; +import { + BulkIdsDto, + BulkStatusDto, + CreateCycleDto, + CycleDto, + UpdateCycleDto, + UpdateCycleStatusDto, +} from './dto/cycle.dto'; + +@ApiTags('cycles') +@ApiBearerAuth(BEARER_AUTH_NAME) +@UseGuards(FieldPrivilegeGuard) +@Controller('cycles') +export class CyclesWriteController { + constructor(private readonly cyclesService: CyclesService) {} + + @Post('import') + @RequireFieldPrivilege('cycle', 'import') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 1_048_576 }, + fileFilter: (_req, file, cb) => { + if (!isAllowedCsvUpload(file)) { + cb(new BadRequestException('Only CSV files are allowed'), false); + return; + } + cb(null, true); + }, + }), + ) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + required: ['file'], + }, + }) + @ApiOperation({ summary: 'Import cycles from CSV' }) + @ApiOkResponse({ schema: { properties: { imported: { type: 'number' } } } }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + importCsv( + @UploadedFile() file: { buffer?: Buffer } | undefined, + @CurrentUser('id') userId: string, + ): Promise<{ imported: number }> { + const csv = file?.buffer?.toString('utf8') ?? ''; + return this.cyclesService.importCsv(csv, userId); + } + + @Post('bulk-delete') + @HttpCode(200) + @RequireFieldPrivilege('cycle', 'delete') + @ApiOperation({ summary: 'Bulk archive cycles' }) + @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkDelete( + @Body() dto: BulkIdsDto, + @CurrentUser() user: AuthUser, + ): Promise<{ deleted: number }> { + return this.cyclesService.bulkDelete(dto.ids, user); + } + + @Post('bulk-status') + @HttpCode(200) + @RequireFieldPrivilege('cycle', 'update') + @ApiOperation({ summary: 'Bulk update cycle status' }) + @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkStatus( + @Body() dto: BulkStatusDto, + @CurrentUser() user: AuthUser, + ): Promise<{ updated: number }> { + return this.cyclesService.bulkUpdateStatus( + dto.ids, + dto.status, + user.id, + user, + ); + } + + @Post() + @RequireFieldPrivilege('cycle', 'create') + @ApiOperation({ summary: 'Create cycle' }) + @ApiCreatedResponse({ type: CycleDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + create( + @Body() dto: CreateCycleDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.cyclesService.create({ + employeeId: dto.employeeId, + purpose: dto.purpose, + cycleNumber: dto.cycleNumber, + weekdays: dto.weekdays, + status: dto.status, + userId, + }); + } + + @Patch(':id/status') + @RequireFieldPrivilege('cycle', 'update') + @ApiOperation({ summary: 'Update cycle status' }) + @ApiOkResponse({ type: CycleDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCycleStatusDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.cyclesService.updateStatus(id, dto.status, user.id, user); + } + + @Patch(':id') + @RequireFieldPrivilege('cycle', 'update') + @ApiOperation({ summary: 'Update cycle (not status)' }) + @ApiOkResponse({ type: CycleDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCycleDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.cyclesService.update( + id, + { + employeeId: dto.employeeId, + purpose: dto.purpose, + cycleNumber: dto.cycleNumber, + weekdays: dto.weekdays, + userId: user.id, + }, + user, + ); + } + + @Delete(':id') + @HttpCode(204) + @RequireFieldPrivilege('cycle', 'delete') + @ApiOperation({ summary: 'Archive cycle' }) + @ApiNoContentResponse() + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async delete( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ): Promise { + await this.cyclesService.delete(id, user); + } +} diff --git a/src/modules/field/cycles/cycles.repository.spec.ts b/src/modules/field/cycles/cycles.repository.spec.ts new file mode 100644 index 0000000..3d58ba4 --- /dev/null +++ b/src/modules/field/cycles/cycles.repository.spec.ts @@ -0,0 +1,77 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Status } from '../../../common/value-objects/status/status'; +import { DRIZZLE } from '../../../database/database.module'; +import { CyclesRepository } from './cycles.repository'; + +describe('CyclesRepository', () => { + let repository: CyclesRepository; + const limit = jest.fn(); + const where = jest.fn(); + const from = jest.fn(); + const select = jest.fn(); + const returning = jest.fn(); + const values = jest.fn(); + const insert = jest.fn(); + const set = jest.fn(); + const update = jest.fn(); + const transaction = jest.fn(); + const orderBy = jest.fn(); + const offset = jest.fn(); + const $dynamic = jest.fn(); + + const db = { + select, + insert, + update, + transaction, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + where.mockImplementation(() => ({ limit, orderBy, returning })); + orderBy.mockImplementation(() => ({ limit })); + limit.mockImplementation(() => ({ offset })); + offset.mockResolvedValue([]); + from.mockImplementation(() => ({ where, $dynamic })); + $dynamic.mockReturnValue({ where }); + select.mockImplementation(() => ({ from })); + values.mockReturnValue({ returning }); + insert.mockReturnValue({ values }); + set.mockReturnValue({ where }); + update.mockReturnValue({ set }); + returning.mockResolvedValue([]); + transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db)); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [CyclesRepository, { provide: DRIZZLE, useValue: db }], + }).compile(); + repository = moduleRef.get(CyclesRepository); + }); + + it('extendListQuery is a passthrough hook', () => { + const qb = { tag: 'qb' }; + expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb); + }); + + it('create maps unique violations to ConflictException', async () => { + transaction.mockRejectedValue({ code: '23505' }); + await expect( + repository.create({ + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: [], + status: Status.create('draft'), + userId: 'user-1', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('archive throws when no row is updated', async () => { + returning.mockResolvedValue([]); + await expect( + repository.archive('missing', 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/modules/field/cycles/cycles.repository.ts b/src/modules/field/cycles/cycles.repository.ts new file mode 100644 index 0000000..d029dfb --- /dev/null +++ b/src/modules/field/cycles/cycles.repository.ts @@ -0,0 +1,451 @@ +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 { + cycleDestinations, + cycleWeekdays, + cycles, + type CycleDestinationRow, + type CycleRow, + type CycleWeekdayRow, +} from '../../../database/cycles-table'; +import type { FieldPurpose, WeekdayName } from '../shared/field-purpose'; +import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle'; + +type QueryExecutor = Pick; + +@Injectable() +export class CyclesRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async list( + filters: ListCyclesFilters, + ): Promise<{ data: Cycle[]; total: number }> { + const where = this.buildListWhere(filters); + const totalRows = await this.db + .select({ total: count() }) + .from(cycles) + .where(where); + let qb = this.db.select().from(cycles).$dynamic(); + qb = this.extendListQuery(qb, filters); + const rows = await qb + .where(where) + .orderBy(asc(cycles.cycleNumber)) + .limit(filters.limit) + .offset(filters.offset); + return { + data: rows.map((row) => this.toDomain(row, [], [])), + total: Number(totalRows[0]?.total ?? 0), + }; + } + + extendListQuery(qb: T, filters: ListCyclesFilters): T { + void filters; + return qb; + } + + async findById(id: string): Promise { + const rows: CycleRow[] = await this.db + .select() + .from(cycles) + .where(eq(cycles.id, id)) + .limit(1); + const row = rows[0]; + if (!row) { + return null; + } + const weekdays = await this.selectWeekdays(this.db, id); + const destinations = await this.selectDestinations( + this.db, + weekdays.map((weekday) => weekday.id), + ); + return this.toDomain(row, weekdays, destinations); + } + + async findLiveByKey( + employeeId: string, + purpose: string, + cycleNumber: number, + excludeId?: string, + ): Promise { + const parts: SQL[] = [ + eq(cycles.employeeId, employeeId), + eq(cycles.purpose, purpose), + eq(cycles.cycleNumber, cycleNumber), + ne(cycles.status, 'archived'), + ]; + if (excludeId) { + parts.push(ne(cycles.id, excludeId)); + } + const rows = await this.db + .select() + .from(cycles) + .where(and(...parts)) + .limit(1); + const row = rows[0]; + return row ? this.toDomain(row, [], []) : null; + } + + async listActiveByEmployeePurpose( + employeeId: string, + purpose: string, + ): Promise { + const rows = await this.db + .select() + .from(cycles) + .where( + and( + eq(cycles.employeeId, employeeId), + eq(cycles.purpose, purpose), + eq(cycles.status, 'active'), + ), + ) + .orderBy(asc(cycles.cycleNumber)); + const result: Cycle[] = []; + for (const row of rows) { + const weekdays = await this.selectWeekdays(this.db, row.id); + const destinations = await this.selectDestinations( + this.db, + weekdays.map((weekday) => weekday.id), + ); + result.push(this.toDomain(row, weekdays, destinations)); + } + return result; + } + + async create(input: { + employeeId: string; + purpose: FieldPurpose; + cycleNumber: number; + weekdays: readonly PersistableWeekday[]; + status: Status; + userId: string; + }): Promise { + const now = DateTime.fromUnixMs(Date.now()); + try { + return await this.db.transaction(async (tx) => { + const inserted = await tx + .insert(cycles) + .values({ + employeeId: input.employeeId, + purpose: input.purpose, + cycleNumber: input.cycleNumber, + status: input.status.value, + createdAt: now.value, + updatedAt: now.value, + createdBy: input.userId, + updatedBy: input.userId, + }) + .returning(); + const row = inserted[0]; + await this.replaceWeekdays(tx, row.id, input.weekdays); + return this.loadWithChildren(tx, row); + }); + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async update( + id: string, + input: { + employeeId: string; + purpose: FieldPurpose; + cycleNumber: number; + weekdays?: readonly PersistableWeekday[]; + userId: string; + }, + ): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new NotFoundException('Cycle not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + try { + return await this.db.transaction(async (tx) => { + const updated = await tx + .update(cycles) + .set({ + employeeId: input.employeeId, + purpose: input.purpose, + cycleNumber: input.cycleNumber, + updatedAt: now.value, + updatedBy: input.userId, + }) + .where(eq(cycles.id, id)) + .returning(); + const row = updated[0]; + if (input.weekdays) { + await this.replaceWeekdays(tx, id, input.weekdays); + } + return this.loadWithChildren(tx, row); + }); + } 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(cycles) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(eq(cycles.id, id)) + .returning({ id: cycles.id }); + if (updated.length === 0) { + throw new NotFoundException('Cycle not found'); + } + const found = await this.findById(id); + if (!found) { + throw new NotFoundException('Cycle not found'); + } + return found; + } + + 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(cycles) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(inArray(cycles.id, ids)) + .returning({ id: cycles.id }); + return rows.length; + } + + async archive(id: string, userId: string): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const updated = await this.db + .update(cycles) + .set({ + status: 'archived', + updatedAt: now.value, + updatedBy: userId, + }) + .where(eq(cycles.id, id)) + .returning({ id: cycles.id }); + if (updated.length === 0) { + throw new NotFoundException('Cycle not found'); + } + } + + async bulkArchive(ids: string[], userId: string): Promise { + if (ids.length === 0) { + return 0; + } + const now = DateTime.fromUnixMs(Date.now()); + const rows = await this.db + .update(cycles) + .set({ + status: 'archived', + updatedAt: now.value, + updatedBy: userId, + }) + .where(inArray(cycles.id, ids)) + .returning({ id: cycles.id }); + return rows.length; + } + + private async loadWithChildren( + executor: QueryExecutor, + row: CycleRow, + ): Promise { + const weekdays = await this.selectWeekdays(executor, row.id); + const destinations = await this.selectDestinations( + executor, + weekdays.map((weekday) => weekday.id), + ); + return this.toDomain(row, weekdays, destinations); + } + + private async replaceWeekdays( + executor: QueryExecutor, + cycleId: string, + weekdays: readonly PersistableWeekday[], + ): Promise { + await executor + .delete(cycleWeekdays) + .where(eq(cycleWeekdays.cycleId, cycleId)); + for (const weekday of weekdays) { + const inserted = await executor + .insert(cycleWeekdays) + .values({ + cycleId, + weekday: weekday.weekday, + startBranchId: weekday.startBranchId, + endBranchId: weekday.endBranchId, + routeGeometry: weekday.routeGeometry, + }) + .returning(); + const weekdayRow = inserted[0]; + if (weekday.customerIds.length === 0) { + continue; + } + await executor.insert(cycleDestinations).values( + weekday.customerIds.map((customerId, index) => ({ + cycleWeekdayId: weekdayRow.id, + customerId, + sortOrder: index, + })), + ); + } + } + + private async selectWeekdays( + executor: QueryExecutor, + cycleId: string, + ): Promise { + return executor + .select() + .from(cycleWeekdays) + .where(eq(cycleWeekdays.cycleId, cycleId)) + .orderBy(asc(cycleWeekdays.weekday)); + } + + private async selectDestinations( + executor: QueryExecutor, + weekdayIds: string[], + ): Promise { + if (weekdayIds.length === 0) { + return []; + } + return executor + .select() + .from(cycleDestinations) + .where(inArray(cycleDestinations.cycleWeekdayId, weekdayIds)) + .orderBy(asc(cycleDestinations.sortOrder)); + } + + private buildListWhere(filters: ListCyclesFilters): SQL | undefined { + const parts: SQL[] = []; + if (filters.employeeId) { + parts.push(eq(cycles.employeeId, filters.employeeId)); + } + if (filters.purpose) { + parts.push(eq(cycles.purpose, filters.purpose)); + } else if (filters.purposes && filters.purposes.length > 0) { + parts.push(inArray(cycles.purpose, [...filters.purposes])); + } + if (filters.cycleNumber !== undefined) { + parts.push(eq(cycles.cycleNumber, filters.cycleNumber)); + } + if (filters.status) { + parts.push(eq(cycles.status, filters.status)); + } + if (filters.search) { + const search = or( + ilike(cycles.purpose, `%${filters.search}%`), + sql`${cycles.cycleNumber}::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: CycleRow, + weekdayRows: CycleWeekdayRow[], + destinationRows: CycleDestinationRow[], + ): Cycle { + const destinationsByWeekday = new Map(); + for (const destination of destinationRows) { + const list = destinationsByWeekday.get(destination.cycleWeekdayId) ?? []; + destinationsByWeekday.set(destination.cycleWeekdayId, [ + ...list, + destination, + ]); + } + return { + id: row.id, + employeeId: row.employeeId, + purpose: row.purpose as FieldPurpose, + cycleNumber: row.cycleNumber, + weekdays: weekdayRows.map((weekday) => ({ + id: weekday.id, + weekday: weekday.weekday as WeekdayName, + startBranchId: weekday.startBranchId, + endBranchId: weekday.endBranchId, + routeGeometry: weekday.routeGeometry, + destinations: (destinationsByWeekday.get(weekday.id) ?? []).map( + (destination) => ({ + id: destination.id, + customerId: destination.customerId, + sortOrder: destination.sortOrder, + }), + ), + })), + 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('Cycle already exists for this employee'); + } + if (err.code === '23503') { + throw new ConflictException('Cycle 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 }; + } +} diff --git a/src/modules/field/cycles/cycles.service.spec.ts b/src/modules/field/cycles/cycles.service.spec.ts new file mode 100644 index 0000000..b3805ae --- /dev/null +++ b/src/modules/field/cycles/cycles.service.spec.ts @@ -0,0 +1,180 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; +import { BranchesService } from '../../configuration/branches/branches.service'; +import { CustomersService } from '../../configuration/customers/customers.service'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import { PrivilegesService } from '../../privileges/privileges.service'; +import type { Cycle } from './cycle'; +import { CyclesRepository } from './cycles.repository'; +import { CyclesService } from './cycles.service'; + +describe('CyclesService', () => { + let service: CyclesService; + let repository: jest.Mocked< + Pick< + CyclesRepository, + | 'list' + | 'findById' + | 'findLiveByKey' + | 'create' + | 'update' + | 'updateStatus' + | 'bulkUpdateStatus' + | 'archive' + | 'bulkArchive' + > + >; + const employeesService = { findById: jest.fn(), findByCode: jest.fn() }; + const branchesService = { findById: jest.fn(), findByCode: jest.fn() }; + const customersService = { findById: jest.fn(), findByCode: jest.fn() }; + const privilegesService = { checkPermission: jest.fn() }; + + const now = DateTime.fromUnixMs(1_700_000_000_000); + const user: AuthUser = { + id: 'user-1', + username: 'alice', + jti: 'jti-1', + isSuperadmin: true, + }; + const geometry = { + type: 'LineString' as const, + coordinates: [ + [106.8, -6.2], + [106.9, -6.3], + [107.0, -6.4], + ] as const, + }; + const sample: Cycle = { + id: 'cyc-1', + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: [ + { + id: 'wd-1', + weekday: 'monday', + startBranchId: 'br-1', + endBranchId: 'br-2', + routeGeometry: geometry, + destinations: [{ id: 'dest-1', customerId: 'cus-1', sortOrder: 0 }], + }, + ], + status: Status.create('draft'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const located = { id: 'x', latitude: -6.2, longitude: 106.8 }; + + beforeEach(async () => { + jest.clearAllMocks(); + repository = { + list: jest.fn(), + findById: jest.fn(), + findLiveByKey: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + bulkUpdateStatus: jest.fn(), + archive: jest.fn(), + bulkArchive: jest.fn(), + }; + employeesService.findById.mockResolvedValue({ id: 'emp-1' }); + branchesService.findById.mockResolvedValue(located); + customersService.findById.mockResolvedValue(located); + repository.findLiveByKey.mockResolvedValue(null); + repository.create.mockResolvedValue(sample); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + CyclesService, + { provide: CyclesRepository, useValue: repository }, + { provide: EmployeesService, useValue: employeesService }, + { provide: BranchesService, useValue: branchesService }, + { provide: CustomersService, useValue: customersService }, + { provide: PrivilegesService, useValue: privilegesService }, + ], + }).compile(); + service = moduleRef.get(CyclesService); + }); + + it('create rejects incomplete weekdays', async () => { + await expect( + service.create({ + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: { + monday: { + startBranchId: 'br-1', + endBranchId: 'br-2', + customerIds: [], + }, + }, + userId: 'user-1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('create rejects duplicate live keys', async () => { + repository.findLiveByKey.mockResolvedValue(sample); + await expect( + service.create({ + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: { + monday: { + startBranchId: 'br-1', + endBranchId: 'br-2', + customerIds: ['cus-1'], + }, + }, + userId: 'user-1', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('create persists a complete weekday and builds geometry', async () => { + await service.create({ + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: { + monday: { + startBranchId: 'br-1', + endBranchId: 'br-2', + customerIds: ['cus-1'], + }, + }, + userId: 'user-1', + }); + expect(repository.create).toHaveBeenCalled(); + const arg = repository.create.mock.calls[0][0]; + expect(arg.weekdays[0].weekday).toBe('monday'); + expect(arg.weekdays[0].routeGeometry.type).toBe('LineString'); + }); + + it('delete archives instead of hard-deleting', async () => { + repository.findById.mockResolvedValue(sample); + repository.archive.mockResolvedValue(undefined); + await service.delete('cyc-1', user); + expect(repository.archive).toHaveBeenCalledWith('cyc-1', 'user-1'); + }); + + it('findById throws when missing', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.findById('missing', user)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/src/modules/field/cycles/cycles.service.ts b/src/modules/field/cycles/cycles.service.ts new file mode 100644 index 0000000..7c816d0 --- /dev/null +++ b/src/modules/field/cycles/cycles.service.ts @@ -0,0 +1,537 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import type { PaginationResponse } from '../../../common/http/response'; +import { toListPage } from '../../../common/http/response'; +import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error'; +import { Status } from '../../../common/value-objects/status/status'; +import { BranchesService } from '../../configuration/branches/branches.service'; +import { CustomersService } from '../../configuration/customers/customers.service'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import { PrivilegesService } from '../../privileges/privileges.service'; +import { + isValidCycleNumber, + parseCsvRecord, + parseWeekdaysInput, + type WeekdaysInput, +} from '../shared/field-fields'; +import { + fieldPrivilegeKey, + isFieldPurpose, + WEEKDAY_NAMES, + type FieldPurpose, + type WeekdayName, +} from '../shared/field-purpose'; +import { + buildRouteLineString, + isUsableRouteGeometry, +} from '../shared/route-line-string'; +import type { Cycle, PersistableWeekday } from './cycle'; +import { CyclesRepository } from './cycles.repository'; + +export type ListCyclesQuery = { + readonly employeeId?: string; + readonly purpose?: string; + readonly cycleNumber?: number; + readonly status?: string; + readonly search?: string; + readonly page?: number; + readonly limit?: number; + readonly offset?: number; +}; + +const VISIBLE_FIELDS = [ + 'id', + 'employeeId', + 'purpose', + 'cycleNumber', + 'weekdays', + 'status', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +const CSV_REQUIRED_HEADERS = [ + 'employeeCode', + 'purpose', + 'cycleNumber', + 'weekday', + 'customerCodes', + 'startBranchCode', + 'endBranchCode', +] as const; + +@Injectable() +export class CyclesService { + constructor( + private readonly cyclesRepository: CyclesRepository, + private readonly employeesService: EmployeesService, + private readonly branchesService: BranchesService, + private readonly customersService: CustomersService, + private readonly privilegesService: PrivilegesService, + ) {} + + async list( + query: ListCyclesQuery, + user: AuthUser, + ): Promise>> { + const purposes = await this.allowedPurposes(user, 'view'); + if (query.purpose && !purposes.includes(query.purpose as FieldPurpose)) { + throw new ForbiddenException('Insufficient privilege'); + } + const page = toListPage(query); + const { data, total } = await this.cyclesRepository.list({ + employeeId: query.employeeId, + purpose: query.purpose, + cycleNumber: query.cycleNumber, + status: query.status, + search: query.search, + purposes: query.purpose ? undefined : purposes, + limit: page.limit, + offset: page.offset, + }); + return { data: data.map((item) => this.toItem(item)), total }; + } + + async findById( + id: string, + user: AuthUser, + ): Promise> { + const cycle = await this.requireCycle(id); + await this.assertCanAccess(user, cycle.purpose, 'view'); + return this.toItem(cycle); + } + + async create(input: { + employeeId: string; + purpose: string; + cycleNumber: number; + weekdays: unknown; + status?: string; + userId: string; + }): Promise> { + const created = await this.cyclesRepository.create( + await this.toCreatePayload(input), + ); + return this.toItem(created); + } + + async update( + id: string, + input: { + employeeId?: string; + purpose?: string; + cycleNumber?: number; + weekdays?: unknown; + status?: unknown; + userId: string; + }, + user: AuthUser, + ): Promise> { + if (input.status !== undefined) { + throw new BadRequestException('status cannot be updated via PATCH'); + } + const existing = await this.requireCycle(id); + await this.assertCanAccess(user, existing.purpose, 'update'); + const purpose = input.purpose + ? this.assertPurpose(input.purpose) + : existing.purpose; + const employeeId = input.employeeId ?? existing.employeeId; + const cycleNumber = input.cycleNumber ?? existing.cycleNumber; + await this.employeesService.findById(employeeId); + await this.assertUnique(employeeId, purpose, cycleNumber, id); + let weekdays: PersistableWeekday[] | undefined; + if (input.weekdays !== undefined) { + try { + weekdays = await this.toPersistableWeekdays( + parseWeekdaysInput(input.weekdays), + ); + } catch { + throw new BadRequestException('Weekday must be complete'); + } + } + const updated = await this.cyclesRepository.update(id, { + employeeId, + purpose, + cycleNumber, + weekdays, + userId: input.userId, + }); + return this.toItem(updated); + } + + async updateStatus( + id: string, + statusRaw: string, + userId: string, + user: AuthUser, + ): Promise> { + const existing = await this.requireCycle(id); + await this.assertCanAccess(user, existing.purpose, 'update'); + const updated = await this.cyclesRepository.updateStatus( + id, + this.assertStatus(statusRaw), + userId, + ); + return this.toItem(updated); + } + + async bulkUpdateStatus( + ids: string[], + statusRaw: string, + userId: string, + user: AuthUser, + ): Promise<{ updated: number }> { + const status = this.assertStatus(statusRaw); + await this.assertIdsAccessible(ids, user, 'update'); + const updated = await this.cyclesRepository.bulkUpdateStatus( + ids, + status, + userId, + ); + return { updated }; + } + + async delete(id: string, user: AuthUser): Promise { + const existing = await this.requireCycle(id); + await this.assertCanAccess(user, existing.purpose, 'delete'); + await this.cyclesRepository.archive(id, user.id); + } + + async bulkDelete( + ids: string[], + user: AuthUser, + ): Promise<{ deleted: number }> { + await this.assertIdsAccessible(ids, user, 'delete'); + const deleted = await this.cyclesRepository.bulkArchive(ids, user.id); + return { deleted }; + } + + async importCsv(csv: string, userId: string): Promise<{ imported: number }> { + const groups = await this.parseImportGroups(csv); + for (const group of groups) { + await this.cyclesRepository.create( + await this.toCreatePayload({ + employeeId: group.employeeId, + purpose: group.purpose, + cycleNumber: group.cycleNumber, + weekdays: group.weekdays, + userId, + }), + ); + } + return { imported: groups.length }; + } + + get visibleFields(): readonly string[] { + return VISIBLE_FIELDS; + } + + toItem(cycle: Cycle) { + return { + id: cycle.id, + employeeId: cycle.employeeId, + purpose: cycle.purpose, + cycleNumber: cycle.cycleNumber, + weekdays: cycle.weekdays.map((weekday) => ({ + id: weekday.id, + weekday: weekday.weekday, + startBranchId: weekday.startBranchId, + endBranchId: weekday.endBranchId, + routeGeometry: weekday.routeGeometry, + destinations: weekday.destinations.map((destination) => ({ + id: destination.id, + customerId: destination.customerId, + sortOrder: destination.sortOrder, + })), + })), + status: cycle.status.value, + createdAt: cycle.createdAt.value, + updatedAt: cycle.updatedAt.value, + createdBy: cycle.createdBy, + updatedBy: cycle.updatedBy, + }; + } + + private async toCreatePayload(input: { + employeeId: string; + purpose: string; + cycleNumber: number; + weekdays: unknown; + status?: string; + userId: string; + }) { + if (!isValidCycleNumber(input.cycleNumber)) { + throw new BadRequestException('Invalid cycle number'); + } + const purpose = this.assertPurpose(input.purpose); + await this.employeesService.findById(input.employeeId); + await this.assertUnique(input.employeeId, purpose, input.cycleNumber); + let weekdaysInput: WeekdaysInput; + try { + weekdaysInput = parseWeekdaysInput(input.weekdays); + } catch { + throw new BadRequestException('Weekday must be complete'); + } + return { + employeeId: input.employeeId, + purpose, + cycleNumber: input.cycleNumber, + weekdays: await this.toPersistableWeekdays(weekdaysInput), + status: input.status + ? this.assertStatus(input.status) + : Status.create(Status.DEFAULT), + userId: input.userId, + }; + } + + private async toPersistableWeekdays( + weekdays: WeekdaysInput, + ): Promise { + const result: PersistableWeekday[] = []; + for (const weekday of WEEKDAY_NAMES) { + const template = weekdays[weekday]; + if (!template) { + continue; + } + const start = await this.branchesService.findById(template.startBranchId); + const end = await this.branchesService.findById(template.endBranchId); + const customers: Array<{ + longitude: number | null; + latitude: number | null; + }> = []; + for (const customerId of template.customerIds) { + customers.push(await this.customersService.findById(customerId)); + } + const geometry = buildRouteLineString([ + { longitude: start.longitude, latitude: start.latitude }, + ...customers.map((customer) => ({ + longitude: customer.longitude, + latitude: customer.latitude, + })), + { longitude: end.longitude, latitude: end.latitude }, + ]); + if (!isUsableRouteGeometry(geometry)) { + throw new BadRequestException('Weekday route is incomplete'); + } + result.push({ + weekday, + startBranchId: template.startBranchId, + endBranchId: template.endBranchId, + routeGeometry: geometry, + customerIds: template.customerIds, + }); + } + return result; + } + + private async parseImportGroups(csv: string): Promise< + Array<{ + employeeId: string; + purpose: FieldPurpose; + cycleNumber: number; + weekdays: WeekdaysInput; + }> + > { + const rawLines = csv.split(/\r?\n/); + const filled = rawLines + .map((line, index) => ({ line: line.trim(), lineNo: index + 1 })) + .filter((entry) => entry.line.length > 0); + if (filled.length === 0) { + throw new BadRequestException('CSV is empty'); + } + const header = parseCsvRecord(filled[0].line).map((h) => + h.trim().toLowerCase(), + ); + const missing = CSV_REQUIRED_HEADERS.filter( + (h) => header.indexOf(h.toLowerCase()) < 0, + ); + if (missing.length > 0) { + throw new BadRequestException('CSV must include required headers'); + } + const idx = (key: string) => header.indexOf(key.toLowerCase()); + const grouped = new Map< + string, + { + employeeId: string; + purpose: FieldPurpose; + cycleNumber: number; + weekdays: WeekdaysInput; + } + >(); + const errors: string[] = []; + for (let i = 1; i < filled.length; i++) { + const cols = parseCsvRecord(filled[i].line); + const rowNum = filled[i].lineNo; + try { + const employee = await this.employeesService.findByCode( + cols[idx('employeeCode')] ?? '', + ); + const purpose = this.assertPurpose(cols[idx('purpose')] ?? ''); + const cycleNumber = Number.parseInt(cols[idx('cycleNumber')] ?? '', 10); + if (!isValidCycleNumber(cycleNumber)) { + throw new BadRequestException('Invalid cycle number'); + } + const weekdayRaw = (cols[idx('weekday')] ?? '').toLowerCase(); + if (!(WEEKDAY_NAMES as readonly string[]).includes(weekdayRaw)) { + throw new BadRequestException('Weekday must be complete'); + } + const weekday = weekdayRaw as WeekdayName; + const start = await this.branchesService.findByCode( + cols[idx('startBranchCode')] ?? '', + ); + const end = await this.branchesService.findByCode( + cols[idx('endBranchCode')] ?? '', + ); + const customerCodes = (cols[idx('customerCodes')] ?? '') + .split('|') + .map((code) => code.trim()) + .filter((code) => code.length > 0); + if (customerCodes.length === 0) { + throw new BadRequestException('Weekday must be complete'); + } + const customerIds: string[] = []; + for (const code of customerCodes) { + const customer = await this.customersService.findByCode(code); + customerIds.push(customer.id); + } + const key = `${employee.id}:${purpose}:${cycleNumber}`; + const current = grouped.get(key) ?? { + employeeId: employee.id, + purpose, + cycleNumber, + weekdays: {}, + }; + const existingDay = current.weekdays[weekday]; + grouped.set(key, { + ...current, + weekdays: { + ...current.weekdays, + [weekday]: { + startBranchId: start.id, + endBranchId: end.id, + customerIds: [ + ...(existingDay?.customerIds ?? []), + ...customerIds, + ], + }, + }, + }); + } catch (error) { + const reason = + error instanceof BadRequestException || + error instanceof NotFoundException + ? error.message + : 'invalid data'; + errors.push(`row ${rowNum}: ${reason}`); + } + } + if (errors.length > 0) { + throw new BadRequestException({ + message: 'CSV validation failed', + errors, + }); + } + return [...grouped.values()]; + } + + private async requireCycle(id: string): Promise { + const cycle = await this.cyclesRepository.findById(id); + if (!cycle) { + throw new NotFoundException('Cycle not found'); + } + return cycle; + } + + private async assertUnique( + employeeId: string, + purpose: FieldPurpose, + cycleNumber: number, + excludeId?: string, + ): Promise { + const existing = await this.cyclesRepository.findLiveByKey( + employeeId, + purpose, + cycleNumber, + excludeId, + ); + if (existing) { + throw new ConflictException('Cycle already exists for this employee'); + } + } + + private async allowedPurposes( + user: AuthUser, + action: 'view' | 'update' | 'delete', + ): Promise { + if (user.isSuperadmin) { + return ['sales', 'logistics']; + } + const allowed: FieldPurpose[] = []; + for (const purpose of ['sales', 'logistics'] as const) { + const ok = await this.privilegesService.checkPermission( + user.id, + fieldPrivilegeKey('cycle', purpose), + action, + ); + if (ok) { + allowed.push(purpose); + } + } + return allowed; + } + + private async assertCanAccess( + user: AuthUser, + purpose: FieldPurpose, + action: 'view' | 'update' | 'delete', + ): Promise { + if (user.isSuperadmin) { + return; + } + const ok = await this.privilegesService.checkPermission( + user.id, + fieldPrivilegeKey('cycle', purpose), + action, + ); + if (!ok) { + throw new ForbiddenException('Insufficient privilege'); + } + } + + private async assertIdsAccessible( + ids: string[], + user: AuthUser, + action: 'update' | 'delete', + ): Promise { + for (const id of ids) { + const cycle = await this.requireCycle(id); + await this.assertCanAccess(user, cycle.purpose, action); + } + } + + private assertPurpose(raw: string): FieldPurpose { + if (!isFieldPurpose(raw)) { + throw new BadRequestException('Invalid purpose'); + } + return raw; + } + + private assertStatus(raw: string): Status { + try { + return Status.create(raw); + } catch (error) { + if (error instanceof InvalidStatusError) { + throw new BadRequestException('Invalid status'); + } + throw error; + } + } +} diff --git a/src/modules/field/cycles/dto/cycle.dto.ts b/src/modules/field/cycles/dto/cycle.dto.ts new file mode 100644 index 0000000..a426739 --- /dev/null +++ b/src/modules/field/cycles/dto/cycle.dto.ts @@ -0,0 +1,205 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; +import { PaginationQueryDto } from '../../../../common/http/response'; +import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; +import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose'; +import { RouteGeometryDto } from '../../shared/route-geometry.dto'; + +export class CycleWeekdayBodyDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + startBranchId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + endBranchId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + customerIds!: string[]; +} + +export class CreateCycleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + employeeId!: string; + + @ApiProperty({ enum: FIELD_PURPOSES }) + @IsIn([...FIELD_PURPOSES]) + purpose!: string; + + @ApiProperty({ example: 1, minimum: 1 }) + @IsInt() + @Min(1) + cycleNumber!: number; + + @ApiProperty({ + type: 'object', + additionalProperties: { $ref: '#/components/schemas/CycleWeekdayBodyDto' }, + }) + @IsObject() + weekdays!: Record; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; +} + +export class UpdateCycleDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + employeeId?: string; + + @ApiPropertyOptional({ enum: FIELD_PURPOSES }) + @IsOptional() + @IsIn([...FIELD_PURPOSES]) + purpose?: string; + + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + cycleNumber?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + weekdays?: Record; +} + +export class UpdateCycleStatusDto { + @ApiProperty({ enum: CORE_STATUSES }) + @IsIn([...CORE_STATUSES]) + status!: string; +} + +export class BulkIdsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids!: string[]; +} + +export class BulkStatusDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids!: string[]; + + @ApiProperty({ enum: CORE_STATUSES }) + @IsIn([...CORE_STATUSES]) + status!: string; +} + +export class ListCyclesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + employeeId?: string; + + @ApiPropertyOptional({ enum: FIELD_PURPOSES }) + @IsOptional() + @IsIn([...FIELD_PURPOSES]) + purpose?: string; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + @IsInt() + cycleNumber?: number; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + search?: string; +} + +export class CycleDestinationDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + customerId!: string; + + @ApiProperty() + sortOrder!: number; +} + +export class CycleWeekdayDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ enum: WEEKDAY_NAMES }) + weekday!: string; + + @ApiProperty({ format: 'uuid' }) + startBranchId!: string; + + @ApiProperty({ format: 'uuid' }) + endBranchId!: string; + + @ApiProperty({ type: RouteGeometryDto }) + routeGeometry!: RouteGeometryDto; + + @ApiProperty({ type: [CycleDestinationDto] }) + destinations!: CycleDestinationDto[]; +} + +export class CycleDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + employeeId!: string; + + @ApiProperty({ enum: FIELD_PURPOSES }) + purpose!: string; + + @ApiProperty() + cycleNumber!: number; + + @ApiProperty({ type: [CycleWeekdayDto] }) + weekdays!: CycleWeekdayDto[]; + + @ApiProperty({ enum: CORE_STATUSES }) + status!: string; + + @ApiProperty() + createdAt!: number; + + @ApiProperty() + updatedAt!: number; + + @ApiProperty({ format: 'uuid' }) + createdBy!: string; + + @ApiProperty({ format: 'uuid' }) + updatedBy!: string; +} + +void ValidateNested; diff --git a/src/modules/field/field.module.ts b/src/modules/field/field.module.ts new file mode 100644 index 0000000..5f9b030 --- /dev/null +++ b/src/modules/field/field.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { BranchesModule } from '../configuration/branches/branches.module'; +import { CustomersModule } from '../configuration/customers/customers.module'; +import { EmployeesModule } from '../configuration/employees/employees.module'; +import { PrivilegesModule } from '../privileges/privileges.module'; +import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module'; +import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.module'; +import { CyclesReadController } from './cycles/cycles-read.controller'; +import { CyclesWriteController } from './cycles/cycles-write.controller'; +import { CyclesRepository } from './cycles/cycles.repository'; +import { CyclesService } from './cycles/cycles.service'; +import { PlansReadController } from './plans/plans-read.controller'; +import { PlansWriteController } from './plans/plans-write.controller'; +import { PlansRepository } from './plans/plans.repository'; +import { PlansService } from './plans/plans.service'; +import { CompanySettingsController } from './settings/company-settings.controller'; +import { CompanySettingsRepository } from './settings/company-settings.repository'; +import { CompanySettingsService } from './settings/company-settings.service'; +import { FieldPrivilegeGuard } from './shared/field-privilege.guard'; + +@Module({ + imports: [ + PrivilegesModule, + EmployeesModule, + BranchesModule, + CustomersModule, + SalesInvoicesModule, + PackingSlipsModule, + ], + controllers: [ + CompanySettingsController, + CyclesReadController, + CyclesWriteController, + PlansReadController, + PlansWriteController, + ], + providers: [ + FieldPrivilegeGuard, + CompanySettingsRepository, + CompanySettingsService, + CyclesRepository, + CyclesService, + PlansRepository, + PlansService, + ], + exports: [CompanySettingsService, CyclesService, PlansService], +}) +export class FieldModule {} diff --git a/src/modules/field/plans/dto/plan.dto.ts b/src/modules/field/plans/dto/plan.dto.ts new file mode 100644 index 0000000..ca1b1c0 --- /dev/null +++ b/src/modules/field/plans/dto/plan.dto.ts @@ -0,0 +1,256 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, +} from 'class-validator'; +import { PaginationQueryDto } from '../../../../common/http/response'; +import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; +import { FIELD_PURPOSES } from '../../shared/field-purpose'; +import { RouteGeometryDto } from '../../shared/route-geometry.dto'; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export class CreatePlanDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + employeeId!: string; + + @ApiProperty({ enum: FIELD_PURPOSES }) + @IsIn([...FIELD_PURPOSES]) + purpose!: string; + + @ApiProperty({ example: '2026-01-12' }) + @IsString() + @Matches(DATE_PATTERN) + date!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + startBranchId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + endBranchId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + customerIds!: string[]; + + @ApiPropertyOptional({ type: [String], format: 'uuid' }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + invoiceIds?: string[]; + + @ApiPropertyOptional({ type: [String], format: 'uuid' }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + packingSlipIds?: string[]; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; +} + +export class UpdatePlanDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + employeeId?: string; + + @ApiPropertyOptional({ enum: FIELD_PURPOSES }) + @IsOptional() + @IsIn([...FIELD_PURPOSES]) + purpose?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @Matches(DATE_PATTERN) + date?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + startBranchId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + endBranchId?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + customerIds?: string[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + invoiceIds?: string[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + packingSlipIds?: string[]; +} + +export class UpdatePlanStatusDto { + @ApiProperty({ enum: CORE_STATUSES }) + @IsIn([...CORE_STATUSES]) + status!: string; +} + +export class BulkIdsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids!: string[]; +} + +export class BulkStatusDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids!: string[]; + + @ApiProperty({ enum: CORE_STATUSES }) + @IsIn([...CORE_STATUSES]) + status!: string; +} + +export class GeneratePlansDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + employeeId!: string; + + @ApiProperty({ enum: FIELD_PURPOSES }) + @IsIn([...FIELD_PURPOSES]) + purpose!: string; + + @ApiProperty({ example: '2026-01-12' }) + @IsString() + @Matches(DATE_PATTERN) + from!: string; + + @ApiProperty({ example: '2026-01-25' }) + @IsString() + @Matches(DATE_PATTERN) + to!: string; +} + +export class AddPlanDestinationDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + customerId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + afterDestinationId?: string; +} + +export class ListPlansQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + employeeId?: string; + + @ApiPropertyOptional({ enum: FIELD_PURPOSES }) + @IsOptional() + @IsIn([...FIELD_PURPOSES]) + purpose?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @Matches(DATE_PATTERN) + date?: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + search?: string; +} + +export class PlanDestinationDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + customerId!: string; + + @ApiProperty() + sortOrder!: number; +} + +export class PlanDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + employeeId!: string; + + @ApiProperty({ enum: FIELD_PURPOSES }) + purpose!: string; + + @ApiProperty() + date!: number; + + @ApiProperty({ format: 'uuid' }) + startBranchId!: string; + + @ApiProperty({ format: 'uuid' }) + endBranchId!: string; + + @ApiProperty({ type: RouteGeometryDto }) + routeGeometry!: RouteGeometryDto; + + @ApiProperty({ type: [PlanDestinationDto] }) + destinations!: PlanDestinationDto[]; + + @ApiProperty({ type: [String] }) + invoiceIds!: string[]; + + @ApiProperty({ type: [String] }) + packingSlipIds!: string[]; + + @ApiProperty({ enum: CORE_STATUSES }) + status!: string; + + @ApiProperty() + createdAt!: number; + + @ApiProperty() + updatedAt!: number; + + @ApiProperty({ format: 'uuid' }) + createdBy!: string; + + @ApiProperty({ format: 'uuid' }) + updatedBy!: string; +} + +void Type; diff --git a/src/modules/field/plans/plan.ts b/src/modules/field/plans/plan.ts new file mode 100644 index 0000000..187097f --- /dev/null +++ b/src/modules/field/plans/plan.ts @@ -0,0 +1,43 @@ +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; +import type { FieldPurpose } from '../shared/field-purpose'; +import type { RouteGeometry } from '../shared/route-line-string'; + +export type PlanDestination = { + readonly id: string; + readonly customerId: string; + readonly sortOrder: number; +}; + +export type Plan = { + readonly id: string; + readonly employeeId: string; + readonly purpose: FieldPurpose; + readonly date: DateTime; + readonly startBranchId: string; + readonly endBranchId: string; + readonly routeGeometry: RouteGeometry; + readonly destinations: readonly PlanDestination[]; + readonly invoiceIds: readonly string[]; + readonly packingSlipIds: readonly string[]; + readonly status: Status; + readonly createdAt: DateTime; + readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; +}; + +export type PersistablePlanDestination = { + readonly customerId: string; +}; + +export type ListPlansFilters = { + readonly employeeId?: string; + readonly purpose?: string; + readonly date?: number; + readonly status?: string; + readonly search?: string; + readonly purposes?: readonly string[]; + readonly limit: number; + readonly offset: number; +}; diff --git a/src/modules/field/plans/plans-read.controller.spec.ts b/src/modules/field/plans/plans-read.controller.spec.ts new file mode 100644 index 0000000..e1a83b5 --- /dev/null +++ b/src/modules/field/plans/plans-read.controller.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { PlansReadController } from './plans-read.controller'; +import { PlansService } from './plans.service'; + +describe('PlansReadController', () => { + let controller: PlansReadController; + const service = { list: jest.fn(), findById: jest.fn() }; + const user = { id: 'user-1', isSuperadmin: true }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [PlansReadController], + providers: [{ provide: PlansService, useValue: service }], + }) + .overrideGuard(FieldPrivilegeGuard) + .useValue({ canActivate: () => true }) + .compile(); + controller = moduleRef.get(PlansReadController); + }); + + it('list delegates to the service', async () => { + service.list.mockResolvedValue({ data: [], total: 0 }); + await expect(controller.list({ page: 1 }, user as never)).resolves.toEqual({ + data: [], + total: 0, + }); + }); + + it('findOne delegates to the service', async () => { + service.findById.mockResolvedValue({ id: 'pln-1' }); + await expect(controller.findOne('pln-1', user as never)).resolves.toEqual({ + id: 'pln-1', + }); + }); +}); diff --git a/src/modules/field/plans/plans-read.controller.ts b/src/modules/field/plans/plans-read.controller.ts new file mode 100644 index 0000000..154ed09 --- /dev/null +++ b/src/modules/field/plans/plans-read.controller.ts @@ -0,0 +1,77 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { + Pagination, + type PaginationResponse, + PaginationMetaDto, +} from '../../../common/http/response'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { RequireFieldPrivilege } from '../shared/field-privilege.decorator'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { ListPlansQueryDto, PlanDto } from './dto/plan.dto'; +import { PlansService } from './plans.service'; + +@ApiTags('plans') +@ApiBearerAuth(BEARER_AUTH_NAME) +@UseGuards(FieldPrivilegeGuard) +@Controller('plans') +export class PlansReadController { + constructor(private readonly plansService: PlansService) {} + + @Get() + @Pagination() + @RequireFieldPrivilege('plan', 'view') + @ApiOperation({ summary: 'List plans' }) + @ApiOkResponse({ + schema: { + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/PlanDto' }, + }, + meta: { $ref: '#/components/schemas/PaginationMetaDto' }, + }, + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + list( + @Query() query: ListPlansQueryDto, + @CurrentUser() user: AuthUser, + ): Promise> { + return this.plansService.list(query, user); + } + + @Get(':id') + @RequireFieldPrivilege('plan', 'view') + @ApiOperation({ summary: 'Get plan detail' }) + @ApiOkResponse({ type: PlanDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.plansService.findById(id, user); + } +} + +void PaginationMetaDto; diff --git a/src/modules/field/plans/plans-write.controller.spec.ts b/src/modules/field/plans/plans-write.controller.spec.ts new file mode 100644 index 0000000..920b03d --- /dev/null +++ b/src/modules/field/plans/plans-write.controller.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { PlansWriteController } from './plans-write.controller'; +import { PlansService } from './plans.service'; + +describe('PlansWriteController', () => { + let controller: PlansWriteController; + const service = { + create: jest.fn(), + generate: jest.fn(), + addDestination: jest.fn(), + removeDestination: jest.fn(), + delete: jest.fn(), + }; + const user = { + id: 'user-1', + username: 'alice', + jti: 'j', + isSuperadmin: true, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [PlansWriteController], + providers: [{ provide: PlansService, useValue: service }], + }) + .overrideGuard(FieldPrivilegeGuard) + .useValue({ canActivate: () => true }) + .compile(); + controller = moduleRef.get(PlansWriteController); + }); + + it('generate delegates', async () => { + service.generate.mockResolvedValue({ created: 2, skipped: 1 }); + await controller.generate( + { + employeeId: 'emp-1', + purpose: 'sales', + from: '2026-01-05', + to: '2026-01-11', + }, + 'user-1', + ); + expect(service.generate).toHaveBeenCalledWith({ + employeeId: 'emp-1', + purpose: 'sales', + from: '2026-01-05', + to: '2026-01-11', + userId: 'user-1', + }); + }); + + it('addDestination delegates', async () => { + service.addDestination.mockResolvedValue({ id: 'pln-1' }); + await controller.addDestination( + 'pln-1', + { customerId: 'cus-2' }, + user as never, + ); + expect(service.addDestination).toHaveBeenCalledWith( + 'pln-1', + 'cus-2', + undefined, + user, + ); + }); +}); diff --git a/src/modules/field/plans/plans-write.controller.ts b/src/modules/field/plans/plans-write.controller.ts new file mode 100644 index 0000000..ba35b5c --- /dev/null +++ b/src/modules/field/plans/plans-write.controller.ts @@ -0,0 +1,174 @@ +import { + Body, + Controller, + Delete, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { RequireFieldPrivilege } from '../shared/field-privilege.decorator'; +import { FieldPrivilegeGuard } from '../shared/field-privilege.guard'; +import { + AddPlanDestinationDto, + BulkIdsDto, + BulkStatusDto, + CreatePlanDto, + GeneratePlansDto, + PlanDto, + UpdatePlanDto, + UpdatePlanStatusDto, +} from './dto/plan.dto'; +import { PlansService } from './plans.service'; + +@ApiTags('plans') +@ApiBearerAuth(BEARER_AUTH_NAME) +@UseGuards(FieldPrivilegeGuard) +@Controller('plans') +export class PlansWriteController { + constructor(private readonly plansService: PlansService) {} + + @Post('generate') + @RequireFieldPrivilege('plan', 'create') + @ApiOperation({ summary: 'Generate plans from cycles for a date range' }) + @ApiOkResponse({ + schema: { + properties: { + created: { type: 'number' }, + skipped: { type: 'number' }, + }, + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + generate( + @Body() dto: GeneratePlansDto, + @CurrentUser('id') userId: string, + ): Promise<{ created: number; skipped: number }> { + return this.plansService.generate({ ...dto, userId }); + } + + @Post('bulk-delete') + @HttpCode(200) + @RequireFieldPrivilege('plan', 'delete') + @ApiOperation({ summary: 'Bulk archive plans' }) + @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } }) + bulkDelete( + @Body() dto: BulkIdsDto, + @CurrentUser() user: AuthUser, + ): Promise<{ deleted: number }> { + return this.plansService.bulkDelete(dto.ids, user); + } + + @Post('bulk-status') + @HttpCode(200) + @RequireFieldPrivilege('plan', 'update') + @ApiOperation({ summary: 'Bulk update plan status' }) + @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } }) + bulkStatus( + @Body() dto: BulkStatusDto, + @CurrentUser() user: AuthUser, + ): Promise<{ updated: number }> { + return this.plansService.bulkUpdateStatus( + dto.ids, + dto.status, + user.id, + user, + ); + } + + @Post() + @RequireFieldPrivilege('plan', 'create') + @ApiOperation({ summary: 'Create plan' }) + @ApiCreatedResponse({ type: PlanDto }) + create( + @Body() dto: CreatePlanDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.plansService.create({ ...dto, userId }); + } + + @Post(':id/destinations') + @RequireFieldPrivilege('plan', 'update') + @ApiOperation({ summary: 'Add a destination to a plan' }) + @ApiOkResponse({ type: PlanDto }) + addDestination( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddPlanDestinationDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.plansService.addDestination( + id, + dto.customerId, + dto.afterDestinationId, + user, + ); + } + + @Delete(':id/destinations/:destinationId') + @RequireFieldPrivilege('plan', 'update') + @ApiOperation({ summary: 'Remove a destination from a plan' }) + @ApiOkResponse({ type: PlanDto }) + removeDestination( + @Param('id', ParseUUIDPipe) id: string, + @Param('destinationId', ParseUUIDPipe) destinationId: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.plansService.removeDestination(id, destinationId, user); + } + + @Patch(':id/status') + @RequireFieldPrivilege('plan', 'update') + @ApiOperation({ summary: 'Update plan status' }) + @ApiOkResponse({ type: PlanDto }) + updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePlanStatusDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.plansService.updateStatus(id, dto.status, user.id, user); + } + + @Patch(':id') + @RequireFieldPrivilege('plan', 'update') + @ApiOperation({ summary: 'Update plan (not status)' }) + @ApiOkResponse({ type: PlanDto }) + @ApiNotFoundResponse() + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePlanDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.plansService.update(id, { ...dto, userId: user.id }, user); + } + + @Delete(':id') + @HttpCode(204) + @RequireFieldPrivilege('plan', 'delete') + @ApiOperation({ summary: 'Archive plan' }) + @ApiNoContentResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async delete( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ): Promise { + await this.plansService.delete(id, user); + } +} diff --git a/src/modules/field/plans/plans.repository.ts b/src/modules/field/plans/plans.repository.ts new file mode 100644 index 0000000..469898d --- /dev/null +++ b/src/modules/field/plans/plans.repository.ts @@ -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; + +@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(qb: T, filters: ListPlansFilters): T { + void filters; + return qb; + } + + async findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/modules/field/plans/plans.service.spec.ts b/src/modules/field/plans/plans.service.spec.ts new file mode 100644 index 0000000..a56a055 --- /dev/null +++ b/src/modules/field/plans/plans.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; +import { BranchesService } from '../../configuration/branches/branches.service'; +import { CustomersService } from '../../configuration/customers/customers.service'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import { PrivilegesService } from '../../privileges/privileges.service'; +import { PackingSlipsService } from '../../sales/packing-slips/packing-slips.service'; +import { SalesInvoicesService } from '../../sales/sales-invoices/sales-invoices.service'; +import type { Cycle } from '../cycles/cycle'; +import { CyclesRepository } from '../cycles/cycles.repository'; +import { CompanySettingsService } from '../settings/company-settings.service'; +import type { Plan } from './plan'; +import { PlansRepository } from './plans.repository'; +import { PlansService } from './plans.service'; + +describe('PlansService', () => { + let service: PlansService; + let plansRepository: jest.Mocked< + Pick< + PlansRepository, + | 'list' + | 'findById' + | 'findLiveByKey' + | 'create' + | 'update' + | 'replaceDestinations' + | 'updateStatus' + | 'archive' + > + >; + const cyclesRepository = { listActiveByEmployeePurpose: jest.fn() }; + const companySettingsService = { requireCycleStartDate: jest.fn() }; + const employeesService = { findById: jest.fn() }; + const branchesService = { findById: jest.fn() }; + const customersService = { findById: jest.fn() }; + const salesInvoicesService = { findById: jest.fn() }; + const packingSlipsService = { findById: jest.fn() }; + const privilegesService = { checkPermission: jest.fn() }; + + const user: AuthUser = { + id: 'user-1', + username: 'alice', + jti: 'jti-1', + isSuperadmin: true, + }; + const now = DateTime.fromUnixMs(1_700_000_000_000); + const geometry = { + type: 'LineString' as const, + coordinates: [ + [106.8, -6.2], + [106.9, -6.3], + [107.0, -6.4], + ] as const, + }; + const located = { id: 'x', latitude: -6.2, longitude: 106.8 }; + + const cycle: Cycle = { + id: 'cyc-1', + employeeId: 'emp-1', + purpose: 'sales', + cycleNumber: 1, + weekdays: [ + { + id: 'wd-1', + weekday: 'monday', + startBranchId: 'br-1', + endBranchId: 'br-2', + routeGeometry: geometry, + destinations: [{ id: 'cd-1', customerId: 'cus-1', sortOrder: 0 }], + }, + ], + status: Status.create('active'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const plan: Plan = { + id: 'pln-1', + employeeId: 'emp-1', + purpose: 'sales', + date: DateTime.create('2026-01-05'), + startBranchId: 'br-1', + endBranchId: 'br-2', + routeGeometry: geometry, + destinations: [{ id: 'pd-1', customerId: 'cus-1', sortOrder: 0 }], + invoiceIds: [], + packingSlipIds: [], + status: Status.create('active'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + beforeEach(async () => { + jest.clearAllMocks(); + plansRepository = { + list: jest.fn(), + findById: jest.fn(), + findLiveByKey: jest.fn(), + create: jest.fn(), + update: jest.fn(), + replaceDestinations: jest.fn(), + updateStatus: jest.fn(), + archive: jest.fn(), + }; + employeesService.findById.mockResolvedValue({ id: 'emp-1' }); + branchesService.findById.mockResolvedValue(located); + customersService.findById.mockResolvedValue(located); + plansRepository.findLiveByKey.mockResolvedValue(null); + plansRepository.create.mockResolvedValue(plan); + companySettingsService.requireCycleStartDate.mockResolvedValue( + DateTime.create('2026-01-05'), + ); + cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([cycle]); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + PlansService, + { provide: PlansRepository, useValue: plansRepository }, + { provide: CyclesRepository, useValue: cyclesRepository }, + { provide: CompanySettingsService, useValue: companySettingsService }, + { provide: EmployeesService, useValue: employeesService }, + { provide: BranchesService, useValue: branchesService }, + { provide: CustomersService, useValue: customersService }, + { provide: SalesInvoicesService, useValue: salesInvoicesService }, + { provide: PackingSlipsService, useValue: packingSlipsService }, + { provide: PrivilegesService, useValue: privilegesService }, + ], + }).compile(); + service = moduleRef.get(PlansService); + }); + + it('rejects packing slips on a sales plan', async () => { + await expect( + service.create({ + employeeId: 'emp-1', + purpose: 'sales', + date: '2026-01-05', + startBranchId: 'br-1', + endBranchId: 'br-2', + customerIds: ['cus-1'], + packingSlipIds: ['ps-1'], + userId: 'user-1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('generate copies a weekday and skips an existing plan', async () => { + plansRepository.findLiveByKey + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(plan); + const result = await service.generate({ + employeeId: 'emp-1', + purpose: 'sales', + from: '2026-01-05', + to: '2026-01-12', + userId: 'user-1', + }); + expect(result.created).toBe(1); + expect(result.skipped).toBeGreaterThan(0); + expect(plansRepository.create).toHaveBeenCalledTimes(1); + const created = plansRepository.create.mock.calls[0][0]; + expect(created.invoiceIds).toEqual([]); + expect(created.status.value).toBe('active'); + }); + + it('generate fails when the employee has no cycle', async () => { + cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([]); + await expect( + service.generate({ + employeeId: 'emp-1', + purpose: 'sales', + from: '2026-01-05', + to: '2026-01-05', + userId: 'user-1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses to remove the last destination', async () => { + plansRepository.findById.mockResolvedValue(plan); + await expect( + service.removeDestination('pln-1', 'pd-1', user), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('adds a destination without writing a cycle', async () => { + plansRepository.findById.mockResolvedValue(plan); + plansRepository.replaceDestinations.mockResolvedValue({ + ...plan, + destinations: [ + ...plan.destinations, + { id: 'pd-2', customerId: 'cus-2', sortOrder: 1 }, + ], + }); + await service.addDestination('pln-1', 'cus-2', undefined, user); + expect(plansRepository.replaceDestinations).toHaveBeenCalled(); + expect(cyclesRepository.listActiveByEmployeePurpose).not.toHaveBeenCalled(); + }); + + it('rejects a duplicate destination', async () => { + plansRepository.findById.mockResolvedValue(plan); + await expect( + service.addDestination('pln-1', 'cus-1', undefined, user), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/src/modules/field/plans/plans.service.ts b/src/modules/field/plans/plans.service.ts new file mode 100644 index 0000000..b8eef55 --- /dev/null +++ b/src/modules/field/plans/plans.service.ts @@ -0,0 +1,624 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import type { PaginationResponse } from '../../../common/http/response'; +import { toListPage } from '../../../common/http/response'; +import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error'; +import { Status } from '../../../common/value-objects/status/status'; +import { BranchesService } from '../../configuration/branches/branches.service'; +import { CustomersService } from '../../configuration/customers/customers.service'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import { PrivilegesService } from '../../privileges/privileges.service'; +import { PackingSlipsService } from '../../sales/packing-slips/packing-slips.service'; +import { SalesInvoicesService } from '../../sales/sales-invoices/sales-invoices.service'; +import { CyclesRepository } from '../cycles/cycles.repository'; +import { CompanySettingsService } from '../settings/company-settings.service'; +import { + cycleNumberForDate, + fieldPrivilegeKey, + isFieldPurpose, + noCycleMessage, + type FieldPurpose, +} from '../shared/field-purpose'; +import { + buildRouteLineString, + isUsableRouteGeometry, +} from '../shared/route-line-string'; +import type { Plan } from './plan'; +import { PlansRepository } from './plans.repository'; + +export type ListPlansQuery = { + readonly employeeId?: string; + readonly purpose?: string; + readonly date?: string; + readonly status?: string; + readonly search?: string; + readonly page?: number; + readonly limit?: number; + readonly offset?: number; +}; + +const VISIBLE_FIELDS = [ + 'id', + 'employeeId', + 'purpose', + 'date', + 'startBranchId', + 'endBranchId', + 'routeGeometry', + 'destinations', + 'invoiceIds', + 'packingSlipIds', + 'status', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +const MS_PER_DAY = 86_400_000; + +@Injectable() +export class PlansService { + constructor( + private readonly plansRepository: PlansRepository, + private readonly cyclesRepository: CyclesRepository, + private readonly companySettingsService: CompanySettingsService, + private readonly employeesService: EmployeesService, + private readonly branchesService: BranchesService, + private readonly customersService: CustomersService, + private readonly salesInvoicesService: SalesInvoicesService, + private readonly packingSlipsService: PackingSlipsService, + private readonly privilegesService: PrivilegesService, + ) {} + + async list( + query: ListPlansQuery, + user: AuthUser, + ): Promise>> { + const purposes = await this.allowedPurposes(user, 'view'); + if (query.purpose && !purposes.includes(query.purpose as FieldPurpose)) { + throw new ForbiddenException('Insufficient privilege'); + } + const page = toListPage(query); + const { data, total } = await this.plansRepository.list({ + employeeId: query.employeeId, + purpose: query.purpose, + date: query.date + ? this.assertDate(query.date).startOfDay().value + : undefined, + status: query.status, + search: query.search, + purposes: query.purpose ? undefined : purposes, + limit: page.limit, + offset: page.offset, + }); + return { data: data.map((item) => this.toItem(item)), total }; + } + + async findById( + id: string, + user: AuthUser, + ): Promise> { + const plan = await this.requirePlan(id); + await this.assertCanAccess(user, plan.purpose, 'view'); + return this.toItem(plan); + } + + async create(input: { + employeeId: string; + purpose: string; + date: string; + startBranchId: string; + endBranchId: string; + customerIds: string[]; + invoiceIds?: string[]; + packingSlipIds?: string[]; + status?: string; + userId: string; + }): Promise> { + const created = await this.plansRepository.create( + await this.toWritePayload(input), + ); + return this.toItem(created); + } + + async update( + id: string, + input: { + employeeId?: string; + purpose?: string; + date?: string; + startBranchId?: string; + endBranchId?: string; + customerIds?: string[]; + invoiceIds?: string[]; + packingSlipIds?: string[]; + status?: unknown; + userId: string; + }, + user: AuthUser, + ): Promise> { + if (input.status !== undefined) { + throw new BadRequestException('status cannot be updated via PATCH'); + } + const existing = await this.requirePlan(id); + await this.assertCanAccess(user, existing.purpose, 'update'); + const purpose = input.purpose + ? this.assertPurpose(input.purpose) + : existing.purpose; + const employeeId = input.employeeId ?? existing.employeeId; + const date = input.date + ? this.assertDate(input.date).startOfDay() + : existing.date; + await this.employeesService.findById(employeeId); + await this.assertUnique(employeeId, purpose, date.value, id); + const startBranchId = input.startBranchId ?? existing.startBranchId; + const endBranchId = input.endBranchId ?? existing.endBranchId; + const customerIds = + input.customerIds ?? existing.destinations.map((d) => d.customerId); + const geometry = await this.buildGeometry( + startBranchId, + endBranchId, + customerIds, + ); + const attachments = this.assertAttachments( + purpose, + input.invoiceIds ?? [...existing.invoiceIds], + input.packingSlipIds ?? [...existing.packingSlipIds], + ); + if (input.invoiceIds) { + await this.assertInvoices(input.invoiceIds); + } + if (input.packingSlipIds) { + await this.assertPackingSlips(input.packingSlipIds); + } + const updated = await this.plansRepository.update(id, { + employeeId, + purpose, + date, + startBranchId, + endBranchId, + routeGeometry: geometry, + customerIds, + invoiceIds: attachments.invoiceIds, + packingSlipIds: attachments.packingSlipIds, + userId: input.userId, + }); + return this.toItem(updated); + } + + async updateStatus( + id: string, + statusRaw: string, + userId: string, + user: AuthUser, + ): Promise> { + const existing = await this.requirePlan(id); + await this.assertCanAccess(user, existing.purpose, 'update'); + const updated = await this.plansRepository.updateStatus( + id, + this.assertStatus(statusRaw), + userId, + ); + return this.toItem(updated); + } + + async bulkUpdateStatus( + ids: string[], + statusRaw: string, + userId: string, + user: AuthUser, + ): Promise<{ updated: number }> { + const status = this.assertStatus(statusRaw); + await this.assertIdsAccessible(ids, user, 'update'); + const updated = await this.plansRepository.bulkUpdateStatus( + ids, + status, + userId, + ); + return { updated }; + } + + async delete(id: string, user: AuthUser): Promise { + const existing = await this.requirePlan(id); + await this.assertCanAccess(user, existing.purpose, 'delete'); + await this.plansRepository.archive(id, user.id); + } + + async bulkDelete( + ids: string[], + user: AuthUser, + ): Promise<{ deleted: number }> { + await this.assertIdsAccessible(ids, user, 'delete'); + const deleted = await this.plansRepository.bulkArchive(ids, user.id); + return { deleted }; + } + + async generate(input: { + employeeId: string; + purpose: string; + from: string; + to: string; + userId: string; + }): Promise<{ created: number; skipped: number }> { + const purpose = this.assertPurpose(input.purpose); + await this.employeesService.findById(input.employeeId); + const epoch = await this.companySettingsService.requireCycleStartDate(); + const from = this.assertDate(input.from).startOfDay(); + const to = this.assertDate(input.to).startOfDay(); + if (to.value < from.value) { + throw new BadRequestException('Invalid date range'); + } + if (from.value < epoch.startOfDay().value) { + throw new BadRequestException('Date is before the cycle start date'); + } + const cycles = await this.cyclesRepository.listActiveByEmployeePurpose( + input.employeeId, + purpose, + ); + if (cycles.length === 0) { + throw new BadRequestException(noCycleMessage(purpose)); + } + const totalCycles = Math.max(...cycles.map((cycle) => cycle.cycleNumber)); + let created = 0; + let skipped = 0; + for (let cursor = from.value; cursor <= to.value; cursor += MS_PER_DAY) { + const day = DateTime.fromUnixMs(cursor).startOfDay(); + const cycleNumber = cycleNumberForDate( + day.wholeWeeksSince(epoch), + totalCycles, + ); + const cycle = cycles.find((item) => item.cycleNumber === cycleNumber); + if (!cycle) { + skipped += 1; + continue; + } + const weekday = cycle.weekdays.find( + (item) => item.weekday === day.weekdayName(), + ); + if (!weekday) { + skipped += 1; + continue; + } + const existing = await this.plansRepository.findLiveByKey( + input.employeeId, + purpose, + day.value, + ); + if (existing) { + skipped += 1; + continue; + } + await this.plansRepository.create({ + employeeId: input.employeeId, + purpose, + date: day, + startBranchId: weekday.startBranchId, + endBranchId: weekday.endBranchId, + routeGeometry: weekday.routeGeometry, + customerIds: weekday.destinations.map((d) => d.customerId), + invoiceIds: [], + packingSlipIds: [], + status: Status.create('active'), + userId: input.userId, + }); + created += 1; + } + return { created, skipped }; + } + + async addDestination( + planId: string, + customerId: string, + afterDestinationId: string | undefined, + user: AuthUser, + ): Promise> { + const plan = await this.requirePlan(planId); + await this.assertCanAccess(user, plan.purpose, 'update'); + await this.customersService.findById(customerId); + if (plan.destinations.some((d) => d.customerId === customerId)) { + throw new ConflictException('Customer is already on this plan'); + } + const ids = plan.destinations.map((d) => d.customerId); + if (afterDestinationId) { + const after = plan.destinations.find((d) => d.id === afterDestinationId); + if (!after) { + throw new BadRequestException('Destination is not on this plan'); + } + const index = ids.indexOf(after.customerId); + ids.splice(index + 1, 0, customerId); + } else { + ids.push(customerId); + } + const geometry = await this.buildGeometry( + plan.startBranchId, + plan.endBranchId, + ids, + ); + const updated = await this.plansRepository.replaceDestinations( + planId, + ids, + geometry, + user.id, + ); + return this.toItem(updated); + } + + async removeDestination( + planId: string, + destinationId: string, + user: AuthUser, + ): Promise> { + const plan = await this.requirePlan(planId); + await this.assertCanAccess(user, plan.purpose, 'update'); + if (plan.destinations.length <= 1) { + throw new BadRequestException( + 'A live plan must keep at least one destination', + ); + } + const remaining = plan.destinations.filter((d) => d.id !== destinationId); + if (remaining.length === plan.destinations.length) { + throw new NotFoundException('Destination not found'); + } + const ids = remaining.map((d) => d.customerId); + const geometry = await this.buildGeometry( + plan.startBranchId, + plan.endBranchId, + ids, + ); + const updated = await this.plansRepository.replaceDestinations( + planId, + ids, + geometry, + user.id, + ); + return this.toItem(updated); + } + + get visibleFields(): readonly string[] { + return VISIBLE_FIELDS; + } + + toItem(plan: Plan) { + return { + id: plan.id, + employeeId: plan.employeeId, + purpose: plan.purpose, + date: plan.date.value, + startBranchId: plan.startBranchId, + endBranchId: plan.endBranchId, + routeGeometry: plan.routeGeometry, + destinations: plan.destinations.map((destination) => ({ + id: destination.id, + customerId: destination.customerId, + sortOrder: destination.sortOrder, + })), + invoiceIds: [...plan.invoiceIds], + packingSlipIds: [...plan.packingSlipIds], + status: plan.status.value, + createdAt: plan.createdAt.value, + updatedAt: plan.updatedAt.value, + createdBy: plan.createdBy, + updatedBy: plan.updatedBy, + }; + } + + private async toWritePayload(input: { + employeeId: string; + purpose: string; + date: string; + startBranchId: string; + endBranchId: string; + customerIds: string[]; + invoiceIds?: string[]; + packingSlipIds?: string[]; + status?: string; + userId: string; + }) { + const purpose = this.assertPurpose(input.purpose); + const date = this.assertDate(input.date).startOfDay(); + await this.employeesService.findById(input.employeeId); + await this.assertUnique(input.employeeId, purpose, date.value); + const geometry = await this.buildGeometry( + input.startBranchId, + input.endBranchId, + input.customerIds, + ); + const attachments = this.assertAttachments( + purpose, + input.invoiceIds ?? [], + input.packingSlipIds ?? [], + ); + await this.assertInvoices(attachments.invoiceIds); + await this.assertPackingSlips(attachments.packingSlipIds); + return { + employeeId: input.employeeId, + purpose, + date, + startBranchId: input.startBranchId, + endBranchId: input.endBranchId, + routeGeometry: geometry, + customerIds: input.customerIds, + invoiceIds: attachments.invoiceIds, + packingSlipIds: attachments.packingSlipIds, + status: input.status + ? this.assertStatus(input.status) + : Status.create(Status.DEFAULT), + userId: input.userId, + }; + } + + private async buildGeometry( + startBranchId: string, + endBranchId: string, + customerIds: readonly string[], + ) { + if (customerIds.length === 0) { + throw new BadRequestException( + 'A working day needs at least one destination', + ); + } + const start = await this.branchesService.findById(startBranchId); + const end = await this.branchesService.findById(endBranchId); + const customers: Array<{ + longitude: number | null; + latitude: number | null; + }> = []; + for (const customerId of customerIds) { + customers.push(await this.customersService.findById(customerId)); + } + const geometry = buildRouteLineString([ + { longitude: start.longitude, latitude: start.latitude }, + ...customers.map((customer) => ({ + longitude: customer.longitude, + latitude: customer.latitude, + })), + { longitude: end.longitude, latitude: end.latitude }, + ]); + if (!isUsableRouteGeometry(geometry)) { + throw new BadRequestException('Route is incomplete'); + } + return geometry; + } + + private assertAttachments( + purpose: FieldPurpose, + invoiceIds: readonly string[], + packingSlipIds: readonly string[], + ): { invoiceIds: string[]; packingSlipIds: string[] } { + if (purpose === 'sales' && packingSlipIds.length > 0) { + throw new BadRequestException('Sales plans cannot include packing slips'); + } + if (purpose === 'logistics' && invoiceIds.length > 0) { + throw new BadRequestException('Logistics plans cannot include invoices'); + } + return { + invoiceIds: [...invoiceIds], + packingSlipIds: [...packingSlipIds], + }; + } + + private async assertInvoices(ids: readonly string[]): Promise { + for (const id of ids) { + await this.salesInvoicesService.findById(id); + } + } + + private async assertPackingSlips(ids: readonly string[]): Promise { + for (const id of ids) { + await this.packingSlipsService.findById(id); + } + } + + private async requirePlan(id: string): Promise { + const plan = await this.plansRepository.findById(id); + if (!plan) { + throw new NotFoundException('Plan not found'); + } + return plan; + } + + private async assertUnique( + employeeId: string, + purpose: FieldPurpose, + date: number, + excludeId?: string, + ): Promise { + const existing = await this.plansRepository.findLiveByKey( + employeeId, + purpose, + date, + excludeId, + ); + if (existing) { + throw new ConflictException('Plan already exists for this employee'); + } + } + + private async allowedPurposes( + user: AuthUser, + action: 'view' | 'update' | 'delete', + ): Promise { + if (user.isSuperadmin) { + return ['sales', 'logistics']; + } + const allowed: FieldPurpose[] = []; + for (const purpose of ['sales', 'logistics'] as const) { + const ok = await this.privilegesService.checkPermission( + user.id, + fieldPrivilegeKey('plan', purpose), + action, + ); + if (ok) { + allowed.push(purpose); + } + } + return allowed; + } + + private async assertCanAccess( + user: AuthUser, + purpose: FieldPurpose, + action: 'view' | 'update' | 'delete', + ): Promise { + if (user.isSuperadmin) { + return; + } + const ok = await this.privilegesService.checkPermission( + user.id, + fieldPrivilegeKey('plan', purpose), + action, + ); + if (!ok) { + throw new ForbiddenException('Insufficient privilege'); + } + } + + private async assertIdsAccessible( + ids: string[], + user: AuthUser, + action: 'update' | 'delete', + ): Promise { + for (const id of ids) { + const plan = await this.requirePlan(id); + await this.assertCanAccess(user, plan.purpose, action); + } + } + + private assertPurpose(raw: string): FieldPurpose { + if (!isFieldPurpose(raw)) { + throw new BadRequestException('Invalid purpose'); + } + return raw; + } + + private assertDate(raw: string): DateTime { + try { + return DateTime.create(raw); + } catch (error) { + if (error instanceof InvalidDateTimeError) { + throw new BadRequestException('Invalid date'); + } + throw error; + } + } + + private assertStatus(raw: string): Status { + try { + return Status.create(raw); + } catch (error) { + if (error instanceof InvalidStatusError) { + throw new BadRequestException('Invalid status'); + } + throw error; + } + } +} diff --git a/src/modules/field/settings/company-setting.ts b/src/modules/field/settings/company-setting.ts new file mode 100644 index 0000000..2fdacae --- /dev/null +++ b/src/modules/field/settings/company-setting.ts @@ -0,0 +1,17 @@ +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; + +export type CompanySetting = { + readonly id: string; + readonly cycleStartDate: DateTime; + readonly status: Status; + readonly createdAt: DateTime; + readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; +}; + +export type UpsertCompanySettingInput = { + readonly cycleStartDate: DateTime; + readonly userId: string; +}; diff --git a/src/modules/field/settings/company-settings.controller.ts b/src/modules/field/settings/company-settings.controller.ts new file mode 100644 index 0000000..7991310 --- /dev/null +++ b/src/modules/field/settings/company-settings.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { SETTINGS_PRIVILEGE_KEY } from '../shared/field-purpose'; +import { CompanySettingsService } from './company-settings.service'; +import { + CompanySettingDto, + UpdateCompanySettingDto, +} from './dto/company-setting.dto'; + +@ApiTags('settings') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('settings') +export class CompanySettingsController { + constructor( + private readonly companySettingsService: CompanySettingsService, + ) {} + + @Get() + @RequirePrivilege(SETTINGS_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'Get company settings' }) + @ApiOkResponse({ type: CompanySettingDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + get(): Promise { + return this.companySettingsService.get(); + } + + @Patch() + @RequirePrivilege(SETTINGS_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update company cycle start date' }) + @ApiOkResponse({ type: CompanySettingDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + update( + @Body() dto: UpdateCompanySettingDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.companySettingsService.update(dto.cycleStartDate, userId); + } +} diff --git a/src/modules/field/settings/company-settings.repository.ts b/src/modules/field/settings/company-settings.repository.ts new file mode 100644 index 0000000..a0c8343 --- /dev/null +++ b/src/modules/field/settings/company-settings.repository.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { eq } 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 { + companySettings, + type CompanySettingsRow, +} from '../../../database/company-settings-table'; +import type { + CompanySetting, + UpsertCompanySettingInput, +} from './company-setting'; + +@Injectable() +export class CompanySettingsRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async find(): Promise { + const rows: CompanySettingsRow[] = await this.db + .select() + .from(companySettings) + .limit(1); + const row = rows[0]; + return row ? this.toDomain(row) : null; + } + + async upsert(input: UpsertCompanySettingInput): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const existing = await this.find(); + if (!existing) { + const inserted = await this.db + .insert(companySettings) + .values({ + cycleStartDate: input.cycleStartDate.value, + status: Status.create(Status.DEFAULT).value, + createdAt: now.value, + updatedAt: now.value, + createdBy: input.userId, + updatedBy: input.userId, + }) + .returning(); + return this.toDomain(inserted[0]); + } + const updated = await this.db + .update(companySettings) + .set({ + cycleStartDate: input.cycleStartDate.value, + updatedAt: now.value, + updatedBy: input.userId, + }) + .where(eq(companySettings.id, existing.id)) + .returning(); + return this.toDomain(updated[0]); + } + + private toDomain(row: CompanySettingsRow): CompanySetting { + return { + id: row.id, + cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate), + status: Status.create(row.status), + createdAt: DateTime.fromUnixMs(row.createdAt), + updatedAt: DateTime.fromUnixMs(row.updatedAt), + createdBy: row.createdBy, + updatedBy: row.updatedBy, + }; + } +} diff --git a/src/modules/field/settings/company-settings.service.spec.ts b/src/modules/field/settings/company-settings.service.spec.ts new file mode 100644 index 0000000..1e37f2f --- /dev/null +++ b/src/modules/field/settings/company-settings.service.spec.ts @@ -0,0 +1,64 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; +import type { CompanySetting } from './company-setting'; +import { CompanySettingsRepository } from './company-settings.repository'; +import { CompanySettingsService } from './company-settings.service'; + +describe('CompanySettingsService', () => { + let service: CompanySettingsService; + let repository: jest.Mocked< + Pick + >; + + const now = DateTime.fromUnixMs(1_700_000_000_000); + const sample: CompanySetting = { + id: 'set-1', + cycleStartDate: DateTime.create('2026-01-05'), + status: Status.create('draft'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + beforeEach(async () => { + repository = { + find: jest.fn(), + upsert: jest.fn(), + }; + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + CompanySettingsService, + { provide: CompanySettingsRepository, useValue: repository }, + ], + }).compile(); + service = moduleRef.get(CompanySettingsService); + }); + + it('get throws when settings are missing', async () => { + repository.find.mockResolvedValue(null); + await expect(service.get()).rejects.toBeInstanceOf(NotFoundException); + }); + + it('get maps cycleStartDate to unix ms', async () => { + repository.find.mockResolvedValue(sample); + const result = await service.get(); + expect(result.cycleStartDate).toBe(sample.cycleStartDate.value); + }); + + it('update persists start of day', async () => { + repository.upsert.mockResolvedValue(sample); + await service.update('2026-01-05', 'user-1'); + const arg = repository.upsert.mock.calls[0][0]; + expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true); + expect(arg.userId).toBe('user-1'); + }); + + it('update rejects invalid dates', async () => { + await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/modules/field/settings/company-settings.service.ts b/src/modules/field/settings/company-settings.service.ts new file mode 100644 index 0000000..a09b4a0 --- /dev/null +++ b/src/modules/field/settings/company-settings.service.ts @@ -0,0 +1,67 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { CompanySettingsRepository } from './company-settings.repository'; +import type { CompanySetting } from './company-setting'; + +@Injectable() +export class CompanySettingsService { + constructor( + private readonly companySettingsRepository: CompanySettingsRepository, + ) {} + + async get(): Promise> { + const setting = await this.companySettingsRepository.find(); + if (!setting) { + throw new NotFoundException('Settings not configured'); + } + return this.toItem(setting); + } + + async update( + cycleStartDateRaw: string, + userId: string, + ): Promise> { + const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay(); + const saved = await this.companySettingsRepository.upsert({ + cycleStartDate, + userId, + }); + return this.toItem(saved); + } + + async requireCycleStartDate(): Promise { + const setting = await this.companySettingsRepository.find(); + if (!setting) { + throw new NotFoundException('Settings not configured'); + } + return setting.cycleStartDate; + } + + toItem(setting: CompanySetting) { + return { + id: setting.id, + cycleStartDate: setting.cycleStartDate.value, + status: setting.status.value, + createdAt: setting.createdAt.value, + updatedAt: setting.updatedAt.value, + createdBy: setting.createdBy, + updatedBy: setting.updatedBy, + }; + } + + private assertDate(raw: string): DateTime { + try { + return DateTime.create(raw); + } catch (error) { + if (error instanceof InvalidDateTimeError) { + throw new BadRequestException('Invalid cycle start date'); + } + throw error; + } + } +} diff --git a/src/modules/field/settings/dto/company-setting.dto.ts b/src/modules/field/settings/dto/company-setting.dto.ts new file mode 100644 index 0000000..9211ba4 --- /dev/null +++ b/src/modules/field/settings/dto/company-setting.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +export class UpdateCompanySettingDto { + @ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' }) + @IsString() + @IsNotEmpty() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'cycleStartDate must be a calendar date', + }) + cycleStartDate!: string; +} + +export class CompanySettingDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' }) + cycleStartDate!: number; + + @ApiProperty() + status!: string; + + @ApiProperty() + createdAt!: number; + + @ApiProperty() + updatedAt!: number; + + @ApiProperty({ format: 'uuid' }) + createdBy!: string; + + @ApiProperty({ format: 'uuid' }) + updatedBy!: string; +} diff --git a/src/modules/field/shared/field-fields.spec.ts b/src/modules/field/shared/field-fields.spec.ts new file mode 100644 index 0000000..9675751 --- /dev/null +++ b/src/modules/field/shared/field-fields.spec.ts @@ -0,0 +1,37 @@ +import { + isValidCycleNumber, + parseCsvRecord, + parseWeekdaysInput, +} from './field-fields'; + +describe('field-fields', () => { + it('accepts cycle numbers starting at 1', () => { + expect(isValidCycleNumber(1)).toBe(true); + expect(isValidCycleNumber(0)).toBe(false); + expect(isValidCycleNumber(1.5)).toBe(false); + }); + + it('treats omitted weekdays as days off and requires complete present days', () => { + const parsed = parseWeekdaysInput({ + monday: { + startBranchId: 'b1', + endBranchId: 'b2', + customerIds: ['c1'], + }, + }); + expect(parsed.monday?.customerIds).toEqual(['c1']); + expect(parsed.sunday).toBeUndefined(); + }); + + it('rejects an incomplete present weekday', () => { + expect(() => + parseWeekdaysInput({ + monday: { startBranchId: 'b1', endBranchId: 'b2', customerIds: [] }, + }), + ).toThrow(); + }); + + it('parses quoted CSV cells', () => { + expect(parseCsvRecord('a,"b,c",d')).toEqual(['a', 'b,c', 'd']); + }); +}); diff --git a/src/modules/field/shared/field-fields.ts b/src/modules/field/shared/field-fields.ts new file mode 100644 index 0000000..efde8f4 --- /dev/null +++ b/src/modules/field/shared/field-fields.ts @@ -0,0 +1,93 @@ +import type { WeekdayName } from './field-purpose'; +import { isWeekdayName } from './field-purpose'; + +export type WeekdayTemplateInput = { + readonly startBranchId: string; + readonly endBranchId: string; + readonly customerIds: readonly string[]; +}; + +export type WeekdaysInput = Partial>; + +export function isValidCycleNumber(raw: number): boolean { + return Number.isInteger(raw) && raw >= 1; +} + +export function assertCompleteWeekday( + raw: WeekdayTemplateInput | undefined, +): WeekdayTemplateInput | undefined { + if (raw === undefined) { + return undefined; + } + if ( + !raw.startBranchId || + !raw.endBranchId || + !Array.isArray(raw.customerIds) || + raw.customerIds.length === 0 + ) { + throw new Error('incomplete'); + } + return raw; +} + +export function parseWeekdaysInput(raw: unknown): WeekdaysInput { + if (raw === undefined || raw === null) { + return {}; + } + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('incomplete'); + } + const record = raw as Record; + const result: WeekdaysInput = {}; + for (const [key, value] of Object.entries(record)) { + if (!isWeekdayName(key)) { + throw new Error('incomplete'); + } + const complete = assertCompleteWeekday(value); + if (complete) { + result[key] = complete; + } + } + return result; +} + +/** RFC 4180-style record split that preserves commas inside quotes. */ +export function parseCsvRecord(line: string): string[] { + const cells: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + current += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + current += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + cells.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + cells.push(current.trim()); + return cells; +} + +export function isAllowedCsvUpload(file: { + mimetype: string; + originalname: string; +}): boolean { + return ( + file.mimetype.includes('csv') || + file.originalname.toLowerCase().endsWith('.csv') + ); +} diff --git a/src/modules/field/shared/field-privilege.decorator.ts b/src/modules/field/shared/field-privilege.decorator.ts new file mode 100644 index 0000000..7464d22 --- /dev/null +++ b/src/modules/field/shared/field-privilege.decorator.ts @@ -0,0 +1,19 @@ +import { SetMetadata } from '@nestjs/common'; +import type { PrivilegeAction } from '../../privileges/privilege-action'; +import type { FieldResource } from './field-purpose'; + +export const REQUIRE_FIELD_PRIVILEGE_KEY = 'requireFieldPrivilege'; + +export type RequireFieldPrivilegeMeta = { + readonly resource: FieldResource; + readonly action: PrivilegeAction; +}; + +export const RequireFieldPrivilege = ( + resource: FieldResource, + action: PrivilegeAction, +) => + SetMetadata(REQUIRE_FIELD_PRIVILEGE_KEY, { + resource, + action, + } satisfies RequireFieldPrivilegeMeta); diff --git a/src/modules/field/shared/field-privilege.guard.spec.ts b/src/modules/field/shared/field-privilege.guard.spec.ts new file mode 100644 index 0000000..2f5f507 --- /dev/null +++ b/src/modules/field/shared/field-privilege.guard.spec.ts @@ -0,0 +1,132 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import type { RequireFieldPrivilegeMeta } from './field-privilege.decorator'; +import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator'; +import { FieldPrivilegeGuard } from './field-privilege.guard'; + +describe('FieldPrivilegeGuard', () => { + const checkPermission = jest.fn(); + const getAllAndOverride = jest.fn(); + const reflector = { + getAllAndOverride, + } as unknown as Reflector; + + const guard = new FieldPrivilegeGuard(reflector, { + checkPermission, + } as never); + + const user: AuthUser = { + id: 'user-1', + username: 'alice', + jti: 'jti-1', + isSuperadmin: false, + }; + + function createContext( + currentUser?: AuthUser, + body?: { purpose?: string }, + query?: { purpose?: string }, + ): ExecutionContext { + return { + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + switchToHttp: () => ({ + getRequest: () => ({ user: currentUser, body, query }), + }), + } as unknown as ExecutionContext; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows when no field privilege metadata', async () => { + getAllAndOverride.mockReturnValue(undefined); + await expect(guard.canActivate(createContext(user))).resolves.toBe(true); + expect(checkPermission).not.toHaveBeenCalled(); + }); + + it('allows sales purpose when sales key is granted', async () => { + const meta: RequireFieldPrivilegeMeta = { + resource: 'cycle', + action: 'create', + }; + getAllAndOverride.mockReturnValue(meta); + checkPermission.mockImplementation((_id: string, key: string) => + Promise.resolve(key === 'SALES.CYCLE'), + ); + + await expect( + guard.canActivate(createContext(user, { purpose: 'sales' })), + ).resolves.toBe(true); + }); + + it('forbids logistics purpose for a sales-only user', async () => { + const meta: RequireFieldPrivilegeMeta = { + resource: 'plan', + action: 'update', + }; + getAllAndOverride.mockReturnValue(meta); + checkPermission.mockImplementation((_id: string, key: string) => + Promise.resolve(key === 'SALES.PLAN'), + ); + + await expect( + guard.canActivate(createContext(user, { purpose: 'logistics' })), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows list without purpose when either sales or logistics view is granted', async () => { + const meta: RequireFieldPrivilegeMeta = { + resource: 'cycle', + action: 'view', + }; + getAllAndOverride.mockReturnValue(meta); + checkPermission.mockImplementation((_id: string, key: string) => + Promise.resolve(key === 'LOGISTICS.CYCLE'), + ); + + await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe( + true, + ); + }); + + it('skips lookup for superadmin', async () => { + const meta: RequireFieldPrivilegeMeta = { + resource: 'cycle', + action: 'delete', + }; + getAllAndOverride.mockReturnValue(meta); + + await expect( + guard.canActivate(createContext({ ...user, isSuperadmin: true })), + ).resolves.toBe(true); + expect(checkPermission).not.toHaveBeenCalled(); + }); + + it('unauthorized when metadata present but no user', async () => { + const meta: RequireFieldPrivilegeMeta = { + resource: 'cycle', + action: 'view', + }; + getAllAndOverride.mockReturnValue(meta); + + await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('reads metadata from handler and class', async () => { + getAllAndOverride.mockReturnValue(undefined); + await guard.canActivate(createContext(user)); + expect(getAllAndOverride).toHaveBeenCalledWith( + REQUIRE_FIELD_PRIVILEGE_KEY, + expect.any(Array), + ); + }); +}); diff --git a/src/modules/field/shared/field-privilege.guard.ts b/src/modules/field/shared/field-privilege.guard.ts new file mode 100644 index 0000000..61a9194 --- /dev/null +++ b/src/modules/field/shared/field-privilege.guard.ts @@ -0,0 +1,92 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { AuthUser } from '../../../common/auth/auth-user'; +import type { PrivilegeAction } from '../../privileges/privilege-action'; +import { PrivilegesService } from '../../privileges/privileges.service'; +import { + fieldPrivilegeKey, + isFieldPurpose, + type FieldPurpose, + type FieldResource, +} from './field-purpose'; +import { + REQUIRE_FIELD_PRIVILEGE_KEY, + type RequireFieldPrivilegeMeta, +} from './field-privilege.decorator'; + +@Injectable() +export class FieldPrivilegeGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly privilegesService: PrivilegesService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const required = this.reflector.getAllAndOverride< + RequireFieldPrivilegeMeta | undefined + >(REQUIRE_FIELD_PRIVILEGE_KEY, [context.getHandler(), context.getClass()]); + + if (!required) { + return true; + } + + const request = context.switchToHttp().getRequest<{ + user?: AuthUser; + body?: { purpose?: string }; + query?: { purpose?: string }; + }>(); + const user = request.user; + if (!user) { + throw new UnauthorizedException(); + } + if (user.isSuperadmin) { + return true; + } + + const purposeRaw = request.body?.purpose ?? request.query?.purpose; + const allowed = await this.allowedPurposes( + user.id, + required.resource, + required.action, + ); + if (purposeRaw !== undefined && purposeRaw !== '') { + if (!isFieldPurpose(purposeRaw)) { + throw new ForbiddenException('Insufficient privilege'); + } + if (!allowed.includes(purposeRaw)) { + throw new ForbiddenException('Insufficient privilege'); + } + return true; + } + if (allowed.length === 0) { + throw new ForbiddenException('Insufficient privilege'); + } + return true; + } + + async allowedPurposes( + userId: string, + resource: FieldResource, + action: PrivilegeAction, + ): Promise { + const purposes: FieldPurpose[] = ['sales', 'logistics']; + const matches: FieldPurpose[] = []; + for (const purpose of purposes) { + const ok = await this.privilegesService.checkPermission( + userId, + fieldPrivilegeKey(resource, purpose), + action, + ); + if (ok) { + matches.push(purpose); + } + } + return matches; + } +} diff --git a/src/modules/field/shared/field-purpose.spec.ts b/src/modules/field/shared/field-purpose.spec.ts new file mode 100644 index 0000000..84ca29b --- /dev/null +++ b/src/modules/field/shared/field-purpose.spec.ts @@ -0,0 +1,37 @@ +import { + cycleNumberForDate, + fieldPrivilegeKey, + isFieldPurpose, + isWeekdayName, + noCycleMessage, +} from './field-purpose'; + +describe('field purpose helpers', () => { + it('accepts sales and logistics only', () => { + expect(isFieldPurpose('sales')).toBe(true); + expect(isFieldPurpose('logistics')).toBe(true); + expect(isFieldPurpose('delivery')).toBe(false); + }); + + it('accepts weekday names', () => { + expect(isWeekdayName('monday')).toBe(true); + expect(isWeekdayName('Monday')).toBe(false); + }); + + it('maps resource and purpose to privilege keys', () => { + expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE'); + expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN'); + }); + + it('returns purpose-specific missing-cycle messages', () => { + expect(noCycleMessage('sales')).toBe('User has no sales cycle'); + expect(noCycleMessage('logistics')).toBe('User has no logistics cycle'); + }); + + it('maps whole weeks to 1-based cycle numbers with wrap', () => { + expect(cycleNumberForDate(0, 3)).toBe(1); + expect(cycleNumberForDate(1, 3)).toBe(2); + expect(cycleNumberForDate(2, 3)).toBe(3); + expect(cycleNumberForDate(3, 3)).toBe(1); + }); +}); diff --git a/src/modules/field/shared/field-purpose.ts b/src/modules/field/shared/field-purpose.ts new file mode 100644 index 0000000..dd052f4 --- /dev/null +++ b/src/modules/field/shared/field-purpose.ts @@ -0,0 +1,58 @@ +export const FIELD_PURPOSES = ['sales', 'logistics'] as const; + +export type FieldPurpose = (typeof FIELD_PURPOSES)[number]; + +export function isFieldPurpose(raw: string): raw is FieldPurpose { + return (FIELD_PURPOSES as readonly string[]).includes(raw); +} + +export const WEEKDAY_NAMES = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +] as const; + +export type WeekdayName = (typeof WEEKDAY_NAMES)[number]; + +export function isWeekdayName(raw: string): raw is WeekdayName { + return (WEEKDAY_NAMES as readonly string[]).includes(raw); +} + +export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE'; +export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN'; +export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE'; +export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN'; +export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING'; + +export type FieldResource = 'cycle' | 'plan'; + +export function fieldPrivilegeKey( + resource: FieldResource, + purpose: FieldPurpose, +): string { + if (resource === 'cycle') { + return purpose === 'sales' + ? SALES_CYCLE_PRIVILEGE_KEY + : LOGISTICS_CYCLE_PRIVILEGE_KEY; + } + return purpose === 'sales' + ? SALES_PLAN_PRIVILEGE_KEY + : LOGISTICS_PLAN_PRIVILEGE_KEY; +} + +export function noCycleMessage(purpose: FieldPurpose): string { + return purpose === 'sales' + ? 'User has no sales cycle' + : 'User has no logistics cycle'; +} + +export function cycleNumberForDate( + wholeWeeksSinceEpoch: number, + totalCycles: number, +): number { + return (wholeWeeksSinceEpoch % totalCycles) + 1; +} diff --git a/src/modules/field/shared/route-geometry.dto.ts b/src/modules/field/shared/route-geometry.dto.ts new file mode 100644 index 0000000..00e6935 --- /dev/null +++ b/src/modules/field/shared/route-geometry.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RouteGeometryDto { + @ApiProperty({ example: 'LineString' }) + type!: string; + + @ApiProperty({ + type: 'array', + items: { + type: 'array', + items: { type: 'number' }, + minItems: 2, + maxItems: 2, + }, + example: [ + [106.8456, -6.2088], + [107.0, -6.3], + ], + }) + coordinates!: ReadonlyArray; +} diff --git a/src/modules/field/shared/route-line-string.spec.ts b/src/modules/field/shared/route-line-string.spec.ts new file mode 100644 index 0000000..23e5503 --- /dev/null +++ b/src/modules/field/shared/route-line-string.spec.ts @@ -0,0 +1,47 @@ +import { + buildRouteLineString, + isUsableRouteGeometry, +} from './route-line-string'; + +describe('buildRouteLineString', () => { + it('preserves given order and skips unlocated points', () => { + const geometry = buildRouteLineString([ + { longitude: 106.8, latitude: -6.2 }, + { longitude: null, latitude: -6.3 }, + { longitude: 106.9, latitude: null }, + { longitude: 107.0, latitude: -6.4 }, + { longitude: 107.1, latitude: -6.5 }, + ]); + + expect(geometry).toEqual({ + type: 'LineString', + coordinates: [ + [106.8, -6.2], + [107.0, -6.4], + [107.1, -6.5], + ], + }); + }); + + it('returns two coordinates when only start and end locate', () => { + const geometry = buildRouteLineString([ + { longitude: 106.8, latitude: -6.2 }, + { longitude: null, latitude: null }, + { longitude: 107.0, latitude: -6.4 }, + ]); + + expect(geometry.coordinates).toEqual([ + [106.8, -6.2], + [107.0, -6.4], + ]); + expect(isUsableRouteGeometry(geometry)).toBe(true); + }); + + it('is not usable with fewer than two located points', () => { + const none = buildRouteLineString([{ longitude: null, latitude: null }]); + const one = buildRouteLineString([{ longitude: 106.8, latitude: -6.2 }]); + + expect(isUsableRouteGeometry(none)).toBe(false); + expect(isUsableRouteGeometry(one)).toBe(false); + }); +}); diff --git a/src/modules/field/shared/route-line-string.ts b/src/modules/field/shared/route-line-string.ts new file mode 100644 index 0000000..8149988 --- /dev/null +++ b/src/modules/field/shared/route-line-string.ts @@ -0,0 +1,44 @@ +export type GeoPoint = { + readonly longitude: number | null; + readonly latitude: number | null; +}; + +export type RouteGeometry = { + readonly type: 'LineString'; + readonly coordinates: readonly (readonly [number, number])[]; +}; + +export function isLocatedPoint( + point: GeoPoint, +): point is { readonly longitude: number; readonly latitude: number } { + return ( + typeof point.longitude === 'number' && + Number.isFinite(point.longitude) && + typeof point.latitude === 'number' && + Number.isFinite(point.latitude) + ); +} + +/** + * Builds a LineString from points in the given order, skipping unlocated ones. + * Coordinates are [longitude, latitude]. + */ +export function buildRouteLineString( + points: readonly GeoPoint[], +): RouteGeometry { + const coordinates: Array = []; + for (const point of points) { + if (!isLocatedPoint(point)) { + continue; + } + coordinates.push([point.longitude, point.latitude]); + } + return { + type: 'LineString', + coordinates, + }; +} + +export function isUsableRouteGeometry(geometry: RouteGeometry): boolean { + return geometry.coordinates.length >= 2; +} diff --git a/test/company-settings.e2e-spec.ts b/test/company-settings.e2e-spec.ts new file mode 100644 index 0000000..9e6450f --- /dev/null +++ b/test/company-settings.e2e-spec.ts @@ -0,0 +1,120 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { eq } from 'drizzle-orm'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/common/configure-app'; +import { DRIZZLE, type DrizzleDB } from '../src/database/database.module'; +import { + privilegeDetails, + privilegeKeys, + privileges, + users, +} from '../src/database/schema'; +import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; + +describe('Company settings (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `set_admin_${Date.now()}`; + const otherUsername = `set_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + configureApp(app, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + }); + await app.init(); + db = app.get(DRIZZLE); + + const adminReg = await request(app.getHttpServer()) + .post('/auth/register') + .send({ username: adminUsername, password }) + .expect(201); + adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + + const adminMe = await request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + adminUserId = (adminMe.body as { id: string }).id; + + const otherReg = await request(app.getHttpServer()) + .post('/auth/register') + .send({ username: otherUsername, password }) + .expect(201); + otherAccessToken = (otherReg.body as { accessToken: string }).accessToken; + + const now = Date.now(); + const [priv] = await db + .insert(privileges) + .values({ + name: 'Settings Admin', + code: `SET_ADMIN_${now}`, + status: 'active', + createdAt: now, + updatedAt: now, + createdBy: adminUserId, + updatedBy: adminUserId, + }) + .returning(); + + const keys = await db.select().from(privilegeKeys); + const detailRows = keys.flatMap((key) => + PRIVILEGE_ACTIONS.map((action) => ({ + privilegeId: priv.id, + privilegeKeyId: key.id, + action, + value: true, + })), + ); + await db.insert(privilegeDetails).values(detailRows); + + await db + .update(users) + .set({ privilegeId: priv.id, updatedAt: Date.now() }) + .where(eq(users.id, adminUserId)); + }); + + afterAll(async () => { + await app.close(); + }); + + it('forbids settings without permission', async () => { + await request(app.getHttpServer()) + .get('/settings') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('upserts the cycle start date', async () => { + const updated = await request(app.getHttpServer()) + .patch('/settings') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ cycleStartDate: '2026-01-05' }) + .expect(200); + expect((updated.body as { cycleStartDate: number }).cycleStartDate).toBe( + Date.parse('2026-01-04T17:00:00.000Z'), + ); + + const got = await request(app.getHttpServer()) + .get('/settings') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect((got.body as { cycleStartDate: number }).cycleStartDate).toBe( + (updated.body as { cycleStartDate: number }).cycleStartDate, + ); + }); +}); diff --git a/test/cycles.e2e-spec.ts b/test/cycles.e2e-spec.ts new file mode 100644 index 0000000..b6fe218 --- /dev/null +++ b/test/cycles.e2e-spec.ts @@ -0,0 +1,238 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { eq } from 'drizzle-orm'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/common/configure-app'; +import { DRIZZLE, type DrizzleDB } from '../src/database/database.module'; +import { + privilegeDetails, + privilegeKeys, + privileges, + users, +} from '../src/database/schema'; +import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; + +describe('Cycles (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `cyc_admin_${Date.now()}`; + const otherUsername = `cyc_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + let employeeId: string; + let startBranchId: string; + let endBranchId: string; + let customerId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + configureApp(app, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + }); + await app.init(); + db = app.get(DRIZZLE); + + const adminReg = await request(app.getHttpServer()) + .post('/auth/register') + .send({ username: adminUsername, password }) + .expect(201); + adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + + const adminMe = await request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + adminUserId = (adminMe.body as { id: string }).id; + + const otherReg = await request(app.getHttpServer()) + .post('/auth/register') + .send({ username: otherUsername, password }) + .expect(201); + otherAccessToken = (otherReg.body as { accessToken: string }).accessToken; + + const now = Date.now(); + const [priv] = await db + .insert(privileges) + .values({ + name: 'Cycle Admin', + code: `CYC_ADMIN_${now}`, + status: 'active', + createdAt: now, + updatedAt: now, + createdBy: adminUserId, + updatedBy: adminUserId, + }) + .returning(); + + const keys = await db.select().from(privilegeKeys); + await db.insert(privilegeDetails).values( + keys.flatMap((key) => + PRIVILEGE_ACTIONS.map((action) => ({ + privilegeId: priv.id, + privilegeKeyId: key.id, + action, + value: true, + })), + ), + ); + await db + .update(users) + .set({ privilegeId: priv.id, updatedAt: Date.now() }) + .where(eq(users.id, adminUserId)); + + const suffix = Date.now().toString().slice(-6); + const employee = await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `CYC_E_${suffix}`, + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }) + .expect(201); + employeeId = (employee.body as { id: string }).id; + + const start = await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `CYC_S_${suffix}`, + name: 'Start Branch', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + latitude: -6.2, + longitude: 106.8, + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + }) + .expect(201); + startBranchId = (start.body as { id: string }).id; + + const end = await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `CYC_E_${suffix}`, + name: 'End Branch', + phone: '+6281234567891', + address: 'Jl Thamrin No 2', + latitude: -6.21, + longitude: 106.81, + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + }) + .expect(201); + endBranchId = (end.body as { id: string }).id; + + const customer = await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `CYC_C_${suffix}`, + name: 'Acme Corp', + phone: '+6281234567892', + address: 'Jl Gatot No 3', + latitude: -6.22, + longitude: 106.82, + }) + .expect(201); + customerId = (customer.body as { id: string }).id; + }); + + afterAll(async () => { + await app.close(); + }); + + it('forbids cycles list without permission', async () => { + await request(app.getHttpServer()) + .get('/cycles') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('creates a sales cycle, rejects an incomplete day, and archives on delete', async () => { + await request(app.getHttpServer()) + .post('/cycles') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + employeeId, + purpose: 'sales', + cycleNumber: 1, + weekdays: { + monday: { + startBranchId, + endBranchId, + customerIds: [], + }, + }, + }) + .expect(400); + + const created = await request(app.getHttpServer()) + .post('/cycles') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + employeeId, + purpose: 'sales', + cycleNumber: 1, + weekdays: { + monday: { + startBranchId, + endBranchId, + customerIds: [customerId], + }, + }, + }) + .expect(201); + + const id = (created.body as { id: string }).id; + expect((created.body as { purpose: string }).purpose).toBe('sales'); + expect( + (created.body as { weekdays: Array<{ weekday: string }> }).weekdays, + ).toHaveLength(1); + + await request(app.getHttpServer()) + .post('/cycles') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + employeeId, + purpose: 'sales', + cycleNumber: 1, + weekdays: { + tuesday: { + startBranchId, + endBranchId, + customerIds: [customerId], + }, + }, + }) + .expect(409); + + await request(app.getHttpServer()) + .delete(`/cycles/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(204); + + const detail = await request(app.getHttpServer()) + .get(`/cycles/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect((detail.body as { status: string }).status).toBe('archived'); + }); +}); diff --git a/test/plans.e2e-spec.ts b/test/plans.e2e-spec.ts new file mode 100644 index 0000000..4a310de --- /dev/null +++ b/test/plans.e2e-spec.ts @@ -0,0 +1,276 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { eq } from 'drizzle-orm'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/common/configure-app'; +import { DRIZZLE, type DrizzleDB } from '../src/database/database.module'; +import { + privilegeDetails, + privilegeKeys, + privileges, + users, +} from '../src/database/schema'; +import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; + +describe('Plans (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `pln_admin_${Date.now()}`; + const otherUsername = `pln_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + let employeeId: string; + let startBranchId: string; + let endBranchId: string; + let customerId: string; + let extraCustomerId: string; + let cycleId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + configureApp(app, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + }); + await app.init(); + db = app.get(DRIZZLE); + + const adminReg = await request(app.getHttpServer()) + .post('/auth/register') + .send({ username: adminUsername, password }) + .expect(201); + adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + + const adminMe = await request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + adminUserId = (adminMe.body as { id: string }).id; + + const otherReg = await request(app.getHttpServer()) + .post('/auth/register') + .send({ username: otherUsername, password }) + .expect(201); + otherAccessToken = (otherReg.body as { accessToken: string }).accessToken; + + const now = Date.now(); + const [priv] = await db + .insert(privileges) + .values({ + name: 'Plan Admin', + code: `PLN_ADMIN_${now}`, + status: 'active', + createdAt: now, + updatedAt: now, + createdBy: adminUserId, + updatedBy: adminUserId, + }) + .returning(); + + const keys = await db.select().from(privilegeKeys); + await db.insert(privilegeDetails).values( + keys.flatMap((key) => + PRIVILEGE_ACTIONS.map((action) => ({ + privilegeId: priv.id, + privilegeKeyId: key.id, + action, + value: true, + })), + ), + ); + await db + .update(users) + .set({ privilegeId: priv.id, updatedAt: Date.now() }) + .where(eq(users.id, adminUserId)); + + const suffix = Date.now().toString().slice(-6); + const employee = await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `PLN_E_${suffix}`, + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }) + .expect(201); + employeeId = (employee.body as { id: string }).id; + + const start = await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `PLN_S_${suffix}`, + name: 'Start Branch', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + latitude: -6.2, + longitude: 106.8, + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + }) + .expect(201); + startBranchId = (start.body as { id: string }).id; + + const end = await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `PLN_N_${suffix}`, + name: 'End Branch', + phone: '+6281234567891', + address: 'Jl Thamrin No 2', + latitude: -6.21, + longitude: 106.81, + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + }) + .expect(201); + endBranchId = (end.body as { id: string }).id; + + const customer = await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `PLN_C_${suffix}`, + name: 'Acme Corp', + phone: '+6281234567892', + address: 'Jl Gatot No 3', + latitude: -6.22, + longitude: 106.82, + }) + .expect(201); + customerId = (customer.body as { id: string }).id; + + const extra = await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `PLN_D_${suffix}`, + name: 'Beta Corp', + phone: '+6281234567893', + address: 'Jl Rasuna No 4', + latitude: -6.23, + longitude: 106.83, + }) + .expect(201); + extraCustomerId = (extra.body as { id: string }).id; + + await request(app.getHttpServer()) + .patch('/settings') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ cycleStartDate: '2026-01-05' }) + .expect(200); + + const cycle = await request(app.getHttpServer()) + .post('/cycles') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + employeeId, + purpose: 'sales', + cycleNumber: 1, + weekdays: { + monday: { + startBranchId, + endBranchId, + customerIds: [customerId], + }, + }, + }) + .expect(201); + cycleId = (cycle.body as { id: string }).id; + + await request(app.getHttpServer()) + .patch(`/cycles/${cycleId}/status`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(200); + }); + + afterAll(async () => { + await app.close(); + }); + + it('forbids plans list without permission', async () => { + await request(app.getHttpServer()) + .get('/plans') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('generates Monday plans, skips existing, and supports D-Day destination edits', async () => { + const generated = await request(app.getHttpServer()) + .post('/plans/generate') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + employeeId, + purpose: 'sales', + from: '2026-01-05', + to: '2026-01-12', + }) + .expect(201); + expect((generated.body as { created: number }).created).toBe(2); + expect((generated.body as { skipped: number }).skipped).toBeGreaterThan(0); + + const again = await request(app.getHttpServer()) + .post('/plans/generate') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + employeeId, + purpose: 'sales', + from: '2026-01-05', + to: '2026-01-12', + }) + .expect(201); + expect((again.body as { created: number }).created).toBe(0); + + const list = await request(app.getHttpServer()) + .get('/plans') + .query({ employeeId, purpose: 'sales', date: '2026-01-05' }) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + const planId = (list.body.data as Array<{ id: string }>)[0].id; + + const added = await request(app.getHttpServer()) + .post(`/plans/${planId}/destinations`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ customerId: extraCustomerId }) + .expect(201); + expect( + (added.body as { destinations: Array<{ customerId: string }> }) + .destinations, + ).toHaveLength(2); + + const extraDest = ( + added.body as { destinations: Array<{ id: string; customerId: string }> } + ).destinations.find((d) => d.customerId === extraCustomerId); + const removed = await request(app.getHttpServer()) + .delete(`/plans/${planId}/destinations/${extraDest?.id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect( + (removed.body as { destinations: unknown[] }).destinations, + ).toHaveLength(1); + + const lastId = (removed.body as { destinations: Array<{ id: string }> }) + .destinations[0].id; + await request(app.getHttpServer()) + .delete(`/plans/${planId}/destinations/${lastId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(400); + }); +});