auto generate grn on import arrival unloading

This commit is contained in:
Hagernesh
2026-07-21 07:21:35 +00:00
parent db466a6b27
commit 36f0045b5d
4 changed files with 130 additions and 14 deletions

View File

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

View File

@@ -1138,14 +1138,15 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
// a train without one. Import GRN handling is left untouched.
// 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],
);
const isExport = bookingRow?.tradeDirection === 'EXPORT';
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1166,10 +1167,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
// Export only, and keep an already-issued GRN rather than reissuing.
...(isExport && !existing[0].grnNumber
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
...(existing[0].grnNumber
? {}
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1184,9 +1185,7 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
...(isExport
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);