feat(eims): resolve buyer geography from the MoR location master

BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own
EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and
the ethiopia-geo-codes table. Both invented their codes and looked names up
globally, so KERSA/GORO/BABILE/BURE — each present in several zones with
different LOCALITY_NOs — could be filed against the wrong jurisdiction.

Resolution is hierarchical and refuses to guess: an unknown or ambiguous
address raises a local validation error naming the level that failed, and
never selects the first matching row. Spelling differences between EDR and
MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays
verbatim so it remains traceable to the Ministry sheet.

Resolution now runs before the counter reservation in both the single and
bulk paths, so a bad company address no longer burns an EIMS sequence number.

Adds eims:import-locations to regenerate the dataset from a future workbook,
reporting duplicate rows and same-hierarchy code conflicts.
This commit is contained in:
Hagernesh
2026-08-24 04:23:47 +00:00
parent d285006114
commit c937290019
25 changed files with 1150 additions and 566 deletions

View File

@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
@@ -18,9 +20,12 @@ import { ImportOperationsService } from './import-operations.service';
]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route.
// ownership check on that same route; the notification modules to tell
// the customer their receipt is ready at handover.
WarehousesModule,
BookingsModule,
NotificationInboxModule,
NotificationsModule,
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],

View File

@@ -1,9 +1,13 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
CreateDjiboutiIncidentDto,
@@ -35,6 +39,8 @@ const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
@Injectable()
export class ImportOperationsService {
private readonly logger = new Logger(ImportOperationsService.name);
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@@ -44,6 +50,8 @@ export class ImportOperationsService {
private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listIncidents(bookingId?: string) {
@@ -161,7 +169,7 @@ export class ImportOperationsService {
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
const saved = await this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
@@ -180,6 +188,15 @@ export class ImportOperationsService {
],
}),
);
// RETURNED is the physical interchange itself — the customer's/trucker's
// custody of the box ends here, EDR's begins. The receipt exists from this
// point on, so tell the customer now, not at some later internal status.
// Standalone returns (no booking) have no company to notify.
if (saved.bookingId) {
await this.notifyEquipmentInterchangeReady(saved);
}
return saved;
}
/**
@@ -257,6 +274,34 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string }> =
await this.emptyReturns.manager.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[row.bookingId],
);
if (!booking?.companyId) return;
const body = `Container ${row.containerNumber} was handed over${
row.facility ? ` at ${row.facility}` : ''
}. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Equipment interchange receipt ready',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, emptyContainerReturnId: row.id },
});
await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`,
);
}
}
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {