mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Merge pull request #763 from Tria-plc/intercity-load-unload
Intercity load unload Data model — yards.has_facility, yard_facilities, facility_handling_events, resolver, seeder The gate — intercity load/unload refused where there's no facility GRN raised on every facility load/unload; storage only at Indode Yards config toggle — adding a facility is now a toggle, not a code change
This commit is contained in:
@@ -59,6 +59,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
|||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
import { PaymentModule } from "./modules/payment/payment.module";
|
||||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
|
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||||
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||||
@@ -196,6 +197,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
FreightPositionsSeeder,
|
FreightPositionsSeeder,
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
|
YardFacilitiesSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||||
// DemoUsersSeeder,
|
// DemoUsersSeeder,
|
||||||
@@ -221,6 +223,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
|
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
@@ -258,6 +261,10 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// File upload settings — keep enabled.
|
// File upload settings — keep enabled.
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
|
|
||||||
|
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||||
|
// Dire Dawa). Idempotent; creates no yards.
|
||||||
|
await this.yardFacilitiesSeeder.run();
|
||||||
|
|
||||||
// Dropdown settings are not seeded on boot; run them with
|
// Dropdown settings are not seeded on boot; run them with
|
||||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||||
|
|
||||||
|
|||||||
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
|
||||||
|
*
|
||||||
|
* 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 {
|
||||||
|
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||||
|
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its
|
||||||
|
* destination yard, but only some yards have the equipment to do it. EDR's
|
||||||
|
* load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad —
|
||||||
|
* and the set grows, so it must be data, not a constant.
|
||||||
|
*
|
||||||
|
* `yards.has_facility` marks a yard as a load/unload point; `yard_facilities`
|
||||||
|
* holds what that facility can do. Only a facility with `has_warehouse` (Indode
|
||||||
|
* today) stores cargo, and therefore accrues storage/demurrage — the rest just
|
||||||
|
* move it on and off the train.
|
||||||
|
*
|
||||||
|
* `facility_handling_events` records each load/unload and carries its GRN.
|
||||||
|
* warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so
|
||||||
|
* a facility with no warehouse could never have a row. `inventory_id` links to the
|
||||||
|
* storage record when the facility does have a warehouse.
|
||||||
|
*/
|
||||||
|
export class YardFacilities2290000000000 implements MigrationInterface {
|
||||||
|
name = 'YardFacilities2290000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yards
|
||||||
|
ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.yard_facilities (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||||
|
has_warehouse boolean NOT NULL DEFAULT false,
|
||||||
|
equipment_notes text NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
// One facility record per yard.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard"
|
||||||
|
ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.facility_handling_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
|
||||||
|
yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||||
|
train_schedule_id uuid NULL REFERENCES freight.train_schedules(id),
|
||||||
|
event_type varchar(10) NOT NULL,
|
||||||
|
grn_number varchar(60) NULL,
|
||||||
|
quantity numeric(14, 3) NULL,
|
||||||
|
weight_tons numeric(14, 3) NULL,
|
||||||
|
inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id),
|
||||||
|
performed_by varchar(120) NULL,
|
||||||
|
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking"
|
||||||
|
ON freight.facility_handling_events (booking_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard"
|
||||||
|
ON freight.facility_handling_events (yard_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn"
|
||||||
|
ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,15 @@ export class CreateYardDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'This yard can load/unload cargo. Intercity bookings may only be loaded at their origin and unloaded at their destination when it is a facility.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasFacility?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from './yard.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a yard's load/unload facility can do. One record per yard flagged
|
||||||
|
* `has_facility`.
|
||||||
|
*
|
||||||
|
* `hasWarehouse` is the line that matters: a facility with a warehouse (Indode
|
||||||
|
* today) stores cargo and therefore accrues storage/demurrage through the normal
|
||||||
|
* warehouse flow; the rest only move cargo on and off the train, so they record
|
||||||
|
* the handling event and its GRN and nothing else.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'yard_facilities' })
|
||||||
|
@Index(['yardId'])
|
||||||
|
export class YardFacility extends BaseEntity {
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
@OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'yard_id' })
|
||||||
|
yard?: Yard;
|
||||||
|
|
||||||
|
/** Cargo can be stored here — enables the warehouse flow (storage, demurrage). */
|
||||||
|
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||||
|
hasWarehouse!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||||
|
equipmentNotes?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
@@ -22,6 +22,14 @@ export class Yard extends BaseEntity {
|
|||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This yard has the equipment to load/unload cargo. Intercity bookings can only
|
||||||
|
* be loaded at their origin and unloaded at their destination where this is
|
||||||
|
* true. What the facility can do lives on the YardFacility record.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'has_facility', type: 'boolean', default: false })
|
||||||
|
hasFacility!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'display_order', type: 'int', default: 1 })
|
@Column({ name: 'display_order', type: 'int', default: 1 })
|
||||||
displayOrder!: number;
|
displayOrder!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ServiceType } from './entities/service-type.entity';
|
|||||||
import { ShippingLine } from './entities/shipping-line.entity';
|
import { ShippingLine } from './entities/shipping-line.entity';
|
||||||
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
||||||
import { Yard } from './entities/yard.entity';
|
import { Yard } from './entities/yard.entity';
|
||||||
|
import { YardFacility } from './entities/yard-facility.entity';
|
||||||
|
|
||||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
||||||
@@ -57,6 +58,7 @@ import { ServiceTypesService } from './services/service-types.service';
|
|||||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||||
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
||||||
import { YardsService } from './services/yards.service';
|
import { YardsService } from './services/yards.service';
|
||||||
|
import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||||
|
|
||||||
import { RuleEngineService } from './rule-engine.service';
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ServiceType,
|
ServiceType,
|
||||||
WeightLimitRule,
|
WeightLimitRule,
|
||||||
Yard,
|
Yard,
|
||||||
|
YardFacility,
|
||||||
ShippingLine,
|
ShippingLine,
|
||||||
Rate,
|
Rate,
|
||||||
ApprovalRule,
|
ApprovalRule,
|
||||||
@@ -130,6 +133,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ServiceTypesService,
|
ServiceTypesService,
|
||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
YardsService,
|
YardsService,
|
||||||
|
YardFacilitiesService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
@@ -144,6 +148,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
PriorityConfigsService,
|
PriorityConfigsService,
|
||||||
YardsService,
|
YardsService,
|
||||||
|
YardFacilitiesService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/** A yard's load/unload capability, resolved for the handling flows. */
|
||||||
|
export interface YardFacilityInfo {
|
||||||
|
yardId: string;
|
||||||
|
yardCode: string | null;
|
||||||
|
yardLabel: string | null;
|
||||||
|
/** The yard can load/unload cargo at all. */
|
||||||
|
hasFacility: boolean;
|
||||||
|
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
||||||
|
hasWarehouse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which yards can handle cargo, and how.
|
||||||
|
*
|
||||||
|
* A yard is a load/unload point when `yards.has_facility` is set; the matching
|
||||||
|
* `yard_facilities` record says whether it also stores cargo. Facilities without a
|
||||||
|
* warehouse move cargo on and off the train and nothing more — no storage, no
|
||||||
|
* demurrage. This is the single resolver the journey and handling flows use, so
|
||||||
|
* they can't drift on what a facility is.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class YardFacilitiesService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||||
|
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||||
|
const [row]: Array<{
|
||||||
|
yardId: string;
|
||||||
|
yardCode: string | null;
|
||||||
|
yardLabel: string | null;
|
||||||
|
hasFacility: boolean;
|
||||||
|
hasWarehouse: boolean | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT y.id AS "yardId",
|
||||||
|
y.code AS "yardCode",
|
||||||
|
y.label AS "yardLabel",
|
||||||
|
y.has_facility AS "hasFacility",
|
||||||
|
f.has_warehouse AS "hasWarehouse"
|
||||||
|
FROM freight.yards y
|
||||||
|
LEFT JOIN freight.yard_facilities f
|
||||||
|
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||||
|
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||||
|
[yardId],
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
yardId: row.yardId,
|
||||||
|
yardCode: row.yardCode,
|
||||||
|
yardLabel: row.yardLabel,
|
||||||
|
hasFacility: Boolean(row.hasFacility),
|
||||||
|
// No facility record means no warehouse, whatever the flag says.
|
||||||
|
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
||||||
|
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
||||||
|
const rows: Array<{
|
||||||
|
yardId: string;
|
||||||
|
yardCode: string | null;
|
||||||
|
yardLabel: string | null;
|
||||||
|
hasFacility: boolean;
|
||||||
|
hasWarehouse: boolean | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT y.id AS "yardId",
|
||||||
|
y.code AS "yardCode",
|
||||||
|
y.label AS "yardLabel",
|
||||||
|
y.has_facility AS "hasFacility",
|
||||||
|
f.has_warehouse AS "hasWarehouse"
|
||||||
|
FROM freight.yards y
|
||||||
|
LEFT JOIN freight.yard_facilities f
|
||||||
|
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||||
|
WHERE y.deleted_at IS NULL
|
||||||
|
AND y.is_active = true
|
||||||
|
AND y.has_facility = true
|
||||||
|
ORDER BY y.display_order ASC, y.label ASC`,
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
yardId: r.yardId,
|
||||||
|
yardCode: r.yardCode,
|
||||||
|
yardLabel: r.yardLabel,
|
||||||
|
hasFacility: true,
|
||||||
|
hasWarehouse: Boolean(r.hasWarehouse),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export class YardsService {
|
|||||||
label: dto.label,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
|
hasFacility: dto.hasFacility ?? false,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource, EntityManager, In } from 'typeorm';
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
||||||
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -43,6 +45,8 @@ export class BookingJourneyService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly yardFacilities: YardFacilitiesService,
|
||||||
|
private readonly facilityHandling: FacilityHandlingService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -63,6 +67,7 @@ export class BookingJourneyService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||||
|
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -72,6 +77,16 @@ export class BookingJourneyService {
|
|||||||
loadedByUserId: userId ?? null,
|
loadedByUserId: userId ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||||
|
// The facility handed the cargo over — raise its GRN. No-ops for yards
|
||||||
|
// without a facility (import/export terminals), which keep their own flow.
|
||||||
|
await this.facilityHandling.recordHandling(manager, {
|
||||||
|
booking,
|
||||||
|
yardId: booking.originYardId,
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
eventType: 'LOAD',
|
||||||
|
performedBy: userId ?? null,
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Customer tracking: cargo is on the train — loading milestones plus the
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||||
@@ -99,6 +114,7 @@ export class BookingJourneyService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||||
|
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||||
|
|
||||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||||
// export continue into clearance, keyed on the booking's own arrival.
|
// export continue into clearance, keyed on the booking's own arrival.
|
||||||
@@ -112,6 +128,17 @@ export class BookingJourneyService {
|
|||||||
} as never);
|
} as never);
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
||||||
|
// The facility took the cargo off the train — raise its GRN. Where the
|
||||||
|
// facility also stores cargo (Indode), the event links the storage record
|
||||||
|
// that storage/demurrage accrue against.
|
||||||
|
await this.facilityHandling.recordHandling(manager, {
|
||||||
|
booking,
|
||||||
|
yardId: booking.destinationYardId,
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
eventType: 'UNLOAD',
|
||||||
|
performedBy: userId ?? null,
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||||
@@ -306,6 +333,33 @@ export class BookingJourneyService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the
|
||||||
|
* booking's own yards, so those yards need the equipment to do it — a train
|
||||||
|
* stopping somewhere is not the same as somewhere being able to load it.
|
||||||
|
*
|
||||||
|
* Import/export are untouched: their cargo is handled at the route's terminal
|
||||||
|
* ports, not at an arbitrary mid-corridor yard, and gating them here would
|
||||||
|
* block existing traffic.
|
||||||
|
*
|
||||||
|
* Lives here rather than in the controller so the checkpoint-driven
|
||||||
|
* autoUnloadAtYard path cannot route around it.
|
||||||
|
*/
|
||||||
|
private async assertYardCanHandleCargo(
|
||||||
|
booking: Booking,
|
||||||
|
yardId: string,
|
||||||
|
side: 'origin' | 'destination',
|
||||||
|
): Promise<void> {
|
||||||
|
if (booking.tradeDirection !== 'DOMESTIC') return;
|
||||||
|
const facility = await this.yardFacilities.facilityForYard(yardId);
|
||||||
|
if (!facility?.hasFacility) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
|
||||||
|
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||||
* or — for a booking boarding at the train's own origin — when the train has
|
* or — for a booking boarding at the train's own origin — when the train has
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
|
export const FACILITY_HANDLING_EVENT_TYPES = ['LOAD', 'UNLOAD'] as const;
|
||||||
|
export type FacilityHandlingEventType = (typeof FACILITY_HANDLING_EVENT_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo loaded onto or unloaded off a train at a yard's facility, and the GRN
|
||||||
|
* raised for it.
|
||||||
|
*
|
||||||
|
* This exists because warehouse_inventory can't do the job: its
|
||||||
|
* warehouse/yard/zone are NOT NULL, so a facility that only has equipment and no
|
||||||
|
* warehouse (Sebeta, Modjo, Adama, Dire Dawa) could never have a row there —
|
||||||
|
* yet it still hands cargo over and still needs a GRN.
|
||||||
|
*
|
||||||
|
* `inventoryId` links to the warehouse record when the facility does store cargo
|
||||||
|
* (Indode), which is what makes storage and demurrage accrue there and nowhere
|
||||||
|
* else.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'facility_handling_events' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
@Index(['yardId'])
|
||||||
|
export class FacilityHandlingEvent extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
/** The facility yard where the cargo was handled. */
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
/** The train the cargo came off / went onto. */
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'event_type', type: 'varchar', length: 10 })
|
||||||
|
eventType!: FacilityHandlingEventType;
|
||||||
|
|
||||||
|
@Column({ name: 'grn_number', type: 'varchar', length: 60, nullable: true })
|
||||||
|
grnNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
quantity?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
weightTons?: number | null;
|
||||||
|
|
||||||
|
/** Set only when the facility stores cargo (has_warehouse) — the storage record. */
|
||||||
|
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
|
||||||
|
inventoryId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||||
|
performedBy?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
|
occurredAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
import { generateGrnNumber } from '../../common/grn.util';
|
||||||
|
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import {
|
||||||
|
FacilityHandlingEvent,
|
||||||
|
FacilityHandlingEventType,
|
||||||
|
} from './entities/facility-handling-event.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records cargo being loaded/unloaded at a yard's facility, and raises its GRN.
|
||||||
|
*
|
||||||
|
* Every facility raises a GRN — the goods changed hands, whether or not anyone
|
||||||
|
* stores them. What differs is what happens next: a facility with a warehouse
|
||||||
|
* (Indode) keeps the cargo, so it goes through the normal warehouse flow and
|
||||||
|
* accrues storage/demurrage; the rest only move it between train and truck, so
|
||||||
|
* the event and its GRN are the whole record.
|
||||||
|
*
|
||||||
|
* Best-effort by design: a failure here must not undo a load/unload that
|
||||||
|
* physically happened.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FacilityHandlingService {
|
||||||
|
private readonly logger = new Logger(FacilityHandlingService.name);
|
||||||
|
|
||||||
|
constructor(private readonly yardFacilities: YardFacilitiesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the handling event and mint its GRN. Returns the GRN, or null when the
|
||||||
|
* yard has no facility (nothing to record) or the write failed.
|
||||||
|
*/
|
||||||
|
async recordHandling(
|
||||||
|
manager: EntityManager,
|
||||||
|
input: {
|
||||||
|
booking: Booking;
|
||||||
|
yardId: string;
|
||||||
|
trainScheduleId?: string | null;
|
||||||
|
eventType: FacilityHandlingEventType;
|
||||||
|
performedBy?: string | null;
|
||||||
|
occurredAt?: Date;
|
||||||
|
},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { booking, yardId, eventType } = input;
|
||||||
|
try {
|
||||||
|
const facility = await this.yardFacilities.facilityForYard(yardId);
|
||||||
|
if (!facility?.hasFacility) return null;
|
||||||
|
|
||||||
|
const occurredAt = input.occurredAt ?? new Date();
|
||||||
|
const grnNumber = generateGrnNumber(
|
||||||
|
booking.tradeDirection ?? 'DOMESTIC',
|
||||||
|
booking.id,
|
||||||
|
occurredAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link the storage record when this facility keeps cargo — that link is
|
||||||
|
// what ties an Indode handover to its storage/demurrage.
|
||||||
|
let inventoryId: string | null = null;
|
||||||
|
if (facility.hasWarehouse) {
|
||||||
|
const [inv]: Array<{ id: string }> = await manager.query(
|
||||||
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[booking.id],
|
||||||
|
);
|
||||||
|
inventoryId = inv?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = manager.getRepository(FacilityHandlingEvent);
|
||||||
|
await repo.save(
|
||||||
|
repo.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
yardId,
|
||||||
|
trainScheduleId: input.trainScheduleId ?? null,
|
||||||
|
eventType,
|
||||||
|
grnNumber,
|
||||||
|
weightTons: Number(booking.cargoTotalWeightVgm) || null,
|
||||||
|
inventoryId,
|
||||||
|
performedBy: input.performedBy ?? null,
|
||||||
|
occurredAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`GRN ${grnNumber} raised on ${eventType} at ${facility.yardCode ?? yardId} for booking ${booking.reference ?? booking.id}`,
|
||||||
|
);
|
||||||
|
return grnNumber;
|
||||||
|
} catch (err) {
|
||||||
|
// The cargo moved regardless — never fail the journey over the paperwork.
|
||||||
|
this.logger.error(
|
||||||
|
`Facility ${eventType} record failed for booking ${booking.id} at yard ${yardId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module';
|
|||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
|
import { FacilityHandlingEvent } from './entities/facility-handling-event.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Route } from '../routes/entities/route.entity';
|
import { Route } from '../routes/entities/route.entity';
|
||||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
@@ -41,6 +43,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
|
FacilityHandlingEvent,
|
||||||
Locomotive,
|
Locomotive,
|
||||||
WagonType,
|
WagonType,
|
||||||
TrainSet,
|
TrainSet,
|
||||||
@@ -81,6 +84,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
IntercityService,
|
IntercityService,
|
||||||
BookingJourneyService,
|
BookingJourneyService,
|
||||||
|
FacilityHandlingService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TrainSchedulingService,
|
TrainSchedulingService,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
|
import { generateGrnNumber } from '../../common/grn.util';
|
||||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||||
@@ -5073,10 +5074,9 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared with the facility handling flow — see common/grn.util.ts. */
|
||||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
return generateGrnNumber(direction, referenceId, date);
|
||||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
|
||||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||||
|
|||||||
67
apps/edr-freight-api/src/seed/yard-facilities.seeder.ts
Normal file
67
apps/edr-freight-api/src/seed/yard-facilities.seeder.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR's load/unload facilities, mapped onto the yards that already represent them.
|
||||||
|
*
|
||||||
|
* The codes are historical and don't read like the facility names, so map by code
|
||||||
|
* and never by label: Indode is `KALITY` ("Gelan Multi Purpose Port (Indode)") and
|
||||||
|
* Sebeta is `LEGACY_DEST` ("Sebeta"). Creating fresh INDODE/SEBETA yards would
|
||||||
|
* split data that existing routes and bookings already point at.
|
||||||
|
*
|
||||||
|
* Only Indode stores cargo, so it is the only facility with a warehouse — the rest
|
||||||
|
* move cargo on and off the train, which is why they accrue no storage/demurrage.
|
||||||
|
*
|
||||||
|
* Negad is deliberately absent: there are two candidates (`NAGAD` "DCT/SGDT" in
|
||||||
|
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
|
||||||
|
* settled which is the intercity facility.
|
||||||
|
*/
|
||||||
|
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [
|
||||||
|
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true },
|
||||||
|
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false },
|
||||||
|
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false },
|
||||||
|
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false },
|
||||||
|
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class YardFacilitiesSeeder {
|
||||||
|
private readonly logger = new Logger(YardFacilitiesSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent: flags existing yards and upserts their facility record. Creates no
|
||||||
|
* yards — a missing code is logged and skipped rather than invented.
|
||||||
|
*/
|
||||||
|
async run(): Promise<void> {
|
||||||
|
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) {
|
||||||
|
const [yard]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
|
||||||
|
[code],
|
||||||
|
);
|
||||||
|
if (!yard) {
|
||||||
|
this.logger.warn(`Yard ${code} (${facility}) not found — skipping facility flag`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE freight.yards
|
||||||
|
SET has_facility = true, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND has_facility = false`,
|
||||||
|
[yard.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||||
|
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`,
|
||||||
|
[yard.id, hasWarehouse, `${facility} load/unload facility`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Yard facilities seeded: ${FACILITY_YARDS.map((f) => f.facility).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -477,6 +477,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
codeColumn("code"),
|
codeColumn("code"),
|
||||||
{ id: "label", header: "Label", accessorKey: "label" },
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
{ id: "country", header: "Country", accessorKey: "country" },
|
{ id: "country", header: "Country", accessorKey: "country" },
|
||||||
|
{
|
||||||
|
id: "hasFacility",
|
||||||
|
header: "Facility",
|
||||||
|
accessorKey: "hasFacility",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
||||||
activeColumn,
|
activeColumn,
|
||||||
],
|
],
|
||||||
@@ -489,6 +495,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
required: true,
|
required: true,
|
||||||
options: YARD_COUNTRIES,
|
options: YARD_COUNTRIES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "hasFacility",
|
||||||
|
label: "Has load/unload facility",
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.",
|
||||||
|
},
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user