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:
Hagernesh
2026-07-27 09:55:15 +00:00
parent 2a97bd4235
commit 101bf69271
15 changed files with 690 additions and 64 deletions

View File

@@ -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

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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",

View File

@@ -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> {

View File

@@ -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);
}
}

View File

@@ -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) {