Add field management module with database schema and validation
- Introduced `FieldModule` to manage cycles and plans, including read and write controllers. - Created database migrations for `company_settings`, `cycles`, `cycle_weekdays`, `cycle_destinations`, `plans`, `plan_destinations`, `plan_invoices`, and `plan_packing_slips` tables, including constraints and unique indexes. - Developed service and repository layers for handling cycle and plan data operations. - Added unit tests for the cycles and plans services, repositories, and controllers to ensure functionality and correctness. - Updated application module to include the new `FieldModule` for better organization.
This commit is contained in:
@@ -0,0 +1,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);
|
||||||
@@ -78,6 +78,13 @@
|
|||||||
"when": 1787558000000,
|
"when": 1787558000000,
|
||||||
"tag": "0010_phase2_shape",
|
"tag": "0010_phase2_shape",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787559000000,
|
||||||
|
"tag": "0011_field",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import loadEnv from './config/env';
|
|||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||||
|
import { FieldModule } from './modules/field/field.module';
|
||||||
import { SalesModule } from './modules/sales/sales.module';
|
import { SalesModule } from './modules/sales/sales.module';
|
||||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
@@ -22,6 +23,7 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
PrivilegesModule,
|
PrivilegesModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
SalesModule,
|
SalesModule,
|
||||||
|
FieldModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -92,8 +92,24 @@ describe('DateTime', () => {
|
|||||||
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects date-only strings', () => {
|
it('parses date-only strings as start of day in DEFAULT_TIMEZONE (GMT+7)', () => {
|
||||||
expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError);
|
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', () => {
|
it('rejects empty string', () => {
|
||||||
@@ -299,4 +315,82 @@ describe('DateTime', () => {
|
|||||||
).toThrow();
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ const MAX_UNIX_MS = 8_640_000_000_000_000;
|
|||||||
const ISO_DATETIME =
|
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;
|
/^(\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 */
|
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
|
||||||
const TZ_OFFSET =
|
const TZ_OFFSET =
|
||||||
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
|
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
|
||||||
@@ -149,6 +167,20 @@ export class DateTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = raw.trim();
|
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);
|
const match = ISO_DATETIME.exec(trimmed);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new InvalidDateTimeError();
|
throw new InvalidDateTimeError();
|
||||||
@@ -175,7 +207,7 @@ export class DateTime {
|
|||||||
offsetMinutes = resolveDefaultOffsetMinutes();
|
offsetMinutes = resolveDefaultOffsetMinutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
const utcMs = utcMsFromParts(
|
return DateTime.fromParts(
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
day,
|
day,
|
||||||
@@ -185,8 +217,6 @@ export class DateTime {
|
|||||||
ms,
|
ms,
|
||||||
offsetMinutes,
|
offsetMinutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DateTime(utcMs, DateTime.createToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromUnixMs(ms: number): DateTime {
|
static fromUnixMs(ms: number): DateTime {
|
||||||
@@ -217,6 +247,32 @@ export class DateTime {
|
|||||||
return formatInOffset(this.unixMs, offsetMinutes);
|
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 {
|
toString(): string {
|
||||||
return this.format();
|
return this.format();
|
||||||
}
|
}
|
||||||
@@ -224,4 +280,27 @@ export class DateTime {
|
|||||||
toJSON(): number {
|
toJSON(): number {
|
||||||
return this.unixMs;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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<StoredRouteGeometry>()
|
||||||
|
.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;
|
||||||
@@ -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<StoredRouteGeometry>()
|
||||||
|
.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;
|
||||||
@@ -210,3 +210,29 @@ export {
|
|||||||
type SalesPaymentInvoiceRow,
|
type SalesPaymentInvoiceRow,
|
||||||
type SalesPaymentRow,
|
type SalesPaymentRow,
|
||||||
} from './sales-payments-table';
|
} 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';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ async function bootstrap() {
|
|||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
configureApp(app);
|
configureApp(app);
|
||||||
|
app.enableCors();
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
}
|
}
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ export class BranchesService {
|
|||||||
return this.toListItem(branch);
|
return this.toListItem(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByCode(
|
||||||
|
code: string,
|
||||||
|
): Promise<ReturnType<BranchesService['toListItem']>> {
|
||||||
|
const branch = await this.branchesRepository.findByCode(code);
|
||||||
|
if (!branch) {
|
||||||
|
throw new NotFoundException('Branch not found');
|
||||||
|
}
|
||||||
|
return this.toListItem(branch);
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ export class CustomersRepository {
|
|||||||
return this.toDomain(row, contacts);
|
return this.toDomain(row, contacts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Customer | null> {
|
||||||
|
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<Customer> {
|
async create(input: CreateCustomerInput): Promise<Customer> {
|
||||||
const now = DateTime.fromUnixMs(Date.now());
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ export class CustomersService {
|
|||||||
return this.toDetail(customer);
|
return this.toDetail(customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByCode(
|
||||||
|
code: string,
|
||||||
|
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
const customer = await this.customersRepository.findByCode(code);
|
||||||
|
if (!customer) {
|
||||||
|
throw new NotFoundException('Customer not found');
|
||||||
|
}
|
||||||
|
return this.toDetail(customer);
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export class EmployeesService {
|
|||||||
return this.toListItem(employee);
|
return this.toListItem(employee);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByCode(
|
||||||
|
code: string,
|
||||||
|
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||||
|
const employee = await this.employeesRepository.findByCode(code);
|
||||||
|
if (!employee) {
|
||||||
|
throw new NotFoundException('Employee not found');
|
||||||
|
}
|
||||||
|
return this.toListItem(employee);
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PaginationResponse<CycleDto>> {
|
||||||
|
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<CycleDto> {
|
||||||
|
return this.cyclesService.findById(id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<CycleDto> {
|
||||||
|
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<CycleDto> {
|
||||||
|
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<CycleDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.cyclesService.delete(id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||||
|
|
||||||
|
@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<T>(qb: T, filters: ListCyclesFilters): T {
|
||||||
|
void filters;
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Cycle | null> {
|
||||||
|
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<Cycle | null> {
|
||||||
|
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<Cycle[]> {
|
||||||
|
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<Cycle> {
|
||||||
|
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<Cycle> {
|
||||||
|
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<Cycle> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<Cycle> {
|
||||||
|
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<void> {
|
||||||
|
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<CycleWeekdayRow[]> {
|
||||||
|
return executor
|
||||||
|
.select()
|
||||||
|
.from(cycleWeekdays)
|
||||||
|
.where(eq(cycleWeekdays.cycleId, cycleId))
|
||||||
|
.orderBy(asc(cycleWeekdays.weekday));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectDestinations(
|
||||||
|
executor: QueryExecutor,
|
||||||
|
weekdayIds: string[],
|
||||||
|
): Promise<CycleDestinationRow[]> {
|
||||||
|
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<string, CycleDestinationRow[]>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PaginationResponse<ReturnType<CyclesService['toItem']>>> {
|
||||||
|
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<ReturnType<CyclesService['toItem']>> {
|
||||||
|
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<ReturnType<CyclesService['toItem']>> {
|
||||||
|
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<ReturnType<CyclesService['toItem']>> {
|
||||||
|
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<ReturnType<CyclesService['toItem']>> {
|
||||||
|
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<void> {
|
||||||
|
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<PersistableWeekday[]> {
|
||||||
|
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<Cycle> {
|
||||||
|
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<void> {
|
||||||
|
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<FieldPurpose[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CycleWeekdayBodyDto>;
|
||||||
|
|
||||||
|
@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<string, CycleWeekdayBodyDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PaginationResponse<PlanDto>> {
|
||||||
|
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<PlanDto> {
|
||||||
|
return this.plansService.findById(id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PlanDto> {
|
||||||
|
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<PlanDto> {
|
||||||
|
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<PlanDto> {
|
||||||
|
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<PlanDto> {
|
||||||
|
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<PlanDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.plansService.delete(id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
ne,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
SQL,
|
||||||
|
} from 'drizzle-orm';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import {
|
||||||
|
planDestinations,
|
||||||
|
planInvoices,
|
||||||
|
planPackingSlips,
|
||||||
|
plans,
|
||||||
|
type PlanDestinationRow,
|
||||||
|
type PlanInvoiceRow,
|
||||||
|
type PlanPackingSlipRow,
|
||||||
|
type PlanRow,
|
||||||
|
} from '../../../database/plans-table';
|
||||||
|
import type { FieldPurpose } from '../shared/field-purpose';
|
||||||
|
import type { RouteGeometry } from '../shared/route-line-string';
|
||||||
|
import type { ListPlansFilters, Plan } from './plan';
|
||||||
|
|
||||||
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlansRepository {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: ListPlansFilters,
|
||||||
|
): Promise<{ data: Plan[]; total: number }> {
|
||||||
|
const where = this.buildListWhere(filters);
|
||||||
|
const totalRows = await this.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(plans)
|
||||||
|
.where(where);
|
||||||
|
let qb = this.db.select().from(plans).$dynamic();
|
||||||
|
qb = this.extendListQuery(qb, filters);
|
||||||
|
const rows = await qb
|
||||||
|
.where(where)
|
||||||
|
.orderBy(asc(plans.date))
|
||||||
|
.limit(filters.limit)
|
||||||
|
.offset(filters.offset);
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => this.toDomain(row, [], [], [])),
|
||||||
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
extendListQuery<T>(qb: T, filters: ListPlansFilters): T {
|
||||||
|
void filters;
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Plan | null> {
|
||||||
|
const rows: PlanRow[] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plans)
|
||||||
|
.where(eq(plans.id, id))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.loadWithChildren(this.db, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLiveByKey(
|
||||||
|
employeeId: string,
|
||||||
|
purpose: string,
|
||||||
|
date: number,
|
||||||
|
excludeId?: string,
|
||||||
|
): Promise<Plan | null> {
|
||||||
|
const parts: SQL[] = [
|
||||||
|
eq(plans.employeeId, employeeId),
|
||||||
|
eq(plans.purpose, purpose),
|
||||||
|
eq(plans.date, date),
|
||||||
|
ne(plans.status, 'archived'),
|
||||||
|
];
|
||||||
|
if (excludeId) {
|
||||||
|
parts.push(ne(plans.id, excludeId));
|
||||||
|
}
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plans)
|
||||||
|
.where(and(...parts))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? this.toDomain(row, [], [], []) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
employeeId: string;
|
||||||
|
purpose: FieldPurpose;
|
||||||
|
date: DateTime;
|
||||||
|
startBranchId: string;
|
||||||
|
endBranchId: string;
|
||||||
|
routeGeometry: RouteGeometry;
|
||||||
|
customerIds: readonly string[];
|
||||||
|
invoiceIds: readonly string[];
|
||||||
|
packingSlipIds: readonly string[];
|
||||||
|
status: Status;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<Plan> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
return await this.db.transaction(async (tx) => {
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(plans)
|
||||||
|
.values({
|
||||||
|
employeeId: input.employeeId,
|
||||||
|
purpose: input.purpose,
|
||||||
|
date: input.date.value,
|
||||||
|
startBranchId: input.startBranchId,
|
||||||
|
endBranchId: input.endBranchId,
|
||||||
|
routeGeometry: input.routeGeometry,
|
||||||
|
status: input.status.value,
|
||||||
|
createdAt: now.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
createdBy: input.userId,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const row = inserted[0];
|
||||||
|
await this.replaceChildren(tx, row.id, input);
|
||||||
|
return this.loadWithChildren(tx, row);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowConstraintViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
employeeId: string;
|
||||||
|
purpose: FieldPurpose;
|
||||||
|
date: DateTime;
|
||||||
|
startBranchId: string;
|
||||||
|
endBranchId: string;
|
||||||
|
routeGeometry: RouteGeometry;
|
||||||
|
customerIds?: readonly string[];
|
||||||
|
invoiceIds?: readonly string[];
|
||||||
|
packingSlipIds?: readonly string[];
|
||||||
|
userId: string;
|
||||||
|
},
|
||||||
|
): Promise<Plan> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Plan not found');
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
return await this.db.transaction(async (tx) => {
|
||||||
|
const updated = await tx
|
||||||
|
.update(plans)
|
||||||
|
.set({
|
||||||
|
employeeId: input.employeeId,
|
||||||
|
purpose: input.purpose,
|
||||||
|
date: input.date.value,
|
||||||
|
startBranchId: input.startBranchId,
|
||||||
|
endBranchId: input.endBranchId,
|
||||||
|
routeGeometry: input.routeGeometry,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.where(eq(plans.id, id))
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
await this.replaceChildren(tx, id, {
|
||||||
|
customerIds: input.customerIds,
|
||||||
|
invoiceIds: input.invoiceIds,
|
||||||
|
packingSlipIds: input.packingSlipIds,
|
||||||
|
});
|
||||||
|
return this.loadWithChildren(tx, row);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowConstraintViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceDestinations(
|
||||||
|
id: string,
|
||||||
|
customerIds: readonly string[],
|
||||||
|
routeGeometry: RouteGeometry,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Plan> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Plan not found');
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
return this.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(plans)
|
||||||
|
.set({
|
||||||
|
routeGeometry,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(plans.id, id));
|
||||||
|
await tx.delete(planDestinations).where(eq(planDestinations.planId, id));
|
||||||
|
if (customerIds.length > 0) {
|
||||||
|
await tx.insert(planDestinations).values(
|
||||||
|
customerIds.map((customerId, index) => ({
|
||||||
|
planId: id,
|
||||||
|
customerId,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows = await tx
|
||||||
|
.select()
|
||||||
|
.from(plans)
|
||||||
|
.where(eq(plans.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return this.loadWithChildren(tx, rows[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Plan> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const updated = await this.db
|
||||||
|
.update(plans)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(plans.id, id))
|
||||||
|
.returning({ id: plans.id });
|
||||||
|
if (updated.length === 0) {
|
||||||
|
throw new NotFoundException('Plan not found');
|
||||||
|
}
|
||||||
|
const found = await this.findById(id);
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundException('Plan not found');
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const rows = await this.db
|
||||||
|
.update(plans)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(inArray(plans.id, ids))
|
||||||
|
.returning({ id: plans.id });
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(id: string, userId: string): Promise<void> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const updated = await this.db
|
||||||
|
.update(plans)
|
||||||
|
.set({
|
||||||
|
status: 'archived',
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(plans.id, id))
|
||||||
|
.returning({ id: plans.id });
|
||||||
|
if (updated.length === 0) {
|
||||||
|
throw new NotFoundException('Plan not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkArchive(ids: string[], userId: string): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const rows = await this.db
|
||||||
|
.update(plans)
|
||||||
|
.set({
|
||||||
|
status: 'archived',
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(inArray(plans.id, ids))
|
||||||
|
.returning({ id: plans.id });
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadWithChildren(
|
||||||
|
executor: QueryExecutor,
|
||||||
|
row: PlanRow,
|
||||||
|
): Promise<Plan> {
|
||||||
|
const destinations = await executor
|
||||||
|
.select()
|
||||||
|
.from(planDestinations)
|
||||||
|
.where(eq(planDestinations.planId, row.id))
|
||||||
|
.orderBy(asc(planDestinations.sortOrder));
|
||||||
|
const invoices = await executor
|
||||||
|
.select()
|
||||||
|
.from(planInvoices)
|
||||||
|
.where(eq(planInvoices.planId, row.id));
|
||||||
|
const packingSlips = await executor
|
||||||
|
.select()
|
||||||
|
.from(planPackingSlips)
|
||||||
|
.where(eq(planPackingSlips.planId, row.id));
|
||||||
|
return this.toDomain(row, destinations, invoices, packingSlips);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceChildren(
|
||||||
|
executor: QueryExecutor,
|
||||||
|
planId: string,
|
||||||
|
input: {
|
||||||
|
customerIds?: readonly string[];
|
||||||
|
invoiceIds?: readonly string[];
|
||||||
|
packingSlipIds?: readonly string[];
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
if (input.customerIds) {
|
||||||
|
await executor
|
||||||
|
.delete(planDestinations)
|
||||||
|
.where(eq(planDestinations.planId, planId));
|
||||||
|
if (input.customerIds.length > 0) {
|
||||||
|
await executor.insert(planDestinations).values(
|
||||||
|
input.customerIds.map((customerId, index) => ({
|
||||||
|
planId,
|
||||||
|
customerId,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.invoiceIds) {
|
||||||
|
await executor
|
||||||
|
.delete(planInvoices)
|
||||||
|
.where(eq(planInvoices.planId, planId));
|
||||||
|
if (input.invoiceIds.length > 0) {
|
||||||
|
await executor
|
||||||
|
.insert(planInvoices)
|
||||||
|
.values(input.invoiceIds.map((invoiceId) => ({ planId, invoiceId })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.packingSlipIds) {
|
||||||
|
await executor
|
||||||
|
.delete(planPackingSlips)
|
||||||
|
.where(eq(planPackingSlips.planId, planId));
|
||||||
|
if (input.packingSlipIds.length > 0) {
|
||||||
|
await executor.insert(planPackingSlips).values(
|
||||||
|
input.packingSlipIds.map((packingSlipId) => ({
|
||||||
|
planId,
|
||||||
|
packingSlipId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildListWhere(filters: ListPlansFilters): SQL | undefined {
|
||||||
|
const parts: SQL[] = [];
|
||||||
|
if (filters.employeeId) {
|
||||||
|
parts.push(eq(plans.employeeId, filters.employeeId));
|
||||||
|
}
|
||||||
|
if (filters.purpose) {
|
||||||
|
parts.push(eq(plans.purpose, filters.purpose));
|
||||||
|
} else if (filters.purposes && filters.purposes.length > 0) {
|
||||||
|
parts.push(inArray(plans.purpose, [...filters.purposes]));
|
||||||
|
}
|
||||||
|
if (filters.date !== undefined) {
|
||||||
|
parts.push(eq(plans.date, filters.date));
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
parts.push(eq(plans.status, filters.status));
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
const search = or(
|
||||||
|
ilike(plans.purpose, `%${filters.search}%`),
|
||||||
|
sql`${plans.date}::text ilike ${'%' + filters.search + '%'}`,
|
||||||
|
);
|
||||||
|
if (search) {
|
||||||
|
parts.push(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parts.length === 1 ? parts[0] : and(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(
|
||||||
|
row: PlanRow,
|
||||||
|
destinationRows: PlanDestinationRow[],
|
||||||
|
invoiceRows: PlanInvoiceRow[],
|
||||||
|
packingSlipRows: PlanPackingSlipRow[],
|
||||||
|
): Plan {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
purpose: row.purpose as FieldPurpose,
|
||||||
|
date: DateTime.fromUnixMs(row.date),
|
||||||
|
startBranchId: row.startBranchId,
|
||||||
|
endBranchId: row.endBranchId,
|
||||||
|
routeGeometry: row.routeGeometry,
|
||||||
|
destinations: destinationRows.map((destination) => ({
|
||||||
|
id: destination.id,
|
||||||
|
customerId: destination.customerId,
|
||||||
|
sortOrder: destination.sortOrder,
|
||||||
|
})),
|
||||||
|
invoiceIds: invoiceRows.map((row) => row.invoiceId),
|
||||||
|
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
|
||||||
|
status: Status.create(row.status),
|
||||||
|
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||||
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
|
const err = this.unwrapDbError(error);
|
||||||
|
if (err.code === '23505') {
|
||||||
|
throw new ConflictException('Plan already exists for this employee');
|
||||||
|
}
|
||||||
|
if (err.code === '23503') {
|
||||||
|
throw new ConflictException('Plan references a missing record');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapDbError(error: unknown): { code?: string } {
|
||||||
|
let current: unknown = error;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if (!current || typeof current !== 'object') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const obj = current as { code?: string; cause?: unknown };
|
||||||
|
if (obj.code === '23505' || obj.code === '23503') {
|
||||||
|
return { code: obj.code };
|
||||||
|
}
|
||||||
|
current = obj.cause;
|
||||||
|
}
|
||||||
|
return error as { code?: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PaginationResponse<ReturnType<PlansService['toItem']>>> {
|
||||||
|
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<ReturnType<PlansService['toItem']>> {
|
||||||
|
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<ReturnType<PlansService['toItem']>> {
|
||||||
|
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<ReturnType<PlansService['toItem']>> {
|
||||||
|
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<ReturnType<PlansService['toItem']>> {
|
||||||
|
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<void> {
|
||||||
|
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<ReturnType<PlansService['toItem']>> {
|
||||||
|
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<ReturnType<PlansService['toItem']>> {
|
||||||
|
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<void> {
|
||||||
|
for (const id of ids) {
|
||||||
|
await this.salesInvoicesService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPackingSlips(ids: readonly string[]): Promise<void> {
|
||||||
|
for (const id of ids) {
|
||||||
|
await this.packingSlipsService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirePlan(id: string): Promise<Plan> {
|
||||||
|
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<void> {
|
||||||
|
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<FieldPurpose[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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<CompanySettingDto> {
|
||||||
|
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<CompanySettingDto> {
|
||||||
|
return this.companySettingsService.update(dto.cycleStartDate, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CompanySetting | null> {
|
||||||
|
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<CompanySetting> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CompanySettingsRepository, 'find' | 'upsert'>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReturnType<CompanySettingsService['toItem']>> {
|
||||||
|
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<ReturnType<CompanySettingsService['toItem']>> {
|
||||||
|
const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay();
|
||||||
|
const saved = await this.companySettingsRepository.upsert({
|
||||||
|
cycleStartDate,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return this.toItem(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireCycleStartDate(): Promise<DateTime> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<WeekdayName, WeekdayTemplateInput>>;
|
||||||
|
|
||||||
|
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<string, WeekdayTemplateInput>;
|
||||||
|
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')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<boolean> {
|
||||||
|
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<FieldPurpose[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<readonly number[]>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<readonly [number, number]> = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<App>;
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<App>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<App>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user