mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge pull request #862 from Tria-plc/testfixes
auto generate grn on import arrival unloading
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
import type { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GRN is the receipt for cargo entering the warehouse, so unloadBooking must
|
||||||
|
* issue one for every direction — import as well as export. It used to mint only
|
||||||
|
* for export, leaving import cargo received with no GRN.
|
||||||
|
*/
|
||||||
|
function makeService(opts: {
|
||||||
|
tradeDirection: string | null;
|
||||||
|
existing?: { id: string; grnNumber: string | null };
|
||||||
|
}) {
|
||||||
|
const created: Record<string, unknown>[] = [];
|
||||||
|
const updated: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
const inventoryRepository = {
|
||||||
|
findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []),
|
||||||
|
update: jest.fn((id: string, patch: Record<string, unknown>) => {
|
||||||
|
updated.push({ id, patch });
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
create: jest.fn((row: Record<string, unknown>) => {
|
||||||
|
created.push(row);
|
||||||
|
return Promise.resolve({ id: 'new-inv', ...row });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||||
|
service.inventoryRepository = inventoryRepository;
|
||||||
|
service.dataSource = {
|
||||||
|
query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]),
|
||||||
|
};
|
||||||
|
// Location comes straight from the dto in these cases, so pickDefaultLocation
|
||||||
|
// is never reached; findById just echoes what was written.
|
||||||
|
service.findById = jest.fn((id: string) =>
|
||||||
|
Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dto: UnloadBookingDto = {
|
||||||
|
warehouseId: 'w1',
|
||||||
|
yardId: 'y1',
|
||||||
|
zoneId: 'z1',
|
||||||
|
} as UnloadBookingDto;
|
||||||
|
|
||||||
|
return { service: service as unknown as WarehouseInventoryService, dto, created, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('unloadBooking — GRN issuance', () => {
|
||||||
|
it('issues an IMPORT GRN when unloading a fresh import booking', async () => {
|
||||||
|
const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' });
|
||||||
|
|
||||||
|
await service.unloadBooking('b-import', dto);
|
||||||
|
|
||||||
|
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still issues an EXPORT GRN', async () => {
|
||||||
|
const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' });
|
||||||
|
|
||||||
|
await service.unloadBooking('b-export', dto);
|
||||||
|
|
||||||
|
expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints a GRN for an existing import row that has none', async () => {
|
||||||
|
const { service, dto, updated } = makeService({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
existing: { id: 'inv-1', grnNumber: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.unloadBooking('b-import', dto);
|
||||||
|
|
||||||
|
expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reissue when the row already has a GRN', async () => {
|
||||||
|
const { service, dto, updated } = makeService({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.unloadBooking('b-import', dto);
|
||||||
|
|
||||||
|
expect(updated[0].patch).not.toHaveProperty('grnNumber');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1138,14 +1138,15 @@ export class WarehouseInventoryService {
|
|||||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||||||
// a train without one. Import GRN handling is left untouched.
|
// 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(
|
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||||
`SELECT trade_direction AS "tradeDirection"
|
`SELECT trade_direction AS "tradeDirection"
|
||||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||||||
|
|
||||||
let location: DefaultLocation | null =
|
let location: DefaultLocation | null =
|
||||||
dto.warehouseId && dto.yardId && dto.zoneId
|
dto.warehouseId && dto.yardId && dto.zoneId
|
||||||
@@ -1166,10 +1167,10 @@ export class WarehouseInventoryService {
|
|||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt,
|
arrivedAt,
|
||||||
// Export only, and keep an already-issued GRN rather than reissuing.
|
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||||||
...(isExport && !existing[0].grnNumber
|
...(existing[0].grnNumber
|
||||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
? {}
|
||||||
: {}),
|
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
|
||||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||||
});
|
});
|
||||||
return this.findById(existing[0].id);
|
return this.findById(existing[0].id);
|
||||||
@@ -1184,9 +1185,7 @@ export class WarehouseInventoryService {
|
|||||||
weight: 0,
|
weight: 0,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt,
|
arrivedAt,
|
||||||
...(isExport
|
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
|
||||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
|
||||||
: {}),
|
|
||||||
notes: dto.notes ?? 'Unloaded',
|
notes: dto.notes ?? 'Unloaded',
|
||||||
});
|
});
|
||||||
return this.findById(saved.id);
|
return this.findById(saved.id);
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ import { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
|||||||
interface WarehouseInfoCardProps {
|
interface WarehouseInfoCardProps {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
bookingReference?: string;
|
bookingReference?: string;
|
||||||
|
/**
|
||||||
|
* Booking payment status. Export cargo is received into the warehouse only
|
||||||
|
* after the booking is paid — receiving an unpaid booking starts storage and
|
||||||
|
* GRN against cargo the customer has not settled. Optional so existing callers
|
||||||
|
* that do not have the booking to hand keep their current behaviour.
|
||||||
|
*/
|
||||||
|
paymentStatus?: string | null;
|
||||||
|
/**
|
||||||
|
* IMPORT | EXPORT | DOMESTIC. The payment gate is export-only: import cargo
|
||||||
|
* arrives OFF a train, so blocking its receive would strand cargo already at
|
||||||
|
* the yard.
|
||||||
|
*/
|
||||||
|
tradeDirection?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
@@ -28,7 +41,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
export function WarehouseInfoCard({
|
||||||
|
bookingId,
|
||||||
|
bookingReference,
|
||||||
|
paymentStatus,
|
||||||
|
tradeDirection,
|
||||||
|
}: WarehouseInfoCardProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useQuery(
|
||||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||||
@@ -46,6 +64,13 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
|||||||
const wagon = scheduleView?.wagon;
|
const wagon = scheduleView?.wagon;
|
||||||
const isLoadedOrDispatched =
|
const isLoadedOrDispatched =
|
||||||
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
|
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
|
||||||
|
// Export only, and only when we were actually told the status — an absent prop
|
||||||
|
// means the caller cannot answer, and guessing "unpaid" would disable a valid
|
||||||
|
// action. Mirrors the server guard on receive().
|
||||||
|
const awaitingPayment =
|
||||||
|
tradeDirection?.toUpperCase() === 'EXPORT' &&
|
||||||
|
paymentStatus != null &&
|
||||||
|
paymentStatus.toUpperCase() !== 'PAID';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder radius="md" padding="lg">
|
<Card withBorder radius="md" padding="lg">
|
||||||
@@ -127,8 +152,12 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="This booking is already received at the warehouse"
|
label={
|
||||||
disabled={!latest}
|
latest
|
||||||
|
? 'This booking is already received at the warehouse'
|
||||||
|
: 'This booking is not paid yet — cargo can only be received once payment is settled'
|
||||||
|
}
|
||||||
|
disabled={!latest && !awaitingPayment}
|
||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
{/* span wrapper so the tooltip still fires on the disabled button */}
|
{/* span wrapper so the tooltip still fires on the disabled button */}
|
||||||
@@ -138,7 +167,7 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
|||||||
leftSection={<PackagePlus size={16} />}
|
leftSection={<PackagePlus size={16} />}
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={() => setModalOpen(true)}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={Boolean(latest)}
|
disabled={Boolean(latest) || awaitingPayment}
|
||||||
>
|
>
|
||||||
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
|
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ export default function BookingRequestDetailPage() {
|
|||||||
<WarehouseInfoCard
|
<WarehouseInfoCard
|
||||||
bookingId={booking.id}
|
bookingId={booking.id}
|
||||||
bookingReference={booking.reference}
|
bookingReference={booking.reference}
|
||||||
|
paymentStatus={booking.paymentStatus}
|
||||||
|
tradeDirection={booking.tradeDirection}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<BookingActionsToolbar
|
<BookingActionsToolbar
|
||||||
|
|||||||
Reference in New Issue
Block a user