mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
feat(warehouses): map GRN numbers to the goods owner
GRN-<DIR>-<DATE>-<REF8> carried no owner, so a note couldn't be identified by who owns the cargo. Add an owner segment sourced from the booking's company at every generation point (import, export, facility, manual receive), keep REF8 for uniqueness, and label the GRN document row Owner's Name.
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user