mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
|
||||
@@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
|
||||
# position types, organization types + default units, org/unit settings, super
|
||||
# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/iam-seed": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
@@ -153,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr_freight",
|
||||
unitKey: "edr_freight_app",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
BookingsModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
@@ -231,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly iamBaselineSeeder: IamBaselineSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
@@ -261,13 +274,22 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
|
||||
// position types, organization types +
|
||||
// default units, org/unit settings and the
|
||||
// super-admin account. Replaces the package's
|
||||
// DataSeeder, and is shared with
|
||||
// edr-passenger-api so one writer owns the
|
||||
// `iam` schema. Runs after edrOrgSeeder
|
||||
// because the super admin attaches to the
|
||||
// edr_freight org/unit.
|
||||
// Writes nothing unless SEED_IAM_BASELINE=true.
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
|
||||
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { generateGrnNumber, grnOwnerSlug } from './grn.util';
|
||||
|
||||
/**
|
||||
* The GRN is mapped to the goods owner for BOTH directions, so a note is
|
||||
* identifiable by who owns the cargo. The reference slice stays the uniqueness
|
||||
* anchor — one owner can have several bookings received the same day.
|
||||
*/
|
||||
const date = new Date('2026-07-27T09:15:00Z');
|
||||
const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
|
||||
|
||||
describe('GRN number', () => {
|
||||
it('maps an import GRN to the owner', () => {
|
||||
expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
|
||||
'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps an export GRN to the owner the same way', () => {
|
||||
expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
|
||||
'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
|
||||
expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
|
||||
expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
|
||||
});
|
||||
|
||||
it('stays unique per booking for one owner on one day', () => {
|
||||
const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
|
||||
const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('strips punctuation and caps the owner segment', () => {
|
||||
expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
|
||||
expect(grnOwnerSlug('a/b c')).toBe('ABC');
|
||||
expect(grnOwnerSlug(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,41 @@
|
||||
/**
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
|
||||
*
|
||||
* The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
|
||||
* both import and export, so a note is identifiable by who owns the cargo
|
||||
* without opening it. The trailing reference slice stays as the uniqueness
|
||||
* anchor — one owner can have several bookings received on the same day.
|
||||
* Owner-less receipts (manual walk-ins with no booking) fall back to the
|
||||
* original `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
|
||||
*
|
||||
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
|
||||
* raised in a warehouse — the two live in different tables
|
||||
* (facility_handling_events vs warehouse_inventory), and a second generator would
|
||||
* eventually let their formats drift apart.
|
||||
*/
|
||||
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
export function generateGrnNumber(
|
||||
direction: string,
|
||||
referenceId: string,
|
||||
date: Date,
|
||||
ownerName?: string | null,
|
||||
): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
const owner = grnOwnerSlug(ownerName);
|
||||
const base = `GRN-${direction.toUpperCase()}-${stamp}`;
|
||||
return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
|
||||
* long company name can't run away with the number. Null when there is nothing
|
||||
* usable, which drops the segment rather than emitting an empty `--`.
|
||||
*/
|
||||
export function grnOwnerSlug(ownerName?: string | null): string | null {
|
||||
const slug = (ownerName ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '')
|
||||
.toUpperCase()
|
||||
.slice(0, 12);
|
||||
return slug || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-truck detention clocks. Detention was timed once per last-mile leg
|
||||
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
|
||||
* delivery shared one window and was billed identical days — wrong the moment
|
||||
* two trucks arrive or return at different times.
|
||||
*
|
||||
* Deliberately NEW columns rather than reusing the existing per-truck
|
||||
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
|
||||
* events stamped by release(), whereas detention runs from arrival at the
|
||||
* DESTINATION until the truck is released/returned.
|
||||
*
|
||||
* Both nullable — a truck without its own window falls back to the leg-level
|
||||
* timestamps, so legacy legs keep billing exactly as before.
|
||||
*/
|
||||
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
|
||||
name = 'AddPerTruckDetentionWindow2860000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS returned_at,
|
||||
DROP COLUMN IF EXISTS destination_arrived_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
|
||||
* the right yard by cargo type (and, for container yards, trade direction):
|
||||
*
|
||||
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
|
||||
* meaningful for CONTAINER_YARD, where import and export stacks are
|
||||
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
|
||||
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
|
||||
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
|
||||
* CONTAINER_YARD structurally but must never be offered for ordinary
|
||||
* import/export cargo, so the frontend match requires an EXACT IMPORT/
|
||||
* EXPORT direction hit for container freight rather than treating BOTH as
|
||||
* a wildcard.
|
||||
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
|
||||
* the existing `cargo_type_wagon_types` join table). Empty = open to any
|
||||
* cargo type of the yard's structural type (additive, never restrictive
|
||||
* by default), so this cannot break a yard nobody has configured yet.
|
||||
*
|
||||
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
|
||||
* so Yards 1 and 9 have a real mapping ready for when they reopen.
|
||||
*/
|
||||
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
|
||||
name = "IndodeYardsAndCargoRouting2990000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_yards
|
||||
ADD COLUMN IF NOT EXISTS direction varchar(10)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
|
||||
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
|
||||
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (yard_id, cargo_type_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// New cargo types Indode's yard list names but the catalog didn't have yet.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
|
||||
VALUES
|
||||
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
|
||||
('COFFEE', 'Coffee', 'PER_TON', true),
|
||||
('TEA', 'Tea', 'PER_TON', true)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
// The 11 real yards at Indode Open Warehouse (code 'IOW').
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_yards
|
||||
(warehouse_id, name, code, type, direction, status, is_active)
|
||||
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
|
||||
FROM freight.warehouses w
|
||||
CROSS JOIN (VALUES
|
||||
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
|
||||
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
|
||||
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
|
||||
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
|
||||
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
|
||||
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
|
||||
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
|
||||
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
|
||||
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
|
||||
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
|
||||
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
|
||||
) AS y(code, name, type, direction, status)
|
||||
WHERE w.code = 'IOW'
|
||||
ON CONFLICT (warehouse_id, code) DO NOTHING
|
||||
`);
|
||||
|
||||
// One default zone per new yard, matching its yard's type — every existing
|
||||
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
|
||||
SELECT y.id, y.name || ' Zone 1', 'Z1',
|
||||
CASE y.type
|
||||
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
|
||||
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
|
||||
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
|
||||
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
|
||||
ELSE 'GENERAL_CARGO_ZONE'
|
||||
END,
|
||||
y.status, y.status = 'ACTIVE'
|
||||
FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id
|
||||
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
ON CONFLICT (yard_id, code) DO NOTHING
|
||||
`);
|
||||
|
||||
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
|
||||
// left with no rows — direction alone decides those, per the entity comment.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
|
||||
SELECT y.id, ct.id
|
||||
FROM freight.warehouses w
|
||||
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
|
||||
JOIN (VALUES
|
||||
('Y1', 'FERTILIZER'),
|
||||
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
|
||||
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
|
||||
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
|
||||
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
|
||||
('Y7', 'PERISHABLE'),
|
||||
('Y9', 'COFFEE'), ('Y9', 'TEA')
|
||||
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
|
||||
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
|
||||
WHERE w.code = 'IOW'
|
||||
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.warehouse_zones z
|
||||
USING freight.warehouse_yards y, freight.warehouses w
|
||||
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
|
||||
AND w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.warehouse_yards y
|
||||
USING freight.warehouses w
|
||||
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
`);
|
||||
// Cargo types and the join table are left in place — other data may have
|
||||
// started referencing them since; dropping columns/tables is not reversible
|
||||
// once real rows exist, and leaving them is harmless.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||
|
||||
/**
|
||||
* One truck's detention window. Each truck reaches the destination and is
|
||||
* released at its own time, so detention days differ between trucks on the
|
||||
* same delivery. Null clears the value (falls back to the leg-level pair).
|
||||
*/
|
||||
export class TruckDetentionTimeInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/** Detention clock start — this truck reached the destination. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
destinationArrivedAt?: string | null;
|
||||
|
||||
/** Detention clock end — this truck was released/returned. Omit = still out. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
returnedAt?: string | null;
|
||||
}
|
||||
|
||||
export class SetDetentionTimesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TruckDetentionTimeInput)
|
||||
trucks!: TruckDetentionTimeInput[];
|
||||
}
|
||||
@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock START for THIS truck: reached the delivery destination.
|
||||
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
|
||||
* leg-level `last_mile.arrived_at`.
|
||||
*/
|
||||
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
|
||||
destinationArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock END for THIS truck: released / returned by the customer.
|
||||
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
|
||||
*/
|
||||
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
|
||||
returnedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
|
||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
@@ -131,6 +132,18 @@ export class LastMileController {
|
||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||
}
|
||||
|
||||
@Post(':id/detention-times')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@ApiOperation({
|
||||
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
|
||||
})
|
||||
async setDetentionTimes(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetDetentionTimesDto,
|
||||
) {
|
||||
return this.lastMileService.setDetentionTimes(id, dto.trucks);
|
||||
}
|
||||
|
||||
@Post(':id/proof-of-delivery')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -869,6 +869,46 @@ export class LastMileService {
|
||||
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||
*/
|
||||
/**
|
||||
* Per-truck detention windows. Each truck reaches the destination and is
|
||||
* released at its own time, so every truck gets its own clock (and therefore
|
||||
* its own chargeable days). Locked once the detention invoice exists.
|
||||
*/
|
||||
async setDetentionTimes(
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
await this.findById(id);
|
||||
|
||||
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||
if (invoices.length) {
|
||||
throw new BadRequestException(
|
||||
'Detention times cannot be changed after the invoice is generated',
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of trucks) {
|
||||
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
|
||||
const end = t.returnedAt ? new Date(t.returnedAt) : null;
|
||||
if (start && end && end.getTime() < start.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'A truck cannot be returned before it arrived — check the detention times',
|
||||
);
|
||||
}
|
||||
await this.dataSource.manager.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: t.vehicleId },
|
||||
{ destinationArrivedAt: start, returnedAt: end },
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async setDistances(
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
|
||||
@@ -48,10 +48,12 @@ export class FacilityHandlingService {
|
||||
if (!facility?.hasFacility) return null;
|
||||
|
||||
const occurredAt = input.occurredAt ?? new Date();
|
||||
// Mapped to the goods owner, same as every warehouse-raised GRN.
|
||||
const grnNumber = generateGrnNumber(
|
||||
booking.tradeDirection ?? 'DOMESTIC',
|
||||
booking.id,
|
||||
occurredAt,
|
||||
booking.company?.name ?? null,
|
||||
);
|
||||
|
||||
// Link the storage record when this facility keeps cargo — that link is
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
|
||||
import {
|
||||
WAREHOUSE_YARD_DIRECTIONS,
|
||||
WAREHOUSE_YARD_TYPES,
|
||||
WarehouseYardDirection,
|
||||
WarehouseYardType,
|
||||
} from '../entities/warehouse-yard.entity';
|
||||
|
||||
export class CreateWarehouseYardDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@@ -46,4 +51,22 @@ export class CreateWarehouseYardDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WAREHOUSE_YARD_DIRECTIONS,
|
||||
description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_YARD_DIRECTIONS)
|
||||
direction?: WarehouseYardDirection;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
cargoTypeIds?: string[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
@@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
|
||||
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Which trade direction this yard serves. Only meaningful for CONTAINER_YARD,
|
||||
* where import and export stacks are physically separate areas (e.g. Indode's
|
||||
* Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo
|
||||
* either way, so BOTH/null is the right default there.
|
||||
*/
|
||||
export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_yards' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['type'])
|
||||
@@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity {
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: WarehouseYardDirection | null;
|
||||
|
||||
/**
|
||||
* Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck,
|
||||
* Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any
|
||||
* cargo type of the yard's structural `type` (the pre-existing behavior),
|
||||
* so this is additive and never blocks a yard that hasn't been configured.
|
||||
*/
|
||||
@ManyToMany(() => CargoType)
|
||||
@JoinTable({
|
||||
name: 'warehouse_yard_cargo_types',
|
||||
schema: 'freight',
|
||||
joinColumn: { name: 'yard_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
|
||||
})
|
||||
cargoTypes?: CargoType[];
|
||||
|
||||
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
/**
|
||||
* Detention is per truck: two trucks on the same delivery with different
|
||||
* windows must produce different chargeable days and amounts (the old
|
||||
* leg-level clock billed them identically).
|
||||
*/
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const svc = Object.create(WarehouseFeeService.prototype) as {
|
||||
computeTruckDetention: (
|
||||
rule: Record<string, unknown> | null,
|
||||
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
|
||||
normalizeCurrency: (c?: string | null) => string;
|
||||
convertAmount: (a: number, from: string, to: string) => Promise<number>;
|
||||
calculateTieredAmount: unknown;
|
||||
};
|
||||
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
|
||||
svc.convertAmount = async (a) => a;
|
||||
|
||||
// 3h grace, 50/truck/day, no tiers.
|
||||
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
|
||||
const now = new Date('2026-07-25T12:00:00Z');
|
||||
|
||||
describe('per-truck detention', () => {
|
||||
it('bills each truck on its own window', async () => {
|
||||
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
|
||||
const a = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{
|
||||
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
|
||||
deliveredAt: now,
|
||||
truckCount: 1,
|
||||
},
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
const b = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{
|
||||
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
|
||||
deliveredAt: now,
|
||||
truckCount: 1,
|
||||
},
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
|
||||
expect(a.chargeableDays).toBe(2);
|
||||
expect(b.chargeableDays).toBe(4);
|
||||
expect(a.amount).toBe(100);
|
||||
expect(b.amount).toBe(200);
|
||||
// The whole point: same delivery, different bills.
|
||||
expect(a.amount).not.toBe(b.amount);
|
||||
});
|
||||
|
||||
it('charges nothing inside the grace window', async () => {
|
||||
const out = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(out.chargeableDays).toBe(0);
|
||||
expect(out.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps accruing against now when a truck has not returned', async () => {
|
||||
const out = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(out.endIsOpen).toBe(true);
|
||||
expect(out.chargeableDays).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ export interface ImportTrainRow {
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */
|
||||
destinationStationId: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
@@ -38,6 +40,8 @@ export interface ImportTrainItemRow {
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
|
||||
cargoTypeCode: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
@@ -205,6 +209,7 @@ export class SchedulingReadFacade {
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.id AS "destinationStationId",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
@@ -280,6 +285,7 @@ export class SchedulingReadFacade {
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
COALESCE(inv.status, b.status) AS "currentStatus",
|
||||
@@ -376,6 +382,7 @@ export class SchedulingReadFacade {
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.id AS "destinationStationId",
|
||||
dy.label AS "destinationName",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
|
||||
@@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => {
|
||||
inventoryWeight: 25,
|
||||
bookingContainerCount: 0,
|
||||
cargoUnitOfMeasure: null,
|
||||
// Double handling now bills only when staff answered Yes after unloading;
|
||||
// these quantity-basis cases assume that answer (the gate itself is covered
|
||||
// in double-handling-gate.spec.ts).
|
||||
doubleHandling: true,
|
||||
facilityId: null,
|
||||
warehouseId: null,
|
||||
yardId: null,
|
||||
|
||||
@@ -92,10 +92,20 @@ export interface FeePreview {
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
}>;
|
||||
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
|
||||
/**
|
||||
* Truck detention: one row PER TRUCK — each truck has its own detention
|
||||
* window (it arrives and is released at its own time) and its own matching
|
||||
* rule by truck type, so days and amount differ between trucks.
|
||||
*/
|
||||
groups?: Array<{
|
||||
assignmentId: string | null;
|
||||
vehicleId: string | null;
|
||||
plateNumber: string | null;
|
||||
vehicleType: string | null;
|
||||
truckCount: number;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
endIsOpen: boolean;
|
||||
chargeableDays: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
@@ -827,25 +837,48 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
// Group the leg's vehicles by CANONICAL truck type so each type is billed
|
||||
// by its own matching rule (rates differ by truck type). The FK to
|
||||
// truck_types is the source of truth — renaming a type's label no longer
|
||||
// silently unmatches its rule; the normalized legacy vehicle_type code is
|
||||
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
|
||||
// instead of dropping them). Falls back to one untyped group.
|
||||
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||||
count(*)::int AS "truckCount"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN freight.truck_types t
|
||||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
||||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||||
GROUP BY 1`,
|
||||
[lastMileId],
|
||||
);
|
||||
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
|
||||
// One row PER TRUCK: each truck has its own detention window (it reaches the
|
||||
// destination and is released at its own time) and resolves its own rule by
|
||||
// CANONICAL truck type — the truck_types FK is the source of truth, with the
|
||||
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
|
||||
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
|
||||
// legs recorded before per-truck tracking.
|
||||
const truckRows: Array<{
|
||||
assignmentId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string | null;
|
||||
vehicleType: string | null;
|
||||
startAt: Date | string | null;
|
||||
endAt: Date | string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT va.id AS "assignmentId",
|
||||
va.vehicle_id AS "vehicleId",
|
||||
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||||
COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt",
|
||||
COALESCE(va.returned_at, $3::timestamptz) AS "endAt"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN freight.truck_types t
|
||||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
||||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||||
ORDER BY va.created_at ASC`,
|
||||
[lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null],
|
||||
);
|
||||
// No trucks assigned yet: keep the leg-level single-truck estimate so the
|
||||
// preview still tells the operator what detention would cost.
|
||||
const trucks = truckRows.length
|
||||
? truckRows
|
||||
: [
|
||||
{
|
||||
assignmentId: null as string | null,
|
||||
vehicleId: null as string | null,
|
||||
plateNumber: null as string | null,
|
||||
vehicleType: null as string | null,
|
||||
startAt: leg.arrivedAt ?? null,
|
||||
endAt: leg.deliveredAt ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
|
||||
@@ -853,7 +886,7 @@ export class WarehouseFeeService {
|
||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||
|
||||
const computed = await Promise.all(
|
||||
groups.map(async (g) => {
|
||||
trucks.map(async (t) => {
|
||||
const item: ItemAttributes = {
|
||||
arrivedAt: null,
|
||||
gateClearedAt: null,
|
||||
@@ -862,7 +895,7 @@ export class WarehouseFeeService {
|
||||
tradeDirection: leg.tradeDirection ?? null,
|
||||
cargoTypeCode: null,
|
||||
containerTypeCode: null,
|
||||
vehicleType: g.vehicleType ?? null,
|
||||
vehicleType: t.vehicleType ?? null,
|
||||
inventoryQuantity: 1,
|
||||
inventoryWeight: 0,
|
||||
bookingContainerCount: 1,
|
||||
@@ -875,37 +908,47 @@ export class WarehouseFeeService {
|
||||
zoneId: null,
|
||||
};
|
||||
const rule = this.bestRule(detentionRules, item);
|
||||
// truckCount 1 — this row IS one truck.
|
||||
const c = await this.computeTruckDetention(
|
||||
rule,
|
||||
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
|
||||
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
|
||||
now,
|
||||
billingCurrency,
|
||||
);
|
||||
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
|
||||
return { ...t, c };
|
||||
}),
|
||||
);
|
||||
|
||||
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
|
||||
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
|
||||
const totalTrucks = computed.length;
|
||||
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
|
||||
const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
|
||||
// Header days: the worst truck — a single number can't represent per-truck
|
||||
// windows, and the longest detention is the one operations must act on.
|
||||
const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0);
|
||||
const single = computed.length === 1 ? computed[0].c : null;
|
||||
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
|
||||
const earliestStart = computed
|
||||
.map((x) => (x.startAt ? new Date(x.startAt).getTime() : null))
|
||||
.filter((n): n is number => n != null)
|
||||
.sort((a, b) => a - b)[0];
|
||||
const anyOpen = computed.some((x) => x.c.endIsOpen);
|
||||
|
||||
return {
|
||||
ruleType: 'TRUCK_DETENTION_FEE',
|
||||
basis: null,
|
||||
unitLabel: 'truck',
|
||||
ruleId: single?.ruleId ?? null,
|
||||
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
|
||||
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName,
|
||||
freeDays: 0,
|
||||
ratePerDay: single?.ratePerDay ?? 0,
|
||||
currency: targetCurrency,
|
||||
ruleCurrency: single?.ruleCurrency ?? null,
|
||||
billingCurrency: targetCurrency,
|
||||
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
|
||||
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
|
||||
endIsOpen: !leg.deliveredAt,
|
||||
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
|
||||
endDate: (anyOpen ? now : new Date(Math.max(
|
||||
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
|
||||
))).toISOString(),
|
||||
endIsOpen: anyOpen,
|
||||
elapsedDays: chargeableDays,
|
||||
chargeableDays,
|
||||
containerCount: totalTrucks,
|
||||
@@ -913,8 +956,14 @@ export class WarehouseFeeService {
|
||||
amount: totalAmount,
|
||||
tiers: single ? single.tiers : [],
|
||||
groups: computed.map((x) => ({
|
||||
assignmentId: x.assignmentId,
|
||||
vehicleId: x.vehicleId,
|
||||
plateNumber: x.plateNumber,
|
||||
vehicleType: x.vehicleType,
|
||||
truckCount: x.truckCount,
|
||||
truckCount: 1,
|
||||
startDate: x.startAt ? new Date(x.startAt).toISOString() : null,
|
||||
endDate: x.c.endDate,
|
||||
endIsOpen: x.c.endIsOpen,
|
||||
chargeableDays: x.c.chargeableDays,
|
||||
ratePerDay: x.c.ratePerDay,
|
||||
amount: x.c.amount,
|
||||
|
||||
@@ -1141,6 +1141,8 @@ export class WarehouseInventoryService {
|
||||
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||||
const arrived: {
|
||||
id: string;
|
||||
/** Goods owner (company) — the GRN number is mapped to it. */
|
||||
customer: string | null;
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -1158,10 +1160,12 @@ export class WarehouseInventoryService {
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
|
||||
[this.ARRIVED_BOOKING_STATUSES],
|
||||
);
|
||||
@@ -1198,7 +1202,7 @@ export class WarehouseInventoryService {
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
...(booking.tradeDirection === 'EXPORT'
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) }
|
||||
: {}),
|
||||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||
});
|
||||
@@ -1223,12 +1227,19 @@ export class WarehouseInventoryService {
|
||||
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||||
// gets one on unload — import as well as export. The direction only decides
|
||||
// the GRN prefix, not whether one is issued.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
// The GRN is mapped to the goods owner (the booking's company), so pull it
|
||||
// alongside the direction rather than issuing an owner-less number.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.trade_direction AS "tradeDirection",
|
||||
company.name AS "ownerName"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||||
const ownerName = bookingRow?.ownerName ?? null;
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
@@ -1252,7 +1263,7 @@ export class WarehouseInventoryService {
|
||||
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||||
...(existing[0].grnNumber
|
||||
? {}
|
||||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
|
||||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }),
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
@@ -1267,7 +1278,7 @@ export class WarehouseInventoryService {
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
|
||||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName),
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
@@ -1577,7 +1588,7 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
||||
const truckEntrance = dto.truckEntrance
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
||||
: undefined;
|
||||
@@ -2143,6 +2154,8 @@ export class WarehouseInventoryService {
|
||||
const bookings: {
|
||||
id: string;
|
||||
status: string;
|
||||
/** Goods owner (company) — the GRN number is mapped to it. */
|
||||
customer: string | null;
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -2165,10 +2178,12 @@ export class WarehouseInventoryService {
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2`,
|
||||
[scheduleId, schedule.destinationStationId],
|
||||
@@ -2235,7 +2250,7 @@ export class WarehouseInventoryService {
|
||||
unloadedAt: now,
|
||||
arrivedAt: existing.arrivedAt ?? now,
|
||||
// Import GRN is issued automatically at train unload.
|
||||
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
|
||||
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }),
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
@@ -2285,7 +2300,7 @@ export class WarehouseInventoryService {
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'UNLOADED',
|
||||
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
|
||||
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer),
|
||||
arrivedAt: now,
|
||||
unloadedAt: now,
|
||||
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
|
||||
@@ -2719,7 +2734,12 @@ export class WarehouseInventoryService {
|
||||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
|
||||
const grnNumber = this.generateGrnNumber(
|
||||
bookingDirection ?? 'WH',
|
||||
dto.bookingId ?? 'MANUAL',
|
||||
now,
|
||||
truckEntrance?.ownerName ?? bookingSource?.customer,
|
||||
);
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes: dto.notes?.trim() || 'Single booking received',
|
||||
@@ -5350,7 +5370,9 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
const rows: Array<[string, unknown]> = [
|
||||
['Booking Reference', data.bookingReference],
|
||||
['Customer / Consignee', data.customerName],
|
||||
// The GRN is mapped to the owner (import: consignee, export: shipper) —
|
||||
// named explicitly so the note reads the same for both directions.
|
||||
["Owner's Name", data.customerName],
|
||||
['Customer TIN', data.customerTin],
|
||||
['Booking Status', data.bookingStatus],
|
||||
['Service Type', data.serviceType],
|
||||
@@ -5981,8 +6003,13 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
/** Shared with the facility handling flow — see common/grn.util.ts. */
|
||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
return generateGrnNumber(direction, referenceId, date);
|
||||
private generateGrnNumber(
|
||||
direction: string,
|
||||
referenceId: string,
|
||||
date: Date,
|
||||
ownerName?: string | null,
|
||||
): string {
|
||||
return generateGrnNumber(direction, referenceId, date, ownerName);
|
||||
}
|
||||
|
||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DeepPartial, Repository } from 'typeorm';
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
|
||||
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */
|
||||
async update(id: string, data: DeepPartial<WarehouseYard>): Promise<WarehouseYard | null> {
|
||||
const { cargoTypes, ...columns } = data;
|
||||
if (Object.keys(columns).length) {
|
||||
await this.repository.update(id, columns as never);
|
||||
}
|
||||
if (cargoTypes) {
|
||||
const entity = await this.repository.findOne({ where: { id } as never });
|
||||
if (entity) {
|
||||
entity.cargoTypes = cargoTypes as CargoType[];
|
||||
await this.repository.save(entity);
|
||||
}
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
@@ -15,7 +16,7 @@ export class WarehouseYardsService {
|
||||
|
||||
findAll(): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
relations: { warehouse: true, zones: true },
|
||||
relations: { warehouse: true, zones: true, cargoTypes: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -23,14 +24,14 @@ export class WarehouseYardsService {
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
relations: { zones: true },
|
||||
relations: { zones: true, cargoTypes: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseYard> {
|
||||
const yard = await this.yardsRepository.findById(id, {
|
||||
relations: { warehouse: true, zones: true },
|
||||
relations: { warehouse: true, zones: true, cargoTypes: true },
|
||||
});
|
||||
|
||||
if (!yard) {
|
||||
@@ -51,6 +52,7 @@ export class WarehouseYardsService {
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
direction: dto.direction ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
@@ -60,6 +62,8 @@ export class WarehouseYardsService {
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,12 +88,16 @@ export class WarehouseYardsService {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
direction: dto.direction ?? existing.direction,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
...(dto.cargoTypeIds
|
||||
? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
|
||||
@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
|
||||
);
|
||||
}
|
||||
|
||||
type TruckRow = {
|
||||
vehicleId: string;
|
||||
label: string;
|
||||
arrived: Date | null;
|
||||
returned: Date | null;
|
||||
};
|
||||
|
||||
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
|
||||
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
|
||||
|
||||
/**
|
||||
* View/override the detention clock (arrival + delivery/return) for a last-mile
|
||||
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
|
||||
* Detention is PER TRUCK: every truck reaches the destination and is released at
|
||||
* its own time, so each row carries its own clock, days and amount. Legs with no
|
||||
* trucks assigned fall back to the single leg-level window.
|
||||
*/
|
||||
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const id = record?.id ?? null;
|
||||
const assignments = record?.vehicleAssignments ?? [];
|
||||
const perTruck = assignments.length > 0;
|
||||
|
||||
const [rows, setRows] = useState<TruckRow[]>([]);
|
||||
// Leg-level fallback (no trucks assigned yet).
|
||||
const [arrived, setArrived] = useState<Date | null>(null);
|
||||
const [delivered, setDelivered] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(
|
||||
assignments.map((a) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
label: plateOf(a),
|
||||
// Fall back to the leg-level pair so a truck without its own window
|
||||
// shows what it is actually being billed on today.
|
||||
arrived: a.destinationArrivedAt
|
||||
? new Date(a.destinationArrivedAt)
|
||||
: record?.arrivedAt
|
||||
? new Date(record.arrivedAt)
|
||||
: null,
|
||||
returned: a.returnedAt
|
||||
? new Date(a.returnedAt)
|
||||
: record?.deliveredAt
|
||||
? new Date(record.deliveredAt)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
|
||||
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['truck-detention-preview', id],
|
||||
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
enabled: opened && Boolean(id),
|
||||
});
|
||||
const preview = previewQuery.data;
|
||||
// With several trucks the header rule is null by design (each truck resolves
|
||||
// its own) — only warn when NO truck matched a rule.
|
||||
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
|
||||
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
|
||||
|
||||
const saveTimes = useMutation({
|
||||
mutationFn: () =>
|
||||
lastMileService.update(id as string, {
|
||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||
}),
|
||||
perTruck
|
||||
? lastMileService.setDetentionTimes(
|
||||
id as string,
|
||||
rows.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
|
||||
returnedAt: r.returned ? r.returned.toISOString() : null,
|
||||
})),
|
||||
)
|
||||
: lastMileService.update(id as string, {
|
||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void previewQuery.refetch();
|
||||
toast({ title: 'Detention times saved' });
|
||||
},
|
||||
onError: () => toast({ title: 'Save failed', variant: 'destructive' }),
|
||||
onError: (e: unknown) => {
|
||||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast({ title: 'Save failed', description, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const values = perTruck
|
||||
? rows.flatMap((r) => [r.arrived, r.returned])
|
||||
: [arrived, delivered];
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (values.some((v) => isBackdated(v))) {
|
||||
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
const reversed = perTruck
|
||||
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
|
||||
: arrived && delivered && delivered < arrived
|
||||
? { label: 'this delivery' }
|
||||
: undefined;
|
||||
if (reversed) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Return time is before arrival',
|
||||
description: `Check the times for ${reversed.label}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
};
|
||||
|
||||
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
|
||||
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
size="xl"
|
||||
title={
|
||||
<Text fw={700}>
|
||||
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
||||
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at"
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
{perTruck ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
Each truck has its own detention clock — record when it reached the destination and
|
||||
when it was released. Days and charges are calculated per truck.
|
||||
</Text>
|
||||
{rows.map((r) => {
|
||||
const g = byVehicle.get(r.vehicleId);
|
||||
return (
|
||||
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={600}>
|
||||
{r.label}
|
||||
</Text>
|
||||
{g?.vehicleType && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{g.vehicleType}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{g && (
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
|
||||
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
|
||||
{g.endIsOpen ? ' · still out' : ''}
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{money(g.amount, preview?.currency ?? 'USD')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at destination"
|
||||
description="Detention clock start"
|
||||
value={r.arrived}
|
||||
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Released / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={r.returned}
|
||||
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
{g && !g.ruleId && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
No detention rule matches this truck type — it will not be billed.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
No trucks assigned yet — this records the delivery-level detention window. Assign
|
||||
trucks to track each one separately.
|
||||
</Text>
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at"
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
<Alert color="gray" variant="light">
|
||||
No preview available.
|
||||
</Alert>
|
||||
) : !preview.ruleId ? (
|
||||
) : !hasAnyRule ? (
|
||||
<Alert color="orange" variant="light">
|
||||
No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules
|
||||
(rule type "Truck Detention Cost").
|
||||
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<Stat label="Chargeable days" value={preview.chargeableDays} />
|
||||
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
|
||||
<Stat label="Trucks" value={preview.containerCount} />
|
||||
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong />
|
||||
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
|
||||
</Group>
|
||||
{preview.endIsOpen && (
|
||||
<Text size="xs" c="orange">
|
||||
Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned.
|
||||
Still accruing — at least one truck has no release time yet. The amount grows until
|
||||
every truck is returned.
|
||||
</Text>
|
||||
)}
|
||||
{preview.groups && preview.groups.length > 1 ? (
|
||||
{preview.groups && preview.groups.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>Trucks</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Days</Table.Th>
|
||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
||||
<Table.Th ta="right">Rate / day</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{preview.groups.map((g, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Tr key={g.assignmentId ?? i}>
|
||||
<Table.Td>
|
||||
{g.vehicleType ?? 'Unknown'}
|
||||
{g.plateNumber ?? 'Unassigned'}
|
||||
{!g.ruleId && (
|
||||
<Text span size="xs" c="red">
|
||||
{' '}· no rule
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{g.truckCount}</Table.Td>
|
||||
<Table.Td>{g.chargeableDays}</Table.Td>
|
||||
<Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
|
||||
<Table.Td>
|
||||
{g.chargeableDays}
|
||||
{g.endIsOpen && (
|
||||
<Text span size="xs" c="orange">
|
||||
{' '}· open
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
@@ -1978,14 +1978,6 @@ function LoadedExportTab({
|
||||
);
|
||||
}
|
||||
|
||||
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isImportUnloadPending);
|
||||
@@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => {
|
||||
const draft = assignments[it.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: it.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: it.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isImportUnloadPending(it);
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { warehousesAtStation, yardsForBooking } from "./options";
|
||||
import type { Warehouse, WarehouseYard } from "@/types/warehouse";
|
||||
|
||||
// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read
|
||||
// against the actual booking-routing decisions staff rely on.
|
||||
const yard = (overrides: Partial<WarehouseYard>): WarehouseYard =>
|
||||
({
|
||||
id: overrides.code,
|
||||
warehouseId: "indode",
|
||||
name: overrides.code,
|
||||
code: overrides.code,
|
||||
type: "GENERAL_CARGO_YARD",
|
||||
capacityWeight: null,
|
||||
capacityContainers: null,
|
||||
maxWeight: null,
|
||||
maxVolume: null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: "ACTIVE",
|
||||
isActive: true,
|
||||
...overrides,
|
||||
}) as WarehouseYard;
|
||||
|
||||
const YARDS: WarehouseYard[] = [
|
||||
yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }),
|
||||
yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }),
|
||||
yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }),
|
||||
yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }),
|
||||
yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }),
|
||||
yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard
|
||||
yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard
|
||||
];
|
||||
|
||||
describe("yardsForBooking", () => {
|
||||
it("container import narrows to exactly the import stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y5"]);
|
||||
});
|
||||
|
||||
it("container export narrows to exactly the export stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "EXPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y6"]);
|
||||
});
|
||||
|
||||
it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("Y10");
|
||||
expect(result.map((y) => y.code)).not.toContain("Y11");
|
||||
});
|
||||
|
||||
it("bulk cargo narrows to the yard configured for that exact cargo type", () => {
|
||||
const automobile = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "AUTOMOBILE",
|
||||
});
|
||||
expect(automobile.map((y) => y.code)).toEqual(["Y3"]);
|
||||
|
||||
const steel = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(steel.map((y) => y.code)).toEqual(["Y2"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when the one configured for this cargo type is closed", () => {
|
||||
// Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty
|
||||
// picker just because the ideal yard is closed; same safety net as
|
||||
// warehousesAtStation falling back when a station has no mapped warehouse.
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "WHEAT",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "SOMETHING_UNMAPPED",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => {
|
||||
const openYard = yard({ code: "GENERIC", type: "BULK_YARD" });
|
||||
const result = yardsForBooking([...YARDS, openYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]);
|
||||
});
|
||||
|
||||
it("only offers yards at the requested warehouse", () => {
|
||||
const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" });
|
||||
const result = yardsForBooking([...YARDS, otherWarehouseYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warehousesAtStation", () => {
|
||||
const warehouse = (id: string, stationId: string | null): Warehouse =>
|
||||
({ id, stationId, name: id, code: id } as Warehouse);
|
||||
|
||||
it("restricts to the warehouse at the given station", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-a");
|
||||
expect(result.map((w) => w.id)).toEqual(["indode"]);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station has no match", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-unknown");
|
||||
expect(result).toEqual(warehouses);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station is null", () => {
|
||||
const warehouses = [warehouse("indode", "station-a")];
|
||||
expect(warehousesAtStation(warehouses, null)).toEqual(warehouses);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
WAREHOUSE_STATUSES,
|
||||
INVENTORY_STATUSES,
|
||||
type Warehouse,
|
||||
type WarehouseYard,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export const humanizeEnum = (value: string) =>
|
||||
@@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) =>
|
||||
const toOptions = (values: readonly string[]) =>
|
||||
values.map((value) => ({ value, label: humanizeEnum(value) }));
|
||||
|
||||
/**
|
||||
* Warehouses actually located at a train's station — e.g. a train destined for
|
||||
* Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's.
|
||||
* Falls back to every warehouse when the station is unmapped (no `stationId`
|
||||
* match anywhere), so unusual/legacy data never blocks the unload flow entirely.
|
||||
*/
|
||||
export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => {
|
||||
if (!stationId) return warehouses;
|
||||
const atStation = warehouses.filter((w) => w.stationId === stationId);
|
||||
return atStation.length ? atStation : warehouses;
|
||||
};
|
||||
|
||||
/**
|
||||
* Yards at ONE warehouse eligible to receive a booking, given what it actually
|
||||
* is — e.g. at Indode: container import always narrows to Yard 5, export to
|
||||
* Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or
|
||||
* Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an
|
||||
* unconfigured yard (no cargo types set) stays open rather than disappearing,
|
||||
* but a yard that IS configured for other cargo never shows for a mismatch.
|
||||
*
|
||||
* Container yards are the one case with no such fallback: a CONTAINER_YARD
|
||||
* left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11
|
||||
* equipment yard) is a service/equipment yard, not a customer cargo yard, and
|
||||
* must never be offered just because the exact-direction stack is missing.
|
||||
*/
|
||||
export const yardsForBooking = (
|
||||
yards: WarehouseYard[],
|
||||
params: {
|
||||
warehouseId: string | null | undefined;
|
||||
freightType: string | null | undefined;
|
||||
tradeDirection: string | null | undefined;
|
||||
cargoTypeCode: string | null | undefined;
|
||||
},
|
||||
): WarehouseYard[] => {
|
||||
const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive);
|
||||
const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
if (isContainer) {
|
||||
const direction = (params.tradeDirection ?? '').toUpperCase();
|
||||
return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction);
|
||||
}
|
||||
|
||||
const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD');
|
||||
if (!params.cargoTypeCode) return nonContainer;
|
||||
|
||||
const cargoMatched = nonContainer.filter((y) => {
|
||||
const codes = (y.cargoTypes ?? []).map((c) => c.code);
|
||||
return codes.length === 0 || codes.includes(params.cargoTypeCode as string);
|
||||
});
|
||||
return cargoMatched.length ? cargoMatched : nonContainer;
|
||||
};
|
||||
|
||||
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
warehousesAtStation,
|
||||
yardsForBooking,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
@@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
const locationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) {
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
train,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
@@ -73,7 +67,7 @@ function ImportTrainDetailRows({
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
train: ImportTrain;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
@@ -81,11 +75,61 @@ function ImportTrainDetailRows({
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
@@ -135,12 +179,17 @@ function ImportTrainDetailRows({
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isUnloadPending(item);
|
||||
|
||||
@@ -395,7 +444,7 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
train={train}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface LastMileRecord {
|
||||
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
/** This truck's own detention window (destination arrival → released). */
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
grossWeightTons?: number | null;
|
||||
netWeightTons?: number | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
@@ -143,4 +146,13 @@ export const lastMileService = {
|
||||
/** Preview the truck-detention charge for a last-mile leg. */
|
||||
truckDetentionPreview: (id: string) =>
|
||||
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
|
||||
/** Per-truck detention windows — each truck has its own clock. */
|
||||
setDetentionTimes: (
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
|
||||
};
|
||||
|
||||
@@ -118,12 +118,19 @@ export interface WarehouseZone {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */
|
||||
export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||
|
||||
export interface WarehouseYard {
|
||||
id: string;
|
||||
warehouseId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseYardType;
|
||||
/** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */
|
||||
direction?: WarehouseYardDirection | null;
|
||||
/** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */
|
||||
cargoTypes?: Array<{ id: string; code: string }>;
|
||||
capacityWeight: number | null;
|
||||
capacityContainers: number | null;
|
||||
maxWeight: number | null;
|
||||
@@ -518,6 +525,8 @@ export interface ImportTrain {
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */
|
||||
destinationStationId: string | null;
|
||||
departureTime?: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
@@ -632,6 +641,8 @@ export interface ImportTrainItem {
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
|
||||
cargoTypeCode: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
@@ -856,10 +867,16 @@ export interface FeePreview {
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
/** Truck detention: per-vehicle-type breakdown. */
|
||||
/** Truck detention: one row per truck — each has its own window and rule. */
|
||||
groups?: Array<{
|
||||
assignmentId?: string | null;
|
||||
vehicleId?: string | null;
|
||||
plateNumber?: string | null;
|
||||
vehicleType: string | null;
|
||||
truckCount: number;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
endIsOpen?: boolean;
|
||||
chargeableDays: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
|
||||
@@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
# Login endpoint for backoffice users: POST /v1/auth/login
|
||||
SEED_EDR_PASSENGER_ORG=false
|
||||
SEED_PASSENGER_STAFF=false
|
||||
# IAM baseline shared with edr-freight-api (roles, IAM app + permissions, position
|
||||
# types, organization types + default units, org/unit settings, super admin).
|
||||
# Replaces the seeder that used to ship inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
# Super-admin account seeded by the above. Shared across the apps on this schema.
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
# Falls back to DEFAULT_PASSWORD when empty.
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||
# Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset.
|
||||
DEFAULT_PASSWORD=Admin@1234
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/iam-seed": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
import { DataSeeder } from "@tria-plc/iamapi-common/db/seed/seeder";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
import {
|
||||
EDR_PASSENGER_APPLICATION,
|
||||
@@ -103,6 +103,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
`Set your EDR Passenger password using this link: ${route}`,
|
||||
},
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
@@ -151,18 +162,23 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AppModule.name);
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly iamBaselineSeeder: IamBaselineSeeder,
|
||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
||||
private readonly segmentFareSeeder: SegmentFareSeeder,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
// Runs first so the roles it seeds exist before EdrPassengerOrgSeeder links
|
||||
// super_admin permissions. Its own super-admin account attaches to the `edr`
|
||||
// organization, which that seeder creates — so on a brand-new database the
|
||||
// account lands on the next boot; it logs a warning and skips until then.
|
||||
// Non-fatal internally, but the wrapper stays for symmetry with the rest.
|
||||
try {
|
||||
await this.seeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[DataSeeder] Seed failed (non-fatal):",
|
||||
"[IamBaselineSeeder] Seed failed (non-fatal):",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* Resolves the booking/check-in cutoff for one boarding stop.
|
||||
* Resolves the booking/check-in cutoff for one boarding stop: stop-level
|
||||
* `RouteStop.checkinMinutesBefore` override wins, else the route-level default
|
||||
* (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with
|
||||
* neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching
|
||||
* that stop), not its departure or the schedule's overall origin departure — a downstream
|
||||
* stop's cutoff must be independent of how long ago the train left its origin. The first stop
|
||||
* of a route has no arrival (nothing to arrive at), so it falls back to its own departure.
|
||||
*
|
||||
* Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30.
|
||||
*
|
||||
* Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt.
|
||||
* - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival).
|
||||
* - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore).
|
||||
* cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the
|
||||
* moment the train reaches the stop — independent of how long ago it left the origin.
|
||||
*
|
||||
* Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both
|
||||
* apply it; GuestBookingService.createGuestBooking also applies it per boarding stop.
|
||||
* Single source of truth for this computation — SeatsService.holdSeats and
|
||||
* SearchService.buildScheduleResult already applied it (search results only ever showed a
|
||||
* segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking
|
||||
* used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin
|
||||
* departure, which could reject a booking the search/hold steps had just accepted under the
|
||||
* route's actual configured cutoff.
|
||||
*/
|
||||
export interface CheckinCutoff {
|
||||
/** The stop's planned departure time (or arrival / schedule departure as fallback). */
|
||||
/** The stop's own estimated arrival time (or departure, for the first stop / missing data). */
|
||||
segmentTime: Date;
|
||||
/** Minutes before segmentTime that booking/holding closes. */
|
||||
checkinMinutes: number;
|
||||
@@ -32,7 +34,7 @@ export function resolveCheckinCutoff(
|
||||
stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined,
|
||||
stationId: string | null | undefined,
|
||||
): CheckinCutoff {
|
||||
const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||
const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
|
||||
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
return {
|
||||
|
||||
@@ -369,6 +369,24 @@ export class BookingsController {
|
||||
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
||||
}
|
||||
|
||||
@Delete("reservations/:seatId")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
||||
description:
|
||||
"For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).",
|
||||
})
|
||||
@ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" })
|
||||
cancelReservationForSeat(
|
||||
@Param("seatId") seatId: string,
|
||||
@Query("scheduleId") scheduleId: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||
return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId);
|
||||
}
|
||||
|
||||
@Get("pay/:token")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2138,6 +2138,39 @@ export class BookingsService {
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff releasing a seat that already has an in-flight backoffice reservation must not
|
||||
* leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler
|
||||
* could pay for a seat that's since been given away. Finds the active reservation covering
|
||||
* this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's
|
||||
* still unpaid), then separately releases the SeatHold issueBookingFromReservation created —
|
||||
* cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet
|
||||
* for an unpaid reservation, so without this the seat would stay held until the hold's own
|
||||
* expiry. Once status flips to CANCELLED, getByPayToken's existing status check already
|
||||
* rejects the old payToken with "This booking is no longer awaiting payment" — no separate
|
||||
* payToken invalidation needed.
|
||||
*/
|
||||
async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) {
|
||||
const bookingSeat = await this.prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
seatId,
|
||||
scheduleId,
|
||||
booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' },
|
||||
},
|
||||
include: { booking: true },
|
||||
});
|
||||
if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat');
|
||||
|
||||
const { bookingRef } = bookingSeat.booking;
|
||||
const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined);
|
||||
|
||||
await this.prisma.seatHold.deleteMany({
|
||||
where: { scheduleId, seatIds: { hasSome: [seatId] } },
|
||||
});
|
||||
|
||||
return { ...result, bookingRef };
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
@@ -2,5 +2,5 @@ import { Module } from '@nestjs/common';
|
||||
import { LiveController } from './live.controller';
|
||||
import { LiveService } from './live.service';
|
||||
|
||||
@Module({ controllers: [LiveController], providers: [LiveService] })
|
||||
@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] })
|
||||
export class LiveModule {}
|
||||
|
||||
@@ -312,6 +312,18 @@ export class NotificationsService {
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
|
||||
|
||||
// A backoffice-issued reservation already sends its own purpose-built message —
|
||||
// GuestBookingService.issueBookingFromReservation texts /reserve/pay/<payToken> for a
|
||||
// PASSENGER-kind booking (the traveler has no portal session, so this generic template's
|
||||
// /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized
|
||||
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
|
||||
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
|
||||
const source = (booking as any)?.source ?? payload.booking?.source;
|
||||
if (source === 'BACKOFFICE_RESERVATION') {
|
||||
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await this.prisma.notificationTemplate.findUnique({
|
||||
where: { code: 'booking.created' },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@@ -177,6 +177,22 @@ export class SchedulesController {
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Post(':id/delay')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
|
||||
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
|
||||
onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned
|
||||
times, this is the only action needed for booking closure to reflect the delay — no separate cutoff
|
||||
update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and
|
||||
records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`,
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule with shifted stop times' })
|
||||
applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) {
|
||||
return this.service.applyDelay(id, dto);
|
||||
}
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
export class ApplyDelayDto {
|
||||
@ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' })
|
||||
@IsInt() delayMinutes: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' })
|
||||
@IsOptional() @IsInt() @Min(1) fromSequence?: number;
|
||||
}
|
||||
|
||||
export class BulkCreateSchedulesDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
|
||||
@@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { LiveModule } from '../live/live.module';
|
||||
|
||||
@Module({
|
||||
imports: [FareEngineModule, AuditModule],
|
||||
imports: [FareEngineModule, AuditModule, LiveModule],
|
||||
controllers: [RoutesController, SchedulesController],
|
||||
providers: [RoutesService, SchedulesService],
|
||||
exports: [RoutesService, SchedulesService],
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { LiveService } from '../live/live.service';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,8 +18,47 @@ export class SchedulesService {
|
||||
private routesService: RoutesService,
|
||||
private fareEngine: FareEngineService,
|
||||
private auditService: AuditService,
|
||||
private liveService: LiveService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Computes each stop's planned arrival/departure time by walking the route in sequence
|
||||
* order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the
|
||||
* previous stop). Falls back to distance-proportional interpolation over `distanceKm` for
|
||||
* any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed
|
||||
* overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays
|
||||
* authoritative even if per-stop estimates drift.
|
||||
*/
|
||||
private computePlannedTimes(
|
||||
route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] },
|
||||
dep: Date,
|
||||
arr: Date,
|
||||
) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
let cursor = dep;
|
||||
return route.stops.map((stop, index) => {
|
||||
if (index === 0) {
|
||||
cursor = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
cursor = arr;
|
||||
} else if (stop.travelMinutesToStop != null) {
|
||||
cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000);
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
cursor = new Date(dep.getTime() + totalDuration * progress);
|
||||
this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = parseEthiopianTime(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
@@ -89,6 +129,7 @@ export class SchedulesService {
|
||||
include: { coach: true },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
@@ -132,7 +173,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||
@@ -209,6 +250,7 @@ export class SchedulesService {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
@@ -303,7 +345,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
@@ -425,6 +467,70 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shifts stored planned times additively rather than reusing updateSchedulePartial's
|
||||
* recompute-from-route-interpolation path — that path also guards `departureAt must be in the
|
||||
* future`, which a delay report for an already-departed/EN_ROUTE train would legitimately
|
||||
* fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived
|
||||
* directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the
|
||||
* stored values here is the entire fix — neither of those needs to change.
|
||||
*/
|
||||
async applyDelay(scheduleId: string, dto: ApplyDelayDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const stopWhere: any = { scheduleId };
|
||||
if (dto.fromSequence != null) {
|
||||
stopWhere.sequence = { gte: dto.fromSequence };
|
||||
} else {
|
||||
// Default: only stops the train hasn't reached yet — a delay report must not retroactively
|
||||
// move a stop that's already BOARDED/COMPLETED.
|
||||
stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] };
|
||||
}
|
||||
|
||||
const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere });
|
||||
const shiftMs = dto.delayMinutes * 60_000;
|
||||
const includesOrigin = stopsToShift.some((s) => s.sequence === 1);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const stop of stopsToShift) {
|
||||
await tx.tripStopTime.update({
|
||||
where: { id: stop.id },
|
||||
data: {
|
||||
plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined,
|
||||
plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window
|
||||
// queries and the displayed departure time, so they must move too (both together, so
|
||||
// durationMinutes stays correct).
|
||||
if (includesOrigin) {
|
||||
await tx.trainSchedule.update({
|
||||
where: { id: scheduleId },
|
||||
data: {
|
||||
departureAt: new Date(schedule.departureAt.getTime() + shiftMs),
|
||||
arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } });
|
||||
const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes);
|
||||
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Schedule',
|
||||
entityId: scheduleId,
|
||||
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
|
||||
});
|
||||
|
||||
return this.getSchedule(scheduleId);
|
||||
}
|
||||
|
||||
async upsertScheduleFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
@@ -681,7 +787,7 @@ export class SchedulesService {
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (route && route.stops.length >= 2) {
|
||||
const plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
const plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@@ -67,6 +67,21 @@ Nationality-Based:
|
||||
return this.service.getFareQuote(dto);
|
||||
}
|
||||
|
||||
@Get('available-dates')
|
||||
@ApiOperation({
|
||||
summary: 'Which dates in a range have a bookable schedule for an origin/destination pair',
|
||||
description: `Used to disable schedule-less dates on the search date picker before the user submits a search.
|
||||
|
||||
For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one
|
||||
schedule exists for the origin→destination pair whose status/package/coach state is bookable and
|
||||
whose check-in cutoff has not yet passed. This does not check seat-level availability — a date
|
||||
can be marked available and still turn out fully booked when actually searched.`,
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' })
|
||||
getAvailableDates(@Query() dto: AvailableDatesQueryDto) {
|
||||
return this.service.getAvailableDates(dto);
|
||||
}
|
||||
|
||||
@Get('fare-breakdown')
|
||||
@ApiOperation({
|
||||
summary: 'Per-passenger fare breakdown for booking review page',
|
||||
|
||||
@@ -29,6 +29,20 @@ export class SearchTripsDto {
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' })
|
||||
@IsDateString() from: string;
|
||||
|
||||
@ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' })
|
||||
@IsDateString() to: string;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FareQuoteDto,
|
||||
FareBreakdownRequestDto,
|
||||
FareBreakdownPassengerDto,
|
||||
AvailableDatesQueryDto,
|
||||
} from "./search.dto";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
@@ -383,16 +384,9 @@ export class SearchService {
|
||||
|
||||
// 1. Does any active route connect these two stations, in this direction, at all —
|
||||
// ignoring date entirely?
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
const routeExists = candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
}
|
||||
|
||||
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
||||
// (regardless of status/package/coach/cutoff — those are checked next)?
|
||||
@@ -425,12 +419,7 @@ export class SearchService {
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter(
|
||||
(s) =>
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0,
|
||||
);
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
@@ -452,6 +441,110 @@ export class SearchService {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any active route connects originStationId → destinationStationId in this
|
||||
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
|
||||
* getAvailableDates.
|
||||
*/
|
||||
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
return candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
}
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
|
||||
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
|
||||
private toAddisDateStr(d: Date): string {
|
||||
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
|
||||
* for originStationId → destinationStationId — used to disable schedule-less dates on the
|
||||
* search date picker before the user submits a search. Reuses the same route-existence and
|
||||
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
|
||||
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
|
||||
* — a date can be marked available and still turn out fully booked when actually searched.
|
||||
*/
|
||||
async getAvailableDates(dto: AvailableDatesQueryDto) {
|
||||
const { originStationId, destinationStationId } = dto;
|
||||
|
||||
const todayStr = this.toAddisDateStr(new Date());
|
||||
const from = dto.from > todayStr ? dto.from : todayStr;
|
||||
const fromDate = new Date(`${from}T00:00:00+03:00`);
|
||||
|
||||
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
|
||||
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
|
||||
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
|
||||
const to = this.toAddisDateStr(toDate);
|
||||
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
from,
|
||||
to,
|
||||
routeExists: false,
|
||||
dates: [] as { date: string; available: boolean }[],
|
||||
};
|
||||
}
|
||||
|
||||
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: fromDate, lt: rangeEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
select: {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
coachAssignments: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const availableDays = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
|
||||
if (!this.isBookableSchedule(s)) continue;
|
||||
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
|
||||
availableDays.add(this.toAddisDateStr(s.departureAt));
|
||||
}
|
||||
|
||||
const dates: { date: string; available: boolean }[] = [];
|
||||
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
|
||||
const dateStr = this.toAddisDateStr(cursor);
|
||||
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
|
||||
}
|
||||
|
||||
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
|
||||
}
|
||||
|
||||
// ── Transit search ─────────────────────────────────────────────────────────
|
||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||
|
||||
@@ -45,13 +45,16 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
);
|
||||
const [effectiveStatuses, reservations] = await Promise.all([
|
||||
this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
),
|
||||
this.resolveActiveReservations(allSeatIds, scheduleId),
|
||||
]);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
@@ -70,6 +73,7 @@ export class SeatsService {
|
||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||
: s.bedPosition;
|
||||
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||
const reservation = reservations.get(s.id);
|
||||
return {
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
@@ -88,6 +92,15 @@ export class SeatsService {
|
||||
position: this.colToPosition(s.col, a.coach.arrangement),
|
||||
bed_type: this.bedPositionToType(resolvedBedPosition),
|
||||
} : {}),
|
||||
// Backoffice-issued reservation covering this seat, if any — lets staff see who's
|
||||
// paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without
|
||||
// leaving the seat map. See resolveActiveReservations.
|
||||
...(reservation ? {
|
||||
bookingRef: reservation.bookingRef,
|
||||
reservationStatus: reservation.status,
|
||||
reservationPassengerName: reservation.passengerName,
|
||||
reservationContactPhone: reservation.contactPhone,
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -259,6 +272,46 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on
|
||||
* this schedule — a booking created via GuestBookingService.issueBookingFromReservation
|
||||
* (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet
|
||||
* paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the
|
||||
* backoffice seat map so staff can see who's paying/ticketed for a given seat without
|
||||
* looking it up separately.
|
||||
*/
|
||||
private async resolveActiveReservations(
|
||||
seatIds: string[],
|
||||
scheduleId: string,
|
||||
): Promise<Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>> {
|
||||
const map = new Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>();
|
||||
if (seatIds.length === 0) return map;
|
||||
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
seatId: { in: seatIds },
|
||||
scheduleId,
|
||||
booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
|
||||
},
|
||||
select: {
|
||||
seatId: true,
|
||||
passengerName: true,
|
||||
booking: { select: { bookingRef: true, status: true, contactPhone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
if (!bs.seatId) continue;
|
||||
map.set(bs.seatId, {
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
status: bs.booking.status,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -295,10 +348,9 @@ export class SeatsService {
|
||||
|
||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
// Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no
|
||||
// arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival,
|
||||
// so holding closes the moment the train reaches the boarding stop.
|
||||
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||
// Arrival basis: the origin stop's own estimated arrival, not its departure. The first
|
||||
// stop of a route has no arrival (nothing to arrive at), so it falls back to its departure.
|
||||
const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -26,6 +26,7 @@ import { validateSync } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||
import { SegmentsService } from "../src/modules/segments/segments.service";
|
||||
import { TicketsService } from "../src/modules/tickets/tickets.service";
|
||||
import { PaymentsService } from "../src/modules/payments/payments.service";
|
||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||
@@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config.
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||
import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto";
|
||||
import { NotificationsService } from "../src/modules/notifications/notifications.service";
|
||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||
|
||||
@@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
let seatsService: SeatsService;
|
||||
let guestBookingService: GuestBookingService;
|
||||
let bookingsService: BookingsService;
|
||||
let notificationsService: NotificationsService;
|
||||
let smsClient: { sendSms: jest.Mock };
|
||||
let emailClient: { sendEmail: jest.Mock };
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
@@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||
|
||||
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||
// Real SegmentsService (not asyncStub) — the getSeatMap test below exercises
|
||||
// resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs
|
||||
// an actual Map back, not asyncStub's `async () => undefined`.
|
||||
const segmentsService = new SegmentsService(harness.prisma as any);
|
||||
seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub());
|
||||
const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||
const paymentsService = new PaymentsService(
|
||||
harness.prisma as any,
|
||||
@@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
harness.prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
seatsService,
|
||||
{ emit: () => true } as any,
|
||||
ticketsService,
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
currencyService,
|
||||
fareEngine,
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
||||
// Same smsClient instance guestBookingService uses — lets the notification-suppression
|
||||
// test assert on ONE shared call count across both services, proving the reservation
|
||||
// flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking.
|
||||
notificationsService = new NotificationsService(
|
||||
harness.prisma as any,
|
||||
asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups,
|
||||
// never hit by these guest-passenger-id-keyed test bookings
|
||||
emailClient as any,
|
||||
smsClient as any,
|
||||
asyncStub(), // pushAdapter
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
smsClient.sendSms.mockClear();
|
||||
emailClient.sendEmail.mockClear();
|
||||
});
|
||||
|
||||
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
|
||||
@@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
||||
});
|
||||
|
||||
it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => {
|
||||
// Regression for: the customer got TWO conflicting messages for one reservation —
|
||||
// the reservation-specific /reserve/pay/<payToken> SMS from issueBookingFromReservation,
|
||||
// AND a second, generic booking.created notification pointing at /booking/detail?ref=,
|
||||
// a page that doesn't work for a traveler with no portal session.
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.notificationTemplate.upsert({
|
||||
where: { code: "booking.created" },
|
||||
update: { active: true },
|
||||
create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true },
|
||||
});
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||
seats[0].id,
|
||||
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||
"staff-user-5",
|
||||
);
|
||||
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS
|
||||
|
||||
// Directly invoke the event handler (the test harness's eventEmitter is a stub, so the
|
||||
// real 'booking.created' emit from issueBookingFromReservation never reaches it) — this
|
||||
// is what NotificationsService would have done had it received that event.
|
||||
await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } });
|
||||
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped
|
||||
expect(emailClient.sendEmail).not.toHaveBeenCalled();
|
||||
|
||||
// Control: a normal (non-reservation) booking must still get the generic notification.
|
||||
const passenger = await harness.prisma.passenger.create({ data: {} });
|
||||
const normalBooking = await harness.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: `WEB-CTRL-${Date.now()}`,
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
status: "PENDING_PAYMENT",
|
||||
totalMinor: 10_000,
|
||||
contactPhone: "+251911234567",
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } });
|
||||
expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings
|
||||
});
|
||||
|
||||
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
|
||||
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
|
||||
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
|
||||
@@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
).rejects.toThrow(/already (held|booked)/i);
|
||||
});
|
||||
|
||||
it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||
seats[0].id,
|
||||
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||
"staff-user-7",
|
||||
);
|
||||
const payToken = result.booking.payToken;
|
||||
|
||||
const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7");
|
||||
expect(cancelResult.cancelled).toBe(true);
|
||||
expect(cancelResult.bookingRef).toBe(result.booking.bookingRef);
|
||||
|
||||
const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } });
|
||||
expect(cancelledBooking?.status).toBe("CANCELLED");
|
||||
|
||||
// The seat is genuinely free — a member of the public can now hold it.
|
||||
await expect(
|
||||
seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
|
||||
} as any),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
// The old payment link no longer works.
|
||||
await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i);
|
||||
});
|
||||
|
||||
it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
await expect(
|
||||
bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"),
|
||||
).rejects.toThrow(/no pending reservation/i);
|
||||
});
|
||||
|
||||
it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
// Seat 0: PASSENGER reservation — still PENDING_PAYMENT.
|
||||
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||
const pending: any = await guestBookingService.issueBookingFromReservation(
|
||||
seats[0].id,
|
||||
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||
"staff-user-6",
|
||||
);
|
||||
|
||||
// Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately.
|
||||
await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id);
|
||||
const staffResult: any = await guestBookingService.issueBookingFromReservation(
|
||||
seats[1].id,
|
||||
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
|
||||
"staff-user-6",
|
||||
);
|
||||
|
||||
const seatMap: any = await seatsService.getSeatMap(schedule.id);
|
||||
const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []);
|
||||
const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id);
|
||||
const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id);
|
||||
|
||||
expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef);
|
||||
expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT");
|
||||
expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock
|
||||
|
||||
expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef);
|
||||
expect(ticketedSeat.reservationStatus).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
it("requires a phone number for a PASSENGER booking", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -28,6 +28,7 @@ interface Schedule {
|
||||
destinationStation?: { id: string; name: string };
|
||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||
isPackageOnly?: boolean;
|
||||
liveStatus?: { delayMinutes: number } | null;
|
||||
}
|
||||
|
||||
interface Train {
|
||||
@@ -210,13 +211,6 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const recalculateStopsMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteScheduleMutation = useMutation({
|
||||
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`),
|
||||
onSuccess: () => {
|
||||
@@ -461,6 +455,16 @@ export default function SchedulesPage() {
|
||||
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'liveStatus.delayMinutes',
|
||||
label: 'Delay',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => {
|
||||
const delay = schedule.liveStatus?.delayMinutes ?? 0;
|
||||
if (delay <= 0) return <span className="text-sm text-muted-foreground">On time</span>;
|
||||
return <span className="edr-badge edr-badge-warning">+{delay} min</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'coachAssignments',
|
||||
label: 'Coaches',
|
||||
@@ -493,6 +497,15 @@ export default function SchedulesPage() {
|
||||
|
||||
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||
|
||||
const applyDelayMutation = useMutation({
|
||||
mutationFn: ({ id, minutes }: { id: string; minutes: number }) =>
|
||||
apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
|
||||
});
|
||||
const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||
const [delayMinutesInput, setDelayMinutesInput] = useState('');
|
||||
const [delayError, setDelayError] = useState<string | null>(null);
|
||||
|
||||
const scheduleActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
@@ -500,6 +513,17 @@ export default function SchedulesPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Report Delay',
|
||||
onClick: (schedule: Schedule) => {
|
||||
setDelayMinutesInput('');
|
||||
setDelayError(null);
|
||||
setDelayPrompt({ isOpen: true, item: schedule });
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Clock,
|
||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||
@@ -660,6 +684,67 @@ export default function SchedulesPage() {
|
||||
isLoading={cancelScheduleMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={delayPrompt.isOpen}
|
||||
onClose={() => setDelayPrompt({ isOpen: false, item: null })}
|
||||
title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`}
|
||||
size="sm"
|
||||
>
|
||||
{delayPrompt.item && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const minutes = parseInt(delayMinutesInput, 10);
|
||||
if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; }
|
||||
try {
|
||||
await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes });
|
||||
setDelayPrompt({ isOpen: false, item: null });
|
||||
} catch (err: any) {
|
||||
setDelayError(err?.response?.data?.message || 'Failed to apply delay.');
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{delayError && (
|
||||
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{delayError}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-lg border border-border bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Current reported delay</span>
|
||||
{(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? (
|
||||
<span className="edr-badge edr-badge-warning">+{delayPrompt.item.liveStatus?.delayMinutes} min</span>
|
||||
) : (
|
||||
<span className="text-sm font-medium">On time</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Delay (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={delayMinutesInput}
|
||||
onChange={(e) => setDelayMinutesInput(e.target.value)}
|
||||
placeholder="e.g. 60"
|
||||
className="input"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Adds to the current reported delay above and pushes every downstream station's
|
||||
check-in cutoff back by this many minutes. Use a negative number to correct an
|
||||
over-reported delay.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => setDelayPrompt({ isOpen: false, item: null })}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton type="submit" loading={applyDelayMutation.isPending}>
|
||||
Apply Delay
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function SeatsPage() {
|
||||
phone: '',
|
||||
email: '',
|
||||
});
|
||||
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null);
|
||||
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
@@ -126,13 +126,19 @@ export default function SeatsPage() {
|
||||
bookingsApi.issueFromReservation(seatId, data),
|
||||
onSuccess: (result: any) => {
|
||||
invalidateSeatData();
|
||||
setIssueBookingResult({ payUrl: result?.payUrl });
|
||||
if (!result?.payUrl) {
|
||||
// STAFF booking — nothing further to show the admin, close immediately.
|
||||
setShowIssueBookingModal(false);
|
||||
setSelectedSeat(null);
|
||||
setIssueBookingCoach(null);
|
||||
}
|
||||
// Always show the reference — the PASSENGER path also needs the PNR alongside the
|
||||
// pay link (staff need to know which booking a seat belongs to, whether it's
|
||||
// awaiting payment or already ticketed), so no longer auto-closing for STAFF.
|
||||
setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef });
|
||||
},
|
||||
});
|
||||
|
||||
// Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat —
|
||||
// distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved).
|
||||
const cancelReservationMutation = useMutation({
|
||||
mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule),
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -222,6 +228,16 @@ export default function SeatsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already
|
||||
// released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking
|
||||
// invalidates its payment link immediately, so warn staff explicitly about that.
|
||||
const handleCancelReservation = async (seat: any) => {
|
||||
if (!selectedSchedule) return;
|
||||
if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) {
|
||||
await cancelReservationMutation.mutateAsync(seat.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIssueBooking = (seat: any, coach: any) => {
|
||||
if (activeTab !== 'schedule' || !selectedSchedule) {
|
||||
alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.');
|
||||
@@ -433,6 +449,7 @@ export default function SeatsPage() {
|
||||
handleBlock={handleBlock}
|
||||
handleRemoveSeat={handleRemoveSeat}
|
||||
handleUnblock={handleUnblock}
|
||||
handleCancelReservation={handleCancelReservation}
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
@@ -529,6 +546,7 @@ export default function SeatsPage() {
|
||||
handleBlock={handleBlock}
|
||||
handleRemoveSeat={handleRemoveSeat}
|
||||
handleUnblock={handleUnblock}
|
||||
handleCancelReservation={handleCancelReservation}
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
@@ -552,6 +570,7 @@ export default function SeatsPage() {
|
||||
handleBlock={handleBlock}
|
||||
handleRemoveSeat={handleRemoveSeat}
|
||||
handleUnblock={handleUnblock}
|
||||
handleCancelReservation={handleCancelReservation}
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
@@ -888,12 +907,25 @@ export default function SeatsPage() {
|
||||
title="Issue Booking"
|
||||
size="md"
|
||||
>
|
||||
{issueBookingResult?.payUrl ? (
|
||||
{issueBookingResult ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Booking created. A payment link has been sent via SMS to the traveler.
|
||||
{issueBookingResult.payUrl
|
||||
? 'Booking created. A payment link has been sent via SMS to the traveler.'
|
||||
: 'Booking confirmed and ticketed.'}
|
||||
</p>
|
||||
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
|
||||
{issueBookingResult.bookingRef && (
|
||||
<div>
|
||||
<label className="label">Booking Reference (PNR)</label>
|
||||
<div className="input font-mono font-semibold text-sm">{issueBookingResult.bookingRef}</div>
|
||||
</div>
|
||||
)}
|
||||
{issueBookingResult.payUrl && (
|
||||
<div>
|
||||
<label className="label">Payment Link</label>
|
||||
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
@@ -1231,6 +1263,7 @@ interface SeatIconProps {
|
||||
handleBlock: (seat: any) => void;
|
||||
handleRemoveSeat: (seat: any) => void;
|
||||
handleUnblock: (seat: any) => void;
|
||||
handleCancelReservation: (seat: any) => void;
|
||||
handleUndoRemove: (seat: any) => void;
|
||||
handleSetMaintenance: (seat: any) => void;
|
||||
handleClearMaintenance: (seat: any) => void;
|
||||
@@ -1248,6 +1281,7 @@ function SeatIcon({
|
||||
handleBlock,
|
||||
handleRemoveSeat,
|
||||
handleUnblock,
|
||||
handleCancelReservation,
|
||||
handleUndoRemove,
|
||||
handleSetMaintenance,
|
||||
handleClearMaintenance,
|
||||
@@ -1285,6 +1319,10 @@ function SeatIcon({
|
||||
const color = getSeatColor(status);
|
||||
const canBlock = status === 'AVAILABLE';
|
||||
const canUnblock = status === 'BLOCKED';
|
||||
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
|
||||
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
|
||||
// it's not reachable via canUnblock anymore; this is the seat's own release path.
|
||||
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
||||
const canMaintenance = false;
|
||||
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
||||
|
||||
@@ -1299,7 +1337,7 @@ function SeatIcon({
|
||||
{isBedCoach ? (
|
||||
<div
|
||||
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}${seat.bookingRef ? ` - PNR ${seat.bookingRef} (${seat.reservationStatus})` : ''}`}
|
||||
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Bed className="w-7 h-7 text-white" />
|
||||
@@ -1307,14 +1345,23 @@ function SeatIcon({
|
||||
) : (
|
||||
<div
|
||||
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
title={`${seat.seatNumber} - ${status}`}
|
||||
title={`${seat.seatNumber} - ${status}${seat.bookingRef ? ` - PNR ${seat.bookingRef} (${seat.reservationStatus})` : ''}`}
|
||||
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Armchair className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
|
||||
{seat.bookingRef && (
|
||||
<span
|
||||
className="text-[9px] leading-3 font-semibold text-foreground/80 mt-0.5 max-w-[3.5rem] truncate"
|
||||
title={`PNR ${seat.bookingRef} — ${seat.reservationStatus}${seat.reservationPassengerName ? ` — ${seat.reservationPassengerName}` : ''}`}
|
||||
>
|
||||
{seat.bookingRef}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
|
||||
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||
{canBlock && (
|
||||
<>
|
||||
@@ -1334,6 +1381,15 @@ function SeatIcon({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canCancelReservation && (
|
||||
<button
|
||||
onClick={() => handleCancelReservation(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title={`Cancel reservation (PNR ${seat.bookingRef}) — invalidates the payment link`}
|
||||
>
|
||||
<Unlock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
{canUnblock && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -52,6 +52,10 @@ export const bookingsApi = {
|
||||
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
|
||||
issueFromReservation: (seatId: string, data: any) =>
|
||||
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
|
||||
// Cancels a seat's still-PENDING_PAYMENT reservation (payment link sent, not yet paid) and
|
||||
// releases the seat — the old payment link stops working immediately.
|
||||
cancelReservation: (seatId: string, scheduleId: string) =>
|
||||
apiClient.delete<any>(`/bookings/reservations/${seatId}?scheduleId=${scheduleId}`),
|
||||
};
|
||||
|
||||
// Passengers API
|
||||
@@ -141,6 +145,8 @@ export const schedulesApi = {
|
||||
update: (id: string, data: any) => apiClient.patch<any>(`/schedules/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
|
||||
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/schedules/${id}/status`, { status }),
|
||||
applyDelay: (id: string, delayMinutes: number, fromSequence?: number) =>
|
||||
apiClient.post<any>(`/schedules/${id}/delay`, { delayMinutes, fromSequence }),
|
||||
assignCoaches: (scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
|
||||
apiClient.post<any>(`/schedules/${scheduleId}/coaches`, { coaches }),
|
||||
getAssignedCoaches: (scheduleId: string) => apiClient.get<any>(`/schedules/${scheduleId}/coaches`),
|
||||
|
||||
@@ -21,9 +21,19 @@ import {
|
||||
ChevronLeft,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
||||
|
||||
const AVAILABLE_DATES_RANGE_DAYS = 90;
|
||||
|
||||
const toDateStr = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
|
||||
interface AvailableDatesResponse {
|
||||
routeExists: boolean;
|
||||
dates: { date: string; available: boolean }[];
|
||||
}
|
||||
|
||||
function useDarkMode() {
|
||||
const [dark, setDark] = useState(
|
||||
() =>
|
||||
@@ -610,6 +620,7 @@ export default function SearchPage() {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
setError,
|
||||
trigger,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
@@ -691,6 +702,62 @@ export default function SearchPage() {
|
||||
const tripType = watch("tripType");
|
||||
const totalPassengers = (adultCount || 1) + (childCount || 0);
|
||||
|
||||
// Which dates have no bookable schedule for the selected From/To — disables them on the
|
||||
// departure date picker before the user submits a doomed search. Only fetched once both
|
||||
// stations are picked; while loading or unselected, no extra dates are disabled (pickers
|
||||
// keep their existing minDate-only behavior).
|
||||
const { data: availableDates } = useQuery<AvailableDatesResponse>({
|
||||
queryKey: ["available-dates", originId, destId],
|
||||
queryFn: async () => {
|
||||
const from = new Date();
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS);
|
||||
return (await apiClient.get("/search/available-dates", {
|
||||
params: {
|
||||
originStationId: originId,
|
||||
destinationStationId: destId,
|
||||
from: toDateStr(from),
|
||||
to: toDateStr(to),
|
||||
},
|
||||
})) as AvailableDatesResponse;
|
||||
},
|
||||
enabled: !!originId && !!destId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const disabledDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
if (!availableDates?.routeExists) return set;
|
||||
for (const d of availableDates.dates) if (!d.available) set.add(d.date);
|
||||
return set;
|
||||
}, [availableDates]);
|
||||
|
||||
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
|
||||
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
|
||||
// window (it was simply never fetched, not confirmed available), so once a route is picked
|
||||
// the picker's own maxDate has to match the same horizon or unchecked future months render
|
||||
// as pickable again.
|
||||
const maxSearchDate = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS);
|
||||
return d;
|
||||
}, []);
|
||||
const departureMaxDate = originId && destId ? maxSearchDate : undefined;
|
||||
|
||||
// If the currently selected departure date becomes unavailable (From/To changed, or the
|
||||
// availability query just resolved), clear it and surface an inline error rather than
|
||||
// letting the user submit a search that's already known to be empty.
|
||||
useEffect(() => {
|
||||
if (departureDate && disabledDates.has(departureDate)) {
|
||||
setValue("departureDate", "");
|
||||
setError("departureDate", {
|
||||
type: "manual",
|
||||
message:
|
||||
"No trains run this route on the selected date — please pick another date.",
|
||||
});
|
||||
}
|
||||
}, [departureDate, disabledDates, setValue, setError]);
|
||||
|
||||
const saveRecent = useCallback((id: string) => {
|
||||
setRecentStationIds((prev) => {
|
||||
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
|
||||
@@ -1126,6 +1193,8 @@ export default function SearchPage() {
|
||||
trigger("departureDate");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
placeholder="Departure date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
@@ -1316,6 +1385,8 @@ export default function SearchPage() {
|
||||
trigger("departureDate");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
placeholder="Departure"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
@@ -1470,6 +1541,8 @@ export default function SearchPage() {
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
placeholder="Departure date"
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
|
||||
@@ -18,15 +18,26 @@ interface ModernDatePickerProps {
|
||||
onChange: (date: Date) => void;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
// Dates with no bookable schedule for the selected route (YYYY-MM-DD keys) — disabled
|
||||
// alongside the minDate/maxDate range, not just clamping it.
|
||||
disabledDates?: Set<string>;
|
||||
placeholder?: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const toDateKey = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
export default function ModernDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabledDates,
|
||||
placeholder = 'Select date',
|
||||
error = false,
|
||||
}: ModernDatePickerProps) {
|
||||
@@ -120,7 +131,7 @@ export default function ModernDatePicker({
|
||||
const date = new Date(viewYear, viewMonth, day);
|
||||
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
||||
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())) || !!disabledDates?.has(toDateKey(date));
|
||||
return (
|
||||
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
|
||||
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||
@@ -180,6 +191,7 @@ export default function ModernDatePicker({
|
||||
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
|
||||
}
|
||||
if (!isDisabled && disabledDates?.has(toDateKey(gregDate))) isDisabled = true;
|
||||
return (
|
||||
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
|
||||
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||
|
||||
@@ -9,9 +9,23 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Station } from '@/types';
|
||||
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const AVAILABLE_DATES_RANGE_DAYS = 90;
|
||||
|
||||
const toDateStr = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
interface AvailableDatesResponse {
|
||||
routeExists: boolean;
|
||||
dates: { date: string; available: boolean }[];
|
||||
}
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1, 'Please select a departure station'),
|
||||
@@ -129,7 +143,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
queryFn: async () => await apiClient.get('/stations') as Station[],
|
||||
});
|
||||
|
||||
const { handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
||||
const { handleSubmit, watch, setValue, clearErrors, setError, formState: { errors } } = useForm<SearchForm>({
|
||||
// @ts-ignore
|
||||
resolver: zodResolver(searchSchema),
|
||||
mode: 'onSubmit',
|
||||
@@ -147,6 +161,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
|
||||
const originId = watch('originStationId');
|
||||
const destinationId = watch('destinationStationId');
|
||||
const departureDate = watch('departureDate');
|
||||
const nationality = watch('nationality');
|
||||
const adultCount = watch('adultCount');
|
||||
const childCount = watch('childCount');
|
||||
@@ -154,6 +169,62 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: s.name }));
|
||||
const destinationOptions = stationOptions.map((s) => ({ ...s, disabled: s.value === originId }));
|
||||
|
||||
// Which dates have no bookable schedule for the selected route — disables them on the date
|
||||
// picker before the user submits a doomed search. Only fetched once both stations are picked;
|
||||
// while loading or unselected, no extra dates are disabled (picker keeps today's minDate-only
|
||||
// behavior).
|
||||
const { data: availableDates } = useQuery<AvailableDatesResponse>({
|
||||
queryKey: ['available-dates', originId, destinationId],
|
||||
queryFn: async () => {
|
||||
const from = new Date();
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS);
|
||||
return await apiClient.get('/search/available-dates', {
|
||||
params: {
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
from: toDateStr(from),
|
||||
to: toDateStr(to),
|
||||
},
|
||||
}) as AvailableDatesResponse;
|
||||
},
|
||||
enabled: !!originId && !!destinationId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const disabledDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
if (!availableDates) return set;
|
||||
if (!availableDates.routeExists) return set; // no route → don't blanket-disable every date, the submit-time error already covers this
|
||||
for (const d of availableDates.dates) if (!d.available) set.add(d.date);
|
||||
return set;
|
||||
}, [availableDates]);
|
||||
|
||||
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
|
||||
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
|
||||
// window (it was simply never fetched, not confirmed available), so once a route is picked
|
||||
// the picker's own maxDate has to match the same horizon or unchecked future months render
|
||||
// as pickable again.
|
||||
const maxSearchDate = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS);
|
||||
return d;
|
||||
}, []);
|
||||
const departureMaxDate = originId && destinationId ? maxSearchDate : undefined;
|
||||
|
||||
// If the currently selected date becomes unavailable (route changed, or the availability
|
||||
// query just resolved), clear it and surface an inline error rather than letting the user
|
||||
// submit a search that's already known to be empty.
|
||||
useEffect(() => {
|
||||
if (departureDate && disabledDates.has(departureDate)) {
|
||||
setValue('departureDate', '');
|
||||
setError('departureDate', {
|
||||
type: 'manual',
|
||||
message: 'No trains run this route on the selected date — please pick another date.',
|
||||
});
|
||||
}
|
||||
}, [departureDate, disabledDates, setValue, setError]);
|
||||
|
||||
const onSubmit = (data: SearchForm) => {
|
||||
setSearchCriteria({ ...data });
|
||||
const params = new URLSearchParams({
|
||||
@@ -213,15 +284,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
|
||||
<ModernDatePicker
|
||||
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
setValue('departureDate', `${y}-${m}-${d}`);
|
||||
setValue('departureDate', toDateStr(date));
|
||||
clearErrors('departureDate');
|
||||
}}
|
||||
minDate={new Date()}
|
||||
maxDate={departureMaxDate}
|
||||
disabledDates={disabledDates}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
{errors.departureDate && (
|
||||
|
||||
@@ -9,21 +9,18 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-freight-api/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}"
|
||||
env_file:
|
||||
- apps/edr-freight-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
restart: always
|
||||
|
||||
gps-tracker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-gps-tracker/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${GT06_TCP_PORT:-5023}:5023"
|
||||
environment:
|
||||
@@ -31,8 +28,8 @@ services:
|
||||
GT06_TCP_HOST: "0.0.0.0"
|
||||
env_file:
|
||||
- apps/edr-gps-tracker/.env
|
||||
restart: unless-stopped
|
||||
|
||||
restart: always
|
||||
|
||||
passenger-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -43,8 +40,8 @@ services:
|
||||
- apps/edr-passenger-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
restart: unless-stopped
|
||||
|
||||
restart: always
|
||||
|
||||
freight-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -58,11 +55,10 @@ services:
|
||||
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
||||
VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-}
|
||||
VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||
|
||||
restart: always
|
||||
|
||||
freight-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -76,11 +72,10 @@ services:
|
||||
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
||||
VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-}
|
||||
VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||
|
||||
restart: always
|
||||
|
||||
passenger-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -89,14 +84,12 @@ services:
|
||||
APP_PACKAGE: "@edr/passenger-portal"
|
||||
APP_PATH: apps/edr-passenger-web/portal
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/portal/.env
|
||||
restart: unless-stopped
|
||||
|
||||
restart: always
|
||||
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -105,14 +98,12 @@ services:
|
||||
APP_PACKAGE: "@edr/passenger-backoffice"
|
||||
APP_PATH: apps/edr-passenger-web/backoffice
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/backoffice/.env
|
||||
restart: unless-stopped
|
||||
|
||||
restart: always
|
||||
|
||||
payment-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -120,12 +111,8 @@ services:
|
||||
args:
|
||||
APP_PACKAGE: "@edr/payment-api"
|
||||
APP_PATH: apps/edr-payment-api
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
|
||||
env_file:
|
||||
- apps/edr-payment-api/.env
|
||||
secrets:
|
||||
npmrc:
|
||||
file: .npmrc
|
||||
restart: always
|
||||
|
||||
File diff suppressed because one or more lines are too long
50
packages/iam-seed/package.json
Normal file
50
packages/iam-seed/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@edr/iam-seed",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Shared IAM baseline seeder for the apps that share the `iam` schema",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -w -p tsconfig.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.43.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typeorm": "^0.3.20",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
645
packages/iam-seed/src/iam-baseline.seed.ts
Normal file
645
packages/iam-seed/src/iam-baseline.seed.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import {
|
||||
IamBaselineSeed,
|
||||
SeedApplication,
|
||||
SeedOrganizationType,
|
||||
SeedPermission,
|
||||
SeedPositionType,
|
||||
SeedRole,
|
||||
SeedRolePermission,
|
||||
SettingDefault,
|
||||
} from "./iam-seed.types";
|
||||
|
||||
/**
|
||||
* The default IAM baseline: what `IamBaselineSeeder` writes when an app does not
|
||||
* override a section via `IamSeedModule.forRoot()`.
|
||||
*
|
||||
* These rows started as a copy of the seed constants inside
|
||||
* `@tria-plc/iamapi-common` (`dist/db/seed/*`) as of 0.7.12, and are owned here
|
||||
* now. Nothing is read from that package at runtime, so the apps sharing the
|
||||
* `iam` schema stay consistent even while they resolve different versions of it
|
||||
* — but a package upgrade will not hand you new IAM permissions either. Diff
|
||||
* against `db/seed/role.seed` and `db/seed/org-type.seed` when upgrading.
|
||||
*
|
||||
* Ids are the package's originals. Keep them: rows in every existing
|
||||
* environment already carry them.
|
||||
*
|
||||
* Deliberately absent, because nothing in EDR reads them: the 11 Addis Ababa
|
||||
* sub-city organizations, the TRIA super-admin organization (`is_super_admin` is
|
||||
* only used to block edits to that row and hide it from one list query), and the
|
||||
* 7 non-IAM Smart Office applications.
|
||||
*/
|
||||
|
||||
/** Applications permissions hang off. Each app seeds its own separately. */
|
||||
const APPLICATIONS: SeedApplication[] = [
|
||||
{
|
||||
id: "019bcb17-5470-7604-8708-7ed04d842b41",
|
||||
key: "iam",
|
||||
name: { am: "የስማርት ኦፊስ ማንነት እና መዳረሻ አስተዳደር", en: "Smart Office Identity and Access Management" },
|
||||
},
|
||||
];
|
||||
|
||||
const ROLES: SeedRole[] = [
|
||||
{
|
||||
id: "520836ee-dc13-4c08-b572-8eced5bfd309",
|
||||
key: "super_admin",
|
||||
name: { am: "ዋና ተቆጣጣሪ", en: "Super Admin" },
|
||||
},
|
||||
{
|
||||
id: "b2de1eae-ef93-4e90-8ec9-351f0dd8a6a9",
|
||||
key: "organization_admin",
|
||||
name: { am: "የመስሪያ ቤት ዋና ተቆጣጣሪ", en: "Organization Admin" },
|
||||
},
|
||||
{
|
||||
id: "b3a9a5b5-9825-4290-8498-c62fb5925acd",
|
||||
key: "unit_admin",
|
||||
name: { am: "የመስሪያ ቤት ጽሕፈት ቤት ዋና ተቆጣጣሪ", en: "Organization Unit Admin" },
|
||||
},
|
||||
{
|
||||
id: "ffe82427-ab16-4571-913c-553deb1b0f0f",
|
||||
key: "guest",
|
||||
name: { am: "ተጠቃሚ", en: "Guest" },
|
||||
},
|
||||
];
|
||||
|
||||
const PERMISSIONS: SeedPermission[] = [
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000001",
|
||||
key: "can:create:role",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሚና መፍጠር", en: "Create Role" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000002",
|
||||
key: "can:update:role",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሚና ማሻሻል", en: "Update Role" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000003",
|
||||
key: "can:delete:role",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሚና ማጥፋት", en: "Delete Role" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000006",
|
||||
key: "can:update:permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ፈቃድ ማሻሻል", en: "Update Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000007",
|
||||
key: "can:delete:permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ፈቃድ ማጥፋት", en: "Delete Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000009",
|
||||
key: "can:create:role_permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የሚና-ፈቃድ መፍጠር", en: "Create Role-Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000010",
|
||||
key: "can:delete:role_permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የሚና-ፈቃድ ማጥፋት", en: "Delete Role-Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000011",
|
||||
key: "can:view:role_permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የሚና-ፈቃድ መመልከት", en: "View Role-Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000012",
|
||||
key: "can:create:user_role",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የተጠቃሚ-ሚና መፍጠር", en: "Create User-Role" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000013",
|
||||
key: "can:delete:user_role",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የተጠቃሚ-ሚና ማጥፋት", en: "Delete User-Role" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000014",
|
||||
key: "can:view:user_role",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የተጠቃሚ-ሚና መመልከት", en: "View User-Role" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000015",
|
||||
key: "can:create:position_permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የመደብ-ሚና መፍጠር", en: "Create Position-Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000016",
|
||||
key: "can:delete:position_permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የመደብ-ሚና ማጥፋት", en: "Delete Position-Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000017",
|
||||
key: "can:view:position_permission",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የመደብ-ሚና መመልከት", en: "View Position-Permission" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000018",
|
||||
key: "can:find_all:organization",
|
||||
name: { am: "ሁሉንም ድርጅቶች መፈለግ", en: "Find All Organizations" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000019",
|
||||
key: "can:update:organization",
|
||||
name: { am: "ድርጅት ማሻሻል", en: "Update Organization" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000020",
|
||||
key: "can:delete:organization",
|
||||
name: { am: "ድርጅት ማጥፋት", en: "Delete Organization" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000021",
|
||||
key: "can:create:unit",
|
||||
name: { am: "ክፍል መፍጠር", en: "Create Unit" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000022",
|
||||
key: "can:update:unit",
|
||||
name: { am: "ክፍል ማሻሻል", en: "Update Unit" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000023",
|
||||
key: "can:delete:unit",
|
||||
name: { am: "ክፍል ማጥፋት", en: "Delete Unit" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000024",
|
||||
key: "can:create:default_unit",
|
||||
name: { am: "የዩኒት አይነት መፍጠር", en: "Create Default Unit" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000025",
|
||||
key: "can:update:default_unit",
|
||||
name: { am: "የዩኒት አይነት ማሻሻል", en: "Update Default Unit" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000026",
|
||||
key: "can:delete:default_unit",
|
||||
name: { am: "የዩኒት አይነት ማጥፋት", en: "Delete Default Unit" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000027",
|
||||
key: "can:create:default_position",
|
||||
name: { am: "የስራ መደብ አይነት መፍጠር", en: "Create Default Position" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000028",
|
||||
key: "can:update:default_position",
|
||||
name: { am: "የስራ መደብ አይነት ማሻሻል", en: "Update Default Position" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000029",
|
||||
key: "can:delete:default_position",
|
||||
name: { am: "የስራ መደብ አይነት ማጥፋት", en: "Delete Default Position" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000030",
|
||||
key: "can:create:organization_type",
|
||||
name: { am: "የድርጅት አይነት መፍጠር", en: "Create Organization Type" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000031",
|
||||
key: "can:update:organization_type",
|
||||
name: { am: "የድርጅት አይነት ማሻሻል", en: "Update Organization Type" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000032",
|
||||
key: "can:delete:organization_type",
|
||||
name: { am: "የድርጅት አይነት ማጥፋት", en: "Delete Organization Type" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000033",
|
||||
key: "can:create:location_type",
|
||||
name: { am: "የአካባቢ አይነት መፍጠር", en: "Create Location Type" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000034",
|
||||
key: "can:update:location_type",
|
||||
name: { am: "የአካባቢ አይነት ማሻሻል", en: "Update Location Type" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000035",
|
||||
key: "can:delete:location_type",
|
||||
name: { am: "የአካባቢ አይነት ማጥፋት", en: "Delete Location Type" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000036",
|
||||
key: "can:create:location",
|
||||
name: { am: "አካባቢ መፍጠር", en: "Create Location" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000037",
|
||||
key: "can:update:location",
|
||||
name: { am: "አካባቢ ማሻሻል", en: "Update Location" },
|
||||
},
|
||||
{
|
||||
id: "019b5993-0000-0000-0000-000000000038",
|
||||
key: "can:delete:location",
|
||||
name: { am: "አካባቢ ማጥፋት", en: "Delete Location" },
|
||||
},
|
||||
{
|
||||
id: "a9a7c0fa-e4fc-4c0e-b1c2-f74f9da40421",
|
||||
key: "create:organization",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የመስሪያ ቤት መፍጠር", en: "Create Organization" },
|
||||
},
|
||||
{
|
||||
id: "c807691b-2693-4079-9dc5-1e080b67006c",
|
||||
key: "activate:organization",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የመስሪያ ቤት አስተካክል", en: "Activate Organization" },
|
||||
},
|
||||
{
|
||||
id: "457a3659-ed96-456a-a6a1-2881226a86ed",
|
||||
key: "can:debarOrganization",
|
||||
applicationKey: "iam",
|
||||
name: { am: "መቼት መቆጣጠር ይችላል", en: "Debar Organization" },
|
||||
},
|
||||
{
|
||||
id: "2cd9e3c5-bd41-48f3-a849-f497b18b2906",
|
||||
key: "manage:organizationAdmin",
|
||||
applicationKey: "iam",
|
||||
name: { am: "መቼት መቆጣጠር ይችላል", en: "Manage Organization Admin" },
|
||||
},
|
||||
{
|
||||
id: "257d8c6e-ef30-4510-892f-d4a2ad4c814d",
|
||||
key: "manage:unitAdmin",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የጽሕፈት ቤት መቼት መቆጣጠር ይችላል", en: "Manage Unit Admin" },
|
||||
},
|
||||
{
|
||||
id: "4052b7b8-e9f6-4bfe-9b93-eb59f9e4e576",
|
||||
key: "can:createEmployee",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሰራተኞችን መመደብ/መፍጠር ይችላሉ", en: "Can create employees" },
|
||||
},
|
||||
{
|
||||
id: "965ec76d-bbdb-47a1-a916-07c52f609fa7",
|
||||
key: "can:deactivateEmployee",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሰራተኞችን ማባረር ይችላሉ", en: "Can deactivate employees" },
|
||||
},
|
||||
{
|
||||
id: "019cbdc6-3d7a-73aa-ac57-51436dfa50e9",
|
||||
key: "can:activateEmployee",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሰራተኞችን መቀበል ይችላሉ", en: "Can activate employees" },
|
||||
},
|
||||
{
|
||||
id: "ebd8cf49-243d-4887-b2c9-cbe61e86a17a",
|
||||
key: "can:uploadUserCSV",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የሰራተኞችን መረጃ መጫን ይችላል", en: "Can Upload User CSV" },
|
||||
},
|
||||
{
|
||||
id: "68bebc03-c1a8-832f-ae5a-b66553e6bcef",
|
||||
key: "can:exportUnitUsers",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የተቀጣሪዎችን መረጃ ማውጣት ይችላሉ", en: "Can Export Unit Users" },
|
||||
},
|
||||
{
|
||||
id: "c2566286-248e-4cb4-923d-b113d6a5d4ba",
|
||||
key: "can:changeUsersProfile",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ተጠቃሚዎች መግለጫ መቀየር ይችላል", en: "Can Change Users Profile" },
|
||||
},
|
||||
{
|
||||
id: "14f438c6-9295-44a0-a4f3-4ac9855efc37",
|
||||
key: "can:activateUser",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ተጠቃሚዎችን መቆጣጠር ይችላል", en: "Can Activate/Deactivate User" },
|
||||
},
|
||||
{
|
||||
id: "31a39f88-47c0-4322-80ef-f397a6791ff2",
|
||||
key: "can:approveNewUser",
|
||||
applicationKey: "iam",
|
||||
name: { am: "አዲስ ተመዝጋቢ ማፅደቅ ይችላል", en: "Can Approve New User" },
|
||||
},
|
||||
{
|
||||
id: "69694936-8b54-8330-b176-16ffc98c33a7",
|
||||
key: "can:viewAllUsers",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ሁሉንም ተጠቃሚዎች ማየት ይችላል", en: "Can View All Users" },
|
||||
},
|
||||
{
|
||||
id: "68f1017f-4c60-8322-984d-e4317986e641",
|
||||
key: "can:manageUsersAccountConfiguration",
|
||||
applicationKey: "iam",
|
||||
name: { am: "የተጠቃሚ መለያ አዋቂነት መቆጣጠር ይችላል", en: "Can Manage Users Account Configuration" },
|
||||
},
|
||||
{
|
||||
id: "fceaa4c5-621f-45ce-b5a7-0ffc9e366fe1",
|
||||
key: "can:setUserRequirementDocument",
|
||||
applicationKey: "iam",
|
||||
name: { am: "ተመዝጋቢዎች የሚያስገቡትን መረጃ መቆጣጠር ይችላል", en: "Can Set User Requirement Document" },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Role → permission-key grants, applied additively: only missing pairs are
|
||||
* inserted, so grants made through the IAM UI survive a reseed.
|
||||
*/
|
||||
const ROLE_PERMISSIONS: SeedRolePermission[] = [
|
||||
{
|
||||
roleKey: "super_admin",
|
||||
permissionKeys: [
|
||||
"can:create:role",
|
||||
"can:update:role",
|
||||
"can:delete:role",
|
||||
"can:update:permission",
|
||||
"can:delete:permission",
|
||||
"can:create:role_permission",
|
||||
"can:delete:role_permission",
|
||||
"can:view:role_permission",
|
||||
"can:create:user_role",
|
||||
"can:delete:user_role",
|
||||
"can:view:user_role",
|
||||
"can:create:position_permission",
|
||||
"can:delete:position_permission",
|
||||
"can:view:position_permission",
|
||||
"can:find_all:organization",
|
||||
"can:update:organization",
|
||||
"can:delete:organization",
|
||||
"can:create:unit",
|
||||
"can:update:unit",
|
||||
"can:delete:unit",
|
||||
"can:create:default_unit",
|
||||
"can:update:default_unit",
|
||||
"can:delete:default_unit",
|
||||
"can:create:default_position",
|
||||
"can:update:default_position",
|
||||
"can:delete:default_position",
|
||||
"can:create:organization_type",
|
||||
"can:update:organization_type",
|
||||
"can:delete:organization_type",
|
||||
"can:create:location_type",
|
||||
"can:update:location_type",
|
||||
"can:delete:location_type",
|
||||
"create:organization",
|
||||
"activate:organization",
|
||||
"can:debarOrganization",
|
||||
"manage:organizationAdmin",
|
||||
"manage:unitAdmin",
|
||||
"can:activateUser",
|
||||
"can:approveNewUser",
|
||||
"can:viewAllUsers",
|
||||
"can:setUserRequirementDocument",
|
||||
"can:create:location",
|
||||
"can:update:location",
|
||||
"can:delete:location",
|
||||
],
|
||||
},
|
||||
{
|
||||
roleKey: "organization_admin",
|
||||
permissionKeys: [
|
||||
"can:uploadUserCSV",
|
||||
"can:changeUsersProfile",
|
||||
"can:createEmployee",
|
||||
"can:deactivateEmployee",
|
||||
"can:exportUnitUsers",
|
||||
"can:create:position_permission",
|
||||
"can:delete:position_permission",
|
||||
"can:view:position_permission",
|
||||
"can:create:unit",
|
||||
"can:update:unit",
|
||||
"can:delete:unit",
|
||||
"manage:unitAdmin",
|
||||
],
|
||||
},
|
||||
{
|
||||
roleKey: "unit_admin",
|
||||
permissionKeys: [
|
||||
"can:uploadUserCSV",
|
||||
"can:createEmployee",
|
||||
"can:exportUnitUsers",
|
||||
"can:changeUsersProfile",
|
||||
"can:deactivateEmployee",
|
||||
"can:manageUsersAccountConfiguration",
|
||||
"can:create:position_permission",
|
||||
"can:delete:position_permission",
|
||||
"can:view:position_permission",
|
||||
"can:create:unit",
|
||||
"can:update:unit",
|
||||
"can:delete:unit",
|
||||
],
|
||||
},
|
||||
{
|
||||
roleKey: "guest",
|
||||
permissionKeys: [
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const POSITION_TYPES: SeedPositionType[] = [
|
||||
{
|
||||
id: "457a3659-ed96-456a-a6a1-2881226a86ec",
|
||||
key: "employee",
|
||||
isSystem: true,
|
||||
name: { am: "ባለሙያ", en: "Employee" },
|
||||
},
|
||||
{
|
||||
id: "34a7f69c-3f30-47a0-81c3-fcfc3087e456",
|
||||
key: "teamLeader",
|
||||
isSystem: true,
|
||||
name: { am: "ቡድን መሪ", en: "Team Leader" },
|
||||
},
|
||||
{
|
||||
id: "2cd9e3c5-bd41-48f3-a849-f497b18b2905",
|
||||
key: "director",
|
||||
isSystem: true,
|
||||
name: { am: "ዳይሬክተር", en: "Director" },
|
||||
},
|
||||
{
|
||||
id: "db310acf-7a78-40a3-83c7-9a9e9e6d1fc7",
|
||||
key: "deputy",
|
||||
isSystem: true,
|
||||
name: { am: "ዘርፍ ኃላፊ", en: "Deputy" },
|
||||
},
|
||||
{
|
||||
id: "1a4b4d40-e4fc-4f38-99a6-f81dc5fcff23",
|
||||
key: "officeHead",
|
||||
isSystem: true,
|
||||
name: { am: "ቢሮ ኃላፊ", en: "Office Head" },
|
||||
},
|
||||
{
|
||||
id: "83bc6cd3-119e-4a41-917c-c763fb3fd013",
|
||||
key: "recordOfficer",
|
||||
isSystem: true,
|
||||
name: { am: "መዝገብ ቤት", en: "Record Officer" },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Organization types and the units an organization of that type is created
|
||||
* with. Both are read at runtime, which is why they are seeded: the New
|
||||
* Organization form posts `organizationTypeId`, and
|
||||
* `createOrganizationWithStructure` builds the new org's units from
|
||||
* `default_units`.
|
||||
*/
|
||||
const ORGANIZATION_TYPES: SeedOrganizationType[] = [
|
||||
{
|
||||
key: "woreda",
|
||||
name: { am: "ወረዳ", en: "Woreda" },
|
||||
defaultUnits: [
|
||||
{ key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Chief Executive Office" } },
|
||||
{ key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } },
|
||||
{ key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } },
|
||||
{ key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } },
|
||||
{ key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Prosecutor's Office" } },
|
||||
{ key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } },
|
||||
{ key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Enforcement Office" } },
|
||||
{ key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } },
|
||||
{ key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Cooperative Office" } },
|
||||
{ key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Business Office" } },
|
||||
{ key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } },
|
||||
{ key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } },
|
||||
{ key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid waste office" } },
|
||||
{ key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } },
|
||||
{ key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } },
|
||||
{ key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service Human Resource Management" } },
|
||||
{ key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Government Building Office" } },
|
||||
{ key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } },
|
||||
{ key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } },
|
||||
{ key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } },
|
||||
{ key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } },
|
||||
{ key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } },
|
||||
{ key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } },
|
||||
{ key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Supervision Office" } },
|
||||
{ key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } },
|
||||
{ key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } },
|
||||
{ key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Charity Coordination Office" } },
|
||||
{ key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } },
|
||||
{ key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } },
|
||||
{ key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Employment and Industry Development Office" } },
|
||||
{ key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } },
|
||||
{ key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "subcity",
|
||||
name: { am: "ክፍለ ከተማ", en: "Sub City" },
|
||||
defaultUnits: [
|
||||
{ key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Main Executive Office" } },
|
||||
{ key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } },
|
||||
{ key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } },
|
||||
{ key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } },
|
||||
{ key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Legal Affairs Office" } },
|
||||
{ key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } },
|
||||
{ key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Regulations Enforcement Office" } },
|
||||
{ key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } },
|
||||
{ key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Community Work Office" } },
|
||||
{ key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Trade Office" } },
|
||||
{ key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } },
|
||||
{ key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } },
|
||||
{ key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid Waste Management Office" } },
|
||||
{ key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } },
|
||||
{ key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } },
|
||||
{ key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service and Human Resource Management" } },
|
||||
{ key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Public Buildings Office" } },
|
||||
{ key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } },
|
||||
{ key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } },
|
||||
{ key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } },
|
||||
{ key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } },
|
||||
{ key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } },
|
||||
{ key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } },
|
||||
{ key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Control Office" } },
|
||||
{ key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } },
|
||||
{ key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } },
|
||||
{ key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Voluntarism Coordination Office" } },
|
||||
{ key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } },
|
||||
{ key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } },
|
||||
{ key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Labor and Industry Development Office" } },
|
||||
{ key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } },
|
||||
{ key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "office",
|
||||
name: { am: "ቢሮ", en: "Bureau" },
|
||||
defaultUnits: [
|
||||
{ key: "ቢሮ", description: "ቢሮ", name: { am: "ቢሮ", en: "Bureau" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "branch",
|
||||
name: { am: "ቅርንጫፍ", en: "Branch" },
|
||||
defaultUnits: [
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Seeded once per organization. All start unset; the IAM UI fills them in. */
|
||||
const ORGANIZATION_SETTINGS: SettingDefault[] = [
|
||||
{ key: "logoFileUrl", displayName: "Logo", type: "file" },
|
||||
{ key: "faviconFileUrl", displayName: "Favicon", type: "file" },
|
||||
{ key: "loginBackgroundFileUrl", displayName: "Login Background", type: "file" },
|
||||
{ key: "stampImageFileUrl", displayName: "Stamp Image", type: "file" },
|
||||
{ key: "customCssFileUrl", displayName: "Custom CSS", type: "file" },
|
||||
{ key: "primaryColor", displayName: "Primary Color", type: "value" },
|
||||
{ key: "secondaryColor", displayName: "Secondary Color", type: "value" },
|
||||
{ key: "accentColor", displayName: "Accent Color", type: "value" },
|
||||
{ key: "loginTitle", displayName: "Login Title", type: "value" },
|
||||
{ key: "loginSubtitle", displayName: "Login Subtitle", type: "value" },
|
||||
{ key: "sidebarColor", displayName: "Sidebar Color", type: "value" },
|
||||
{ key: "headerColor", displayName: "Header Color", type: "value" },
|
||||
{ key: "stampText", displayName: "Stamp Text", type: "value" },
|
||||
{ key: "footerText", displayName: "Footer Text", type: "value" },
|
||||
{ key: "supportEmail", displayName: "Support Email", type: "value" },
|
||||
{ key: "supportPhone", displayName: "Support Phone", type: "value" },
|
||||
];
|
||||
|
||||
/** Seeded once per unit. */
|
||||
const UNIT_SETTINGS: SettingDefault[] = [
|
||||
{ key: "isMultipleDelegationAllowed", displayName: "Is Multiple Delegation Allowed", type: "value" },
|
||||
{ key: "internalSuffix", displayName: "Internal Suffix", type: "value" },
|
||||
{ key: "internalPrefix", displayName: "Internal Prefix", type: "value" },
|
||||
{ key: "internalSuffixCC", displayName: "Internal Suffix CC", type: "value" },
|
||||
{ key: "internalPrefixCC", displayName: "Internal Prefix CC", type: "value" },
|
||||
{ key: "referenceNumberPrefix", displayName: "Reference Number Prefix", type: "value" },
|
||||
{ key: "externalReferenceNumberPrefix", displayName: "External Reference Number Prefix", type: "value" },
|
||||
{ key: "internalMemoReferenceNumberPrefix", displayName: "Internal Memo Reference Number Prefix", type: "value" },
|
||||
{ key: "escalationHour", displayName: "Escalation Hour", type: "value" },
|
||||
{ key: "urgentLetterEscalationHour", displayName: "Urgent Letter Escalation Hour", type: "value" },
|
||||
{ key: "onReviewLetterEscalationHour", displayName: "On Review Letter Escalation Hour", type: "value" },
|
||||
{ key: "urgentOnReviewLetterEscalationHour", displayName: "Urgent On Review Letter Escalation Hour", type: "value" },
|
||||
{ key: "shouldCollaboratorAlwaysSign", displayName: "Should Collaborator Always Sign", type: "value" },
|
||||
{ key: "waitAllCollaboratorsBeforeAction", displayName: "Wait All Collaborators Before Action", type: "value" },
|
||||
{ key: "shouldIncludeForYourReferenceInCC", displayName: "Should Include For Your Reference In CC", type: "value" },
|
||||
{ key: "forwardWithTeeterSignature", displayName: "Forward With Teeter Signature", type: "value" },
|
||||
{ key: "attachSignatureOnAttachment", displayName: "Attach Signature On Attachment", type: "value" },
|
||||
{ key: "positionScopeToFetch", displayName: "Position Scope To Fetch", type: "value" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Defaults for every section except `superAdmin`, which has no sensible default
|
||||
* — the account has to be attached to an organization only the consuming app
|
||||
* knows about.
|
||||
*/
|
||||
export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = {
|
||||
applications: APPLICATIONS,
|
||||
roles: ROLES,
|
||||
permissions: PERMISSIONS,
|
||||
rolePermissions: ROLE_PERMISSIONS,
|
||||
positionTypes: POSITION_TYPES,
|
||||
positionTypePermissions: [],
|
||||
organizationTypes: ORGANIZATION_TYPES,
|
||||
organizationSettings: ORGANIZATION_SETTINGS,
|
||||
unitSettings: UNIT_SETTINGS,
|
||||
superAdmin: null,
|
||||
};
|
||||
875
packages/iam-seed/src/iam-baseline.seeder.ts
Normal file
875
packages/iam-seed/src/iam-baseline.seeder.ts
Normal file
@@ -0,0 +1,875 @@
|
||||
import { Inject, Injectable, Logger, Optional } from "@nestjs/common";
|
||||
import * as argon2 from "argon2";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
|
||||
import { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed";
|
||||
import { IAM_SEED_OPTIONS } from "./iam-seed.constants";
|
||||
import {
|
||||
IamBaselineSeed,
|
||||
IamSeedOptions,
|
||||
LocalizedName,
|
||||
SettingDefault,
|
||||
} from "./iam-seed.types";
|
||||
import { missingSettings } from "./missing-settings.util";
|
||||
|
||||
/** Owned by @tria-plc/iamapi-common's migrations; never created here. */
|
||||
const SCHEMA = "iam";
|
||||
|
||||
const DEFAULT_ENABLE_FLAG = "SEED_IAM_BASELINE";
|
||||
|
||||
/**
|
||||
* Advisory lock key, held for the seed transaction. Every app using this package
|
||||
* takes the same key, so two services — or two replicas of one — can never seed
|
||||
* concurrently. Arbitrary constant; nothing else uses it.
|
||||
*/
|
||||
const SEED_LOCK_KEY = 748_231_905;
|
||||
|
||||
/** Postgres caps a statement at 65535 parameters; stay far below it. */
|
||||
const INSERT_CHUNK = 500;
|
||||
|
||||
type KeyedRow = { id: string; key: string };
|
||||
|
||||
/**
|
||||
* Seeds the IAM baseline shared by every app on the `iam` schema, replacing
|
||||
* `DataSeeder` from `@tria-plc/iamapi-common`.
|
||||
*
|
||||
* The schema has more than one writer, rows may already exist partially, and the
|
||||
* apps resolve different versions of the IAM package. Every write is built for
|
||||
* that:
|
||||
*
|
||||
* - **Insert-only.** A row that already exists by key is left exactly as it is —
|
||||
* no name overwrite, and above all no id rewrite, which would break foreign
|
||||
* keys other apps already point at. Drift is logged, not corrected.
|
||||
* - **Ids resolved from the database**, never assumed from the seed constants.
|
||||
* - **`ON CONFLICT DO NOTHING` on every insert**, so a row appearing between the
|
||||
* read and the write is a no-op rather than a crash.
|
||||
* - **An advisory lock** around the whole transaction, shared by all consumers.
|
||||
* - **Nothing is deleted.** The package seeder wipes every
|
||||
* `position_type_permissions` row for the system position types on each run
|
||||
* and nulls their `unit_id`; this one does not.
|
||||
* - **Never fatal.** A failure is logged and boot continues; missing
|
||||
* prerequisites skip that section with a warning.
|
||||
*
|
||||
* Raw SQL throughout, deliberately: importing the package's entity classes would
|
||||
* tie this package to one copy of `@tria-plc/iamapi-common`, and TypeORM matches
|
||||
* entity metadata by class identity — the apps would need the exact same
|
||||
* resolved version forever. Column names come from the package's own migrations.
|
||||
*
|
||||
* Apps call `run()` themselves so it can be ordered against their own seeders.
|
||||
* Runs unless the enable flag (default SEED_IAM_BASELINE) is explicitly turned
|
||||
* off — insert-only makes seeding the safe default.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IamBaselineSeeder {
|
||||
private readonly logger = new Logger(IamBaselineSeeder.name);
|
||||
private readonly seed: IamBaselineSeed;
|
||||
private readonly enableFlag: string;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Optional()
|
||||
@Inject(IAM_SEED_OPTIONS)
|
||||
options?: IamSeedOptions,
|
||||
) {
|
||||
const { enableFlag, ...overrides } = options ?? {};
|
||||
this.enableFlag = enableFlag ?? DEFAULT_ENABLE_FLAG;
|
||||
this.seed = { ...DEFAULT_IAM_BASELINE_SEED, ...overrides };
|
||||
}
|
||||
|
||||
async run() {
|
||||
// Opt-out, not opt-in: an unset flag seeds. Every write is insert-only, so
|
||||
// the safe default is "keep the baseline current" — a new environment that
|
||||
// forgot the variable gets a working IAM rather than an empty one.
|
||||
const flag = process.env[this.enableFlag]?.trim().toLowerCase();
|
||||
if (flag === "false" || flag === "0" || flag === "off") {
|
||||
this.logger.log(
|
||||
`Skipping IAM baseline seed because ${this.enableFlag}=${flag}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
// Never wait forever on a row another service holds: the seed is
|
||||
// optional, boot is not.
|
||||
await manager.query("SET LOCAL lock_timeout = '15s'");
|
||||
|
||||
// Try, don't wait — another service seeding right now is a reason to
|
||||
// skip, not to queue. Released on commit or rollback.
|
||||
const [{ locked }] = (await manager.query(
|
||||
"SELECT pg_try_advisory_xact_lock($1) AS locked",
|
||||
[SEED_LOCK_KEY],
|
||||
)) as [{ locked: boolean }];
|
||||
|
||||
if (!locked) {
|
||||
this.logger.log(
|
||||
"Skipping IAM baseline seed: another service holds the seed lock",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.seedApplications(manager);
|
||||
await this.seedPermissions(manager);
|
||||
await this.seedRoles(manager);
|
||||
await this.seedRolePermissions(manager);
|
||||
await this.seedPositionTypes(manager);
|
||||
await this.seedPositionTypePermissions(manager);
|
||||
await this.seedOrganizationTypes(manager);
|
||||
await this.seedOrganizationSettings(manager);
|
||||
await this.seedUnitSettings(manager);
|
||||
await this.seedSuperAdmin(manager);
|
||||
});
|
||||
|
||||
this.logger.log("IAM baseline seed complete");
|
||||
} catch (error) {
|
||||
// Boot must not depend on the seed: the schema is shared, and a lock
|
||||
// timeout or a row another service wrote first is not worth an outage.
|
||||
this.logger.error(
|
||||
`IAM baseline seed failed, continuing boot: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedApplications(manager: EntityManager) {
|
||||
const { applications } = this.seed;
|
||||
if (applications.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.loadByKey(
|
||||
manager,
|
||||
"application",
|
||||
applications.map((application) => application.key),
|
||||
);
|
||||
this.warnOnIdDrift(applications, existing, "applications");
|
||||
|
||||
const inserted = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"application",
|
||||
["id", "key", "name::jsonb"],
|
||||
applications
|
||||
.filter((application) => !existing.has(application.key))
|
||||
.map((application) => [
|
||||
application.id,
|
||||
application.key,
|
||||
JSON.stringify(application.name),
|
||||
]),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Applications: ${inserted} inserted, ${applications.length - inserted} already present`,
|
||||
);
|
||||
}
|
||||
|
||||
private async seedPermissions(manager: EntityManager) {
|
||||
const { permissions } = this.seed;
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Permissions without an applicationKey stay unlinked (application_id null),
|
||||
// which is how the package ships the org/unit/location ones.
|
||||
const applicationIdByKey = await this.loadIdsByKey(
|
||||
manager,
|
||||
"application",
|
||||
permissions.flatMap((permission) =>
|
||||
permission.applicationKey ? [permission.applicationKey] : [],
|
||||
),
|
||||
"application",
|
||||
);
|
||||
|
||||
const existing = await this.loadByKey(
|
||||
manager,
|
||||
"permissions",
|
||||
permissions.map((permission) => permission.key),
|
||||
);
|
||||
this.warnOnIdDrift(permissions, existing, "permissions");
|
||||
|
||||
const inserted = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"permissions",
|
||||
["id", "key", "name::jsonb", "application_id"],
|
||||
permissions
|
||||
.filter((permission) => !existing.has(permission.key))
|
||||
.map((permission) => [
|
||||
permission.id,
|
||||
permission.key,
|
||||
JSON.stringify(permission.name),
|
||||
permission.applicationKey
|
||||
? (applicationIdByKey.get(permission.applicationKey) ?? null)
|
||||
: null,
|
||||
]),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Permissions: ${inserted} inserted, ${permissions.length - inserted} already present`,
|
||||
);
|
||||
}
|
||||
|
||||
private async seedRoles(manager: EntityManager) {
|
||||
const { roles } = this.seed;
|
||||
if (roles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.loadByKey(
|
||||
manager,
|
||||
"roles",
|
||||
roles.map((role) => role.key),
|
||||
);
|
||||
this.warnOnIdDrift(roles, existing, "roles");
|
||||
|
||||
const inserted = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"roles",
|
||||
["id", "key", "name::jsonb"],
|
||||
roles
|
||||
.filter((role) => !existing.has(role.key))
|
||||
.map((role) => [role.id, role.key, JSON.stringify(role.name)]),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Roles: ${inserted} inserted, ${roles.length - inserted} already present`,
|
||||
);
|
||||
}
|
||||
|
||||
private async seedRolePermissions(manager: EntityManager) {
|
||||
const { rolePermissions } = this.seed;
|
||||
if (rolePermissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleIdByKey = await this.loadIdsByKey(
|
||||
manager,
|
||||
"roles",
|
||||
rolePermissions.map((mapping) => mapping.roleKey),
|
||||
"role",
|
||||
);
|
||||
const permissionIdByKey = await this.loadIdsByKey(
|
||||
manager,
|
||||
"permissions",
|
||||
rolePermissions.flatMap((mapping) => mapping.permissionKeys),
|
||||
"permission",
|
||||
);
|
||||
|
||||
const existingPairs = await this.loadPairs(
|
||||
manager,
|
||||
"role_permissions",
|
||||
"role_id",
|
||||
"permission_id",
|
||||
[...roleIdByKey.values()],
|
||||
);
|
||||
|
||||
const rows = rolePermissions.flatMap((mapping) => {
|
||||
const roleId = roleIdByKey.get(mapping.roleKey);
|
||||
if (!roleId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapping.permissionKeys.flatMap((key) => {
|
||||
const permissionId = permissionIdByKey.get(key);
|
||||
if (!permissionId || existingPairs.has(`${roleId}:${permissionId}`)) {
|
||||
return [];
|
||||
}
|
||||
return [[roleId, permissionId]];
|
||||
});
|
||||
});
|
||||
|
||||
const inserted = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"role_permissions",
|
||||
["role_id", "permission_id"],
|
||||
rows,
|
||||
);
|
||||
if (inserted > 0) {
|
||||
this.logger.log(`Granted ${inserted} role permissions`);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedPositionTypes(manager: EntityManager) {
|
||||
const { positionTypes } = this.seed;
|
||||
if (positionTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.loadByKey(
|
||||
manager,
|
||||
"position_types",
|
||||
positionTypes.map((positionType) => positionType.key),
|
||||
);
|
||||
this.warnOnIdDrift(positionTypes, existing, "position types");
|
||||
|
||||
// unit_id is intentionally left alone — the package seeder resets it to null.
|
||||
const inserted = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"position_types",
|
||||
["id", "key", "name::jsonb", "is_system"],
|
||||
positionTypes
|
||||
.filter((positionType) => !existing.has(positionType.key))
|
||||
.map((positionType) => [
|
||||
positionType.id,
|
||||
positionType.key,
|
||||
JSON.stringify(positionType.name),
|
||||
positionType.isSystem,
|
||||
]),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Position types: ${inserted} inserted, ${positionTypes.length - inserted} already present`,
|
||||
);
|
||||
}
|
||||
|
||||
private async seedPositionTypePermissions(manager: EntityManager) {
|
||||
const { positionTypePermissions } = this.seed;
|
||||
if (positionTypePermissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const positionTypeIdByKey = await this.loadIdsByKey(
|
||||
manager,
|
||||
"position_types",
|
||||
positionTypePermissions.map((mapping) => mapping.positionTypeKey),
|
||||
"position type",
|
||||
);
|
||||
const permissionIdByKey = await this.loadIdsByKey(
|
||||
manager,
|
||||
"permissions",
|
||||
positionTypePermissions.flatMap((mapping) => mapping.permissionKeys),
|
||||
"permission",
|
||||
);
|
||||
|
||||
const existingPairs = await this.loadPairs(
|
||||
manager,
|
||||
"position_type_permissions",
|
||||
"position_type_id",
|
||||
"permission_id",
|
||||
[...positionTypeIdByKey.values()],
|
||||
);
|
||||
|
||||
const rows = positionTypePermissions.flatMap((mapping) => {
|
||||
const positionTypeId = positionTypeIdByKey.get(mapping.positionTypeKey);
|
||||
if (!positionTypeId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapping.permissionKeys.flatMap((key) => {
|
||||
const permissionId = permissionIdByKey.get(key);
|
||||
if (
|
||||
!permissionId ||
|
||||
existingPairs.has(`${positionTypeId}:${permissionId}`)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [[positionTypeId, permissionId]];
|
||||
});
|
||||
});
|
||||
|
||||
const inserted = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"position_type_permissions",
|
||||
["position_type_id", "permission_id"],
|
||||
rows,
|
||||
);
|
||||
if (inserted > 0) {
|
||||
this.logger.log(`Granted ${inserted} position type permissions`);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedOrganizationTypes(manager: EntityManager) {
|
||||
const { organizationTypes } = this.seed;
|
||||
if (organizationTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.loadByKey(
|
||||
manager,
|
||||
"organization_types",
|
||||
organizationTypes.map((organizationType) => organizationType.key),
|
||||
);
|
||||
|
||||
const insertedTypes = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"organization_types",
|
||||
["key", "name::jsonb"],
|
||||
organizationTypes
|
||||
.filter((organizationType) => !existing.has(organizationType.key))
|
||||
.map((organizationType) => [
|
||||
organizationType.key,
|
||||
JSON.stringify(organizationType.name),
|
||||
]),
|
||||
);
|
||||
|
||||
// Re-read: ids are database-generated here, unlike the keyed catalogs above.
|
||||
const typeIdByKey = await this.loadIdsByKey(
|
||||
manager,
|
||||
"organization_types",
|
||||
organizationTypes.map((organizationType) => organizationType.key),
|
||||
"organization type",
|
||||
);
|
||||
|
||||
const existingUnits = await this.loadPairs(
|
||||
manager,
|
||||
"default_units",
|
||||
"organization_type_id",
|
||||
"key",
|
||||
[...typeIdByKey.values()],
|
||||
);
|
||||
|
||||
const unitRows = organizationTypes.flatMap((organizationType) => {
|
||||
const typeId = typeIdByKey.get(organizationType.key);
|
||||
if (!typeId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return organizationType.defaultUnits
|
||||
.filter((unit) => !existingUnits.has(`${typeId}:${unit.key}`))
|
||||
.map((unit) => [
|
||||
unit.key,
|
||||
JSON.stringify(unit.name),
|
||||
unit.description,
|
||||
typeId,
|
||||
]);
|
||||
});
|
||||
|
||||
const insertedUnits = await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"default_units",
|
||||
["key", "name::jsonb", "description", "organization_type_id"],
|
||||
unitRows,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Organization types: ${insertedTypes} inserted, ${insertedUnits} default units inserted`,
|
||||
);
|
||||
}
|
||||
|
||||
private async seedOrganizationSettings(manager: EntityManager) {
|
||||
const defaults = this.seed.organizationSettings;
|
||||
if (defaults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owners = (await manager.query(
|
||||
`SELECT id FROM ${SCHEMA}.organizations`,
|
||||
)) as { id: string }[];
|
||||
|
||||
const inserted = await this.insertOwnerSettings(
|
||||
manager,
|
||||
"organization_settings",
|
||||
"organization_id",
|
||||
owners,
|
||||
defaults,
|
||||
);
|
||||
|
||||
if (inserted > 0) {
|
||||
this.logger.log(
|
||||
`Seeded ${inserted} organization settings across ${owners.length} organizations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedUnitSettings(manager: EntityManager) {
|
||||
const defaults = this.seed.unitSettings;
|
||||
if (defaults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owners = (await manager.query(`SELECT id FROM ${SCHEMA}.units`)) as {
|
||||
id: string;
|
||||
}[];
|
||||
|
||||
const inserted = await this.insertOwnerSettings(
|
||||
manager,
|
||||
"unit_settings",
|
||||
"unit_id",
|
||||
owners,
|
||||
defaults,
|
||||
);
|
||||
|
||||
if (inserted > 0) {
|
||||
this.logger.log(
|
||||
`Seeded ${inserted} unit settings across ${owners.length} units`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings rows missing for each owner. Matched on (owner, key), never on id. */
|
||||
private async insertOwnerSettings(
|
||||
manager: EntityManager,
|
||||
table: string,
|
||||
ownerColumn: string,
|
||||
owners: { id: string }[],
|
||||
defaults: SettingDefault[],
|
||||
): Promise<number> {
|
||||
if (owners.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const existing = (await manager.query(
|
||||
`SELECT "${ownerColumn}" AS owner, key FROM ${SCHEMA}.${table}`,
|
||||
)) as { owner: string; key: string }[];
|
||||
const existingPairs = new Set(
|
||||
existing.map((row) => `${row.owner}:${row.key}`),
|
||||
);
|
||||
|
||||
const rows = owners.flatMap((owner) =>
|
||||
missingSettings(defaults, existingPairs, owner.id).map((setting) => [
|
||||
owner.id,
|
||||
setting.key,
|
||||
setting.displayName,
|
||||
setting.type,
|
||||
setting.value ?? null,
|
||||
]),
|
||||
);
|
||||
|
||||
return this.insertIgnoringConflicts(
|
||||
manager,
|
||||
table,
|
||||
[ownerColumn, "key", "display_name", "type", "value"],
|
||||
rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User + credential + employee + role grant. Every step is skip-if-present: an
|
||||
* existing account keeps its password, its organizations and any extra roles it
|
||||
* was given through the IAM UI. Apps sharing this schema share the account and
|
||||
* each attach their own employee row.
|
||||
*/
|
||||
private async seedSuperAdmin(manager: EntityManager) {
|
||||
const seed = this.seed.superAdmin;
|
||||
if (!seed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail;
|
||||
// SUPER_ADMIN_DEFAULT_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. There
|
||||
// is deliberately no hardcoded default — see SeedSuperAdmin.
|
||||
const password =
|
||||
process.env.SUPER_ADMIN_DEFAULT_PASSWORD?.trim() ||
|
||||
process.env.DEFAULT_PASSWORD?.trim();
|
||||
const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || null;
|
||||
|
||||
const roleId = (
|
||||
await this.loadIdsByKey(manager, "roles", [seed.roleKey], "role")
|
||||
).get(seed.roleKey);
|
||||
if (!roleId) {
|
||||
this.logger.warn(
|
||||
`Skipping super admin: role '${seed.roleKey}' is not in the database`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The organization is optional. Access rides on the role grant, whose
|
||||
// organization_id is nullable, and login does not require an employee — so
|
||||
// the account is created either way and the employee row attaches on a later
|
||||
// run, once the app's own org seeder (gated on its own flag) has run.
|
||||
const [organization] = (await manager.query(
|
||||
`SELECT id FROM ${SCHEMA}.organizations WHERE key = $1 LIMIT 1`,
|
||||
[seed.organizationKey],
|
||||
)) as { id: string }[];
|
||||
|
||||
const organizationId = organization?.id ?? null;
|
||||
if (!organizationId) {
|
||||
this.logger.warn(
|
||||
`Organization '${seed.organizationKey}' does not exist yet: seeding the super admin without an employee record`,
|
||||
);
|
||||
}
|
||||
|
||||
let unitId: string | null = null;
|
||||
if (seed.unitKey && organizationId) {
|
||||
const [unit] = (await manager.query(
|
||||
`SELECT id FROM ${SCHEMA}.units WHERE key = $1 AND organization_id = $2 LIMIT 1`,
|
||||
[seed.unitKey, organizationId],
|
||||
)) as { id: string }[];
|
||||
|
||||
if (!unit) {
|
||||
this.logger.warn(
|
||||
`Super admin unit '${seed.unitKey}' not found, attaching without a unit`,
|
||||
);
|
||||
}
|
||||
unitId = unit?.id ?? null;
|
||||
}
|
||||
|
||||
const userId = await this.ensureSuperAdminUser(manager, {
|
||||
username: seed.username,
|
||||
email,
|
||||
phoneNumber,
|
||||
name: seed.name,
|
||||
});
|
||||
if (!userId) {
|
||||
this.logger.warn("Skipping super admin: could not resolve the user row");
|
||||
return;
|
||||
}
|
||||
|
||||
const [credential] = (await manager.query(
|
||||
`SELECT id FROM ${SCHEMA}.user_credentials WHERE user_id = $1 LIMIT 1`,
|
||||
[userId],
|
||||
)) as { id: string }[];
|
||||
|
||||
if (!credential && !password) {
|
||||
this.logger.warn(
|
||||
`Super admin '${seed.username}' has no credential: set SUPER_ADMIN_DEFAULT_PASSWORD or DEFAULT_PASSWORD, or set the password through the IAM reset flow`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!credential && password) {
|
||||
await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"user_credentials",
|
||||
["user_id", "password", "is_active"],
|
||||
[[userId, await argon2.hash(password), true]],
|
||||
);
|
||||
this.logger.log(`Seeded super admin credential for '${seed.username}'`);
|
||||
}
|
||||
|
||||
const [employee] = organizationId
|
||||
? ((await manager.query(
|
||||
`SELECT id FROM ${SCHEMA}.employees WHERE user_id = $1 AND organization_id = $2 LIMIT 1`,
|
||||
[userId, organizationId],
|
||||
)) as { id: string }[])
|
||||
: [undefined];
|
||||
|
||||
if (organizationId && !employee) {
|
||||
await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"employees",
|
||||
[
|
||||
"user_id",
|
||||
"organization_id",
|
||||
"unit_id",
|
||||
"is_current",
|
||||
"status",
|
||||
"name::jsonb",
|
||||
],
|
||||
[
|
||||
[
|
||||
userId,
|
||||
organizationId,
|
||||
unitId,
|
||||
true,
|
||||
"accepted",
|
||||
JSON.stringify(seed.name),
|
||||
],
|
||||
],
|
||||
);
|
||||
this.logger.log(
|
||||
`Attached super admin to organization '${seed.organizationKey}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// user_roles is UNIQUE (user_id, role_id), so a concurrent grant is a no-op.
|
||||
await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"user_roles",
|
||||
["user_id", "role_id", "organization_id", "unit_id"],
|
||||
[[userId, roleId, organizationId, unitId]],
|
||||
);
|
||||
|
||||
this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The super-admin user row, whether we create it or another service already
|
||||
* did. Matches on username OR email because either is enough to make the
|
||||
* insert fail on its unique index.
|
||||
*/
|
||||
private async ensureSuperAdminUser(
|
||||
manager: EntityManager,
|
||||
account: {
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
name: LocalizedName;
|
||||
},
|
||||
): Promise<string | undefined> {
|
||||
const find = async () => {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT id FROM ${SCHEMA}.users WHERE username = $1 OR email = $2 LIMIT 1`,
|
||||
[account.username, account.email],
|
||||
)) as { id: string }[];
|
||||
return row?.id;
|
||||
};
|
||||
|
||||
const existing = await find();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await this.insertIgnoringConflicts(
|
||||
manager,
|
||||
"users",
|
||||
[
|
||||
"username",
|
||||
"email",
|
||||
"phone_number",
|
||||
"name::jsonb",
|
||||
"user_type",
|
||||
"status",
|
||||
"is_active",
|
||||
"has_set_password",
|
||||
],
|
||||
[
|
||||
[
|
||||
account.username,
|
||||
account.email,
|
||||
account.phoneNumber,
|
||||
JSON.stringify(account.name),
|
||||
"employee",
|
||||
"accepted",
|
||||
true,
|
||||
true,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Re-read rather than trusting the insert: the row may have been skipped
|
||||
// because another service created it a moment earlier.
|
||||
const created = await find();
|
||||
if (created) {
|
||||
this.logger.log(
|
||||
`Seeded super admin user '${account.username}' (${account.email})`,
|
||||
);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report seed rows whose stored id differs from ours — a sign this environment
|
||||
* was seeded by something else, and the reason links are resolved by key.
|
||||
*/
|
||||
private warnOnIdDrift(
|
||||
rows: { id: string; key: string }[],
|
||||
existing: Map<string, KeyedRow>,
|
||||
label: string,
|
||||
) {
|
||||
const drifted = rows.filter((row) => {
|
||||
const stored = existing.get(row.key);
|
||||
return stored && stored.id !== row.id;
|
||||
});
|
||||
|
||||
if (drifted.length > 0) {
|
||||
this.logger.warn(
|
||||
`${drifted.length} ${label} exist under a different id than the seed (left untouched): ${drifted
|
||||
.map((row) => row.key)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT … ON CONFLICT DO NOTHING`, chunked. Every table here is written by
|
||||
* more than one service, so losing a race must cost nothing.
|
||||
*
|
||||
* A column spec may carry a cast for non-text types — `"name::jsonb"`.
|
||||
*/
|
||||
private async insertIgnoringConflicts(
|
||||
manager: EntityManager,
|
||||
table: string,
|
||||
columnSpecs: string[],
|
||||
rows: unknown[][],
|
||||
): Promise<number> {
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const columns = columnSpecs.map((spec) => spec.split("::")[0]);
|
||||
const casts = columnSpecs.map((spec) => {
|
||||
const [, cast] = spec.split("::");
|
||||
return cast ? `::${cast}` : "";
|
||||
});
|
||||
const columnList = columns.map((column) => `"${column}"`).join(", ");
|
||||
|
||||
let inserted = 0;
|
||||
for (let start = 0; start < rows.length; start += INSERT_CHUNK) {
|
||||
const chunk = rows.slice(start, start + INSERT_CHUNK);
|
||||
const params: unknown[] = [];
|
||||
const tuples = chunk.map((row) => {
|
||||
const placeholders = row.map((value, columnIndex) => {
|
||||
params.push(value);
|
||||
return `$${params.length}${casts[columnIndex]}`;
|
||||
});
|
||||
return `(${placeholders.join(", ")})`;
|
||||
});
|
||||
|
||||
const result = (await manager.query(
|
||||
`INSERT INTO ${SCHEMA}.${table} (${columnList}) VALUES ${tuples.join(", ")} ON CONFLICT DO NOTHING RETURNING id`,
|
||||
params,
|
||||
)) as unknown[];
|
||||
|
||||
inserted += Array.isArray(result) ? result.length : 0;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/** Existing rows for the given keys, by key. */
|
||||
private async loadByKey(
|
||||
manager: EntityManager,
|
||||
table: string,
|
||||
keys: string[],
|
||||
): Promise<Map<string, KeyedRow>> {
|
||||
const wanted = [...new Set(keys)];
|
||||
if (wanted.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = (await manager.query(
|
||||
`SELECT id, key FROM ${SCHEMA}.${table} WHERE key = ANY($1)`,
|
||||
[wanted],
|
||||
)) as KeyedRow[];
|
||||
|
||||
return new Map(rows.map((row) => [row.key, row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `key → id`. Keys with no row are warned about and left out: whatever
|
||||
* references them is skipped rather than failing the whole seed, since another
|
||||
* service may own that row.
|
||||
*/
|
||||
private async loadIdsByKey(
|
||||
manager: EntityManager,
|
||||
table: string,
|
||||
keys: string[],
|
||||
label: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const wanted = [...new Set(keys)];
|
||||
const rows = await this.loadByKey(manager, table, wanted);
|
||||
const idByKey = new Map(
|
||||
[...rows.values()].map((row) => [row.key, row.id] as [string, string]),
|
||||
);
|
||||
|
||||
const missing = wanted.filter((key) => !idByKey.has(key));
|
||||
if (missing.length > 0) {
|
||||
this.logger.warn(
|
||||
`Unresolved ${label} keys, anything referencing them is skipped: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return idByKey;
|
||||
}
|
||||
|
||||
/** Existing `left:right` pairs of a link table, for the given left-hand ids. */
|
||||
private async loadPairs(
|
||||
manager: EntityManager,
|
||||
table: string,
|
||||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
if (leftIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = (await manager.query(
|
||||
`SELECT "${leftColumn}" AS left_value, "${rightColumn}" AS right_value
|
||||
FROM ${SCHEMA}.${table} WHERE "${leftColumn}" = ANY($1)`,
|
||||
[leftIds],
|
||||
)) as { left_value: string; right_value: string }[];
|
||||
|
||||
return new Set(rows.map((row) => `${row.left_value}:${row.right_value}`));
|
||||
}
|
||||
}
|
||||
2
packages/iam-seed/src/iam-seed.constants.ts
Normal file
2
packages/iam-seed/src/iam-seed.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */
|
||||
export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS");
|
||||
39
packages/iam-seed/src/iam-seed.module.ts
Normal file
39
packages/iam-seed/src/iam-seed.module.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DynamicModule, Module } from "@nestjs/common";
|
||||
|
||||
import { IamBaselineSeeder } from "./iam-baseline.seeder";
|
||||
import { IAM_SEED_OPTIONS } from "./iam-seed.constants";
|
||||
import { IamSeedOptions } from "./iam-seed.types";
|
||||
|
||||
/**
|
||||
* Provides `IamBaselineSeeder`. The app calls `run()` itself — usually from
|
||||
* `onApplicationBootstrap`, after whatever seeder creates the organization the
|
||||
* super admin attaches to.
|
||||
*
|
||||
* IamSeedModule.forRoot({
|
||||
* superAdmin: {
|
||||
* username: "superadmin",
|
||||
* name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
* roleKey: "super_admin",
|
||||
* organizationKey: "edr_freight",
|
||||
* unitKey: "edr_freight_app",
|
||||
* fallbackEmail: "superadmin@tria.com",
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* Any section left out keeps the value from `DEFAULT_IAM_BASELINE_SEED`. The
|
||||
* seeder needs a TypeORM `DataSource` that can reach the `iam` schema; the app's
|
||||
* default one is used, so `TypeOrmModule.forRoot*` must be registered.
|
||||
*/
|
||||
@Module({})
|
||||
export class IamSeedModule {
|
||||
static forRoot(options: IamSeedOptions = {}): DynamicModule {
|
||||
return {
|
||||
module: IamSeedModule,
|
||||
providers: [
|
||||
{ provide: IAM_SEED_OPTIONS, useValue: options },
|
||||
IamBaselineSeeder,
|
||||
],
|
||||
exports: [IamBaselineSeeder],
|
||||
};
|
||||
}
|
||||
}
|
||||
100
packages/iam-seed/src/iam-seed.types.ts
Normal file
100
packages/iam-seed/src/iam-seed.types.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type LocalizedName = { am: string; en: string };
|
||||
|
||||
export type SeedApplication = { id: string; key: string; name: LocalizedName };
|
||||
|
||||
export type SeedRole = { id: string; key: string; name: LocalizedName };
|
||||
|
||||
export type SeedPermission = {
|
||||
id: string;
|
||||
key: string;
|
||||
/** Omitted for the org/unit/location permissions the package leaves unlinked. */
|
||||
applicationKey?: string;
|
||||
name: LocalizedName;
|
||||
};
|
||||
|
||||
export type SeedPositionType = {
|
||||
id: string;
|
||||
key: string;
|
||||
isSystem: boolean;
|
||||
name: LocalizedName;
|
||||
};
|
||||
|
||||
export type SeedRolePermission = { roleKey: string; permissionKeys: string[] };
|
||||
|
||||
export type SeedPositionTypePermission = {
|
||||
positionTypeKey: string;
|
||||
permissionKeys: string[];
|
||||
};
|
||||
|
||||
export type SeedDefaultUnit = {
|
||||
key: string;
|
||||
description: string;
|
||||
name: LocalizedName;
|
||||
};
|
||||
|
||||
export type SeedOrganizationType = {
|
||||
key: string;
|
||||
name: LocalizedName;
|
||||
/** Units an organization of this type is created with. */
|
||||
defaultUnits: SeedDefaultUnit[];
|
||||
};
|
||||
|
||||
export type SettingDefault = {
|
||||
key: string;
|
||||
displayName: string;
|
||||
/** Matches the package's ESettingType. */
|
||||
type: "value" | "file";
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The super-admin account. Its access comes from the `super_admin` role grant,
|
||||
* so the account needs no permissions of its own.
|
||||
*
|
||||
* One account is shared by every app on this schema: whichever seeds first
|
||||
* creates the user, the others find it and attach their own employee row for
|
||||
* their organization.
|
||||
*
|
||||
* Email and phone are read from the environment at seed time —
|
||||
* SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE — falling back to `fallbackEmail`.
|
||||
*
|
||||
* The password has NO fallback, by design. It comes from SUPER_ADMIN_DEFAULT_PASSWORD,
|
||||
* or DEFAULT_PASSWORD if that is unset. With neither set, the account is still
|
||||
* created and granted its role but gets no credential, and the seeder says so —
|
||||
* the password is then set through the IAM reset flow. A seeded default would
|
||||
* otherwise become a known password in whatever environment forgot to override
|
||||
* it.
|
||||
*/
|
||||
export type SeedSuperAdmin = {
|
||||
username: string;
|
||||
name: LocalizedName;
|
||||
/** Key in `roles` — the grant that carries the access. */
|
||||
roleKey: string;
|
||||
/** Existing organization to attach the employee to. Must already exist. */
|
||||
organizationKey: string;
|
||||
/** Optional unit within that organization; the employee is unit-less without it. */
|
||||
unitKey?: string;
|
||||
fallbackEmail: string;
|
||||
};
|
||||
|
||||
export type IamBaselineSeed = {
|
||||
applications: SeedApplication[];
|
||||
roles: SeedRole[];
|
||||
permissions: SeedPermission[];
|
||||
rolePermissions: SeedRolePermission[];
|
||||
positionTypes: SeedPositionType[];
|
||||
positionTypePermissions: SeedPositionTypePermission[];
|
||||
organizationTypes: SeedOrganizationType[];
|
||||
organizationSettings: SettingDefault[];
|
||||
unitSettings: SettingDefault[];
|
||||
superAdmin: SeedSuperAdmin | null;
|
||||
};
|
||||
|
||||
/** Per-section override passed to `IamSeedModule.forRoot()`. */
|
||||
export type IamSeedOptions = Partial<IamBaselineSeed> & {
|
||||
/**
|
||||
* Environment variable that must equal "true" for the seeder to write
|
||||
* anything. Defaults to SEED_IAM_BASELINE.
|
||||
*/
|
||||
enableFlag?: string;
|
||||
};
|
||||
20
packages/iam-seed/src/index.ts
Normal file
20
packages/iam-seed/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { IamSeedModule } from "./iam-seed.module";
|
||||
export { IamBaselineSeeder } from "./iam-baseline.seeder";
|
||||
export { IAM_SEED_OPTIONS } from "./iam-seed.constants";
|
||||
export { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed";
|
||||
export { missingSettings } from "./missing-settings.util";
|
||||
export type {
|
||||
IamBaselineSeed,
|
||||
IamSeedOptions,
|
||||
LocalizedName,
|
||||
SeedApplication,
|
||||
SeedDefaultUnit,
|
||||
SeedOrganizationType,
|
||||
SeedPermission,
|
||||
SeedPositionType,
|
||||
SeedPositionTypePermission,
|
||||
SeedRole,
|
||||
SeedRolePermission,
|
||||
SeedSuperAdmin,
|
||||
SettingDefault,
|
||||
} from "./iam-seed.types";
|
||||
27
packages/iam-seed/src/missing-settings.util.spec.ts
Normal file
27
packages/iam-seed/src/missing-settings.util.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { missingSettings } from "./missing-settings.util";
|
||||
|
||||
const defaults = [
|
||||
{ key: "primaryColor", displayName: "Primary Color", value: null },
|
||||
{ key: "logoFileUrl", displayName: "Logo", value: null },
|
||||
];
|
||||
|
||||
describe("missingSettings", () => {
|
||||
it("returns every default when the owner has none", () => {
|
||||
expect(missingSettings(defaults, new Set(), "org-1")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips keys the owner has, but not the same key on another owner", () => {
|
||||
const existing = new Set(["org-1:primaryColor"]);
|
||||
|
||||
expect(
|
||||
missingSettings(defaults, existing, "org-1").map((s) => s.key),
|
||||
).toEqual(["logoFileUrl"]);
|
||||
expect(missingSettings(defaults, existing, "org-2")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops nulls so the column default applies", () => {
|
||||
expect(
|
||||
missingSettings(defaults, new Set(), "org-1")[0].value,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
20
packages/iam-seed/src/missing-settings.util.ts
Normal file
20
packages/iam-seed/src/missing-settings.util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Settings of `ownerId` that are not in `existingPairs` (`"<ownerId>:<key>"`)
|
||||
* yet. This is what keeps the IAM baseline seed idempotent: `organization_settings`
|
||||
* and `unit_settings` have no unique index on (owner, key), so a plain re-insert
|
||||
* — or the package seeder's upsert-on-id, which never supplies an id — silently
|
||||
* duplicates every row.
|
||||
*
|
||||
* `value: null` becomes `undefined` so the column default applies on insert.
|
||||
*/
|
||||
export function missingSettings<
|
||||
TSetting extends { key: string; value?: string | null },
|
||||
>(
|
||||
defaults: TSetting[],
|
||||
existingPairs: Set<string>,
|
||||
ownerId: string,
|
||||
): (Omit<TSetting, "value"> & { value?: string })[] {
|
||||
return defaults
|
||||
.filter((setting) => !existingPairs.has(`${ownerId}:${setting.key}`))
|
||||
.map((setting) => ({ ...setting, value: setting.value ?? undefined }));
|
||||
}
|
||||
11
packages/iam-seed/tsconfig.json
Normal file
11
packages/iam-seed/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/nestjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -45,6 +45,9 @@ importers:
|
||||
'@edr/api-common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-common
|
||||
'@edr/iam-seed':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/iam-seed
|
||||
'@edr/payment-providers':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/payment-providers
|
||||
@@ -754,6 +757,9 @@ importers:
|
||||
|
||||
apps/edr-passenger-api:
|
||||
dependencies:
|
||||
'@edr/iam-seed':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/iam-seed
|
||||
'@edr/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
@@ -1292,6 +1298,43 @@ importers:
|
||||
|
||||
packages/config/tsconfig: {}
|
||||
|
||||
packages/iam-seed:
|
||||
dependencies:
|
||||
argon2:
|
||||
specifier: ^0.43.1
|
||||
version: 0.43.1
|
||||
devDependencies:
|
||||
'@edr/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../config/eslint-config
|
||||
'@edr/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../config/tsconfig
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@types/jest':
|
||||
specifier: ^29.5.13
|
||||
version: 29.5.14
|
||||
'@types/node':
|
||||
specifier: ^20.14.0
|
||||
version: 20.19.42
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
ts-jest:
|
||||
specifier: ^29.2.5
|
||||
version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
typeorm:
|
||||
specifier: ^0.3.20
|
||||
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
|
||||
packages/payment-providers:
|
||||
dependencies:
|
||||
'@edr/types':
|
||||
|
||||
Reference in New Issue
Block a user