feat(freight): offer built-train wagons per boarding yard on multi-yard consists

This commit is contained in:
Marshal
2026-08-18 08:27:18 +00:00
parent 1ca7776143
commit c723b660e2
33 changed files with 1234 additions and 231 deletions

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional } from "class-validator";
/**
* Partial update: the UI flips one currency at a time, so an omitted field
* leaves that currency's channel exactly as it was.
*/
export class UpdateManualPaymentSettingDto {
@ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" })
@IsOptional()
@IsBoolean()
etbEnabled?: boolean;
@ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" })
@IsOptional()
@IsBoolean()
usdEnabled?: boolean;
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
/**
* Single-row table controlling whether Finance may settle invoices by hand
* (bank transfer / counter payment) instead of the customer paying online.
*
* Per currency on purpose: the two channels are operationally different — USD
* bookings have always been bank-transfer-only, while ETB normally goes
* through the gateway and manual settlement is the exception. Switching one
* off must not switch off the other.
*/
@Entity({ schema: "freight", name: "manual_payment_settings" })
export class ManualPaymentSetting extends BaseEntity {
/** Manual settlement allowed for ETB invoices. */
@Column({ name: "etb_enabled", type: "boolean", default: false })
etbEnabled!: boolean;
/** Manual settlement allowed for USD invoices. */
@Column({ name: "usd_enabled", type: "boolean", default: true })
usdEnabled!: boolean;
/** IAM user id of the last operator to change either toggle. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,47 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto";
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
@ApiTags("payment-settings")
@ApiBearerAuth()
@Controller("payment-settings/manual")
export class ManualPaymentSettingsController {
constructor(private readonly service: ManualPaymentSettingsService) {}
/**
* Read is gated on `manual_payment:view`, which Finance also holds — the
* Manual Payments worklist reads this to know which currency tabs to offer.
*/
@Get()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.view,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Whether manual (offline) invoice settlement is enabled, per currency",
})
get() {
return this.service.get();
}
@Patch()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
})
update(
@Body() dto: UpdateManualPaymentSettingDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.update(dto, user?.id ?? null);
}
}

View File

@@ -0,0 +1,71 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
/** The two currencies an invoice can be settled by hand in. */
export type ManualPaymentCurrency = "ETB" | "USD";
/**
* Owns the single `manual_payment_settings` row: whether Finance may settle
* invoices by hand, per currency.
*
* Defaults mirror how the platform behaved before the toggles existed — USD
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
* the new capability and starts OFF, so enabling it is a deliberate act.
*/
@Injectable()
export class ManualPaymentSettingsService {
private readonly logger = new Logger(ManualPaymentSettingsService.name);
constructor(
@InjectRepository(ManualPaymentSetting)
private readonly repository: Repository<ManualPaymentSetting>,
) {}
/** The settings row, created at the defaults on first access. */
async get(): Promise<ManualPaymentSetting> {
const existing = await this.repository.findOne({ where: {} });
if (existing) return existing;
return this.repository.save(
this.repository.create({ etbEnabled: false, usdEnabled: true }),
);
}
/** Currencies manual settlement is currently allowed for. */
async enabledCurrencies(): Promise<ManualPaymentCurrency[]> {
const setting = await this.get();
const enabled: ManualPaymentCurrency[] = [];
if (setting.etbEnabled) enabled.push("ETB");
if (setting.usdEnabled) enabled.push("USD");
return enabled;
}
/** Whether one currency may be settled by hand right now. */
async isEnabled(currency: string | null | undefined): Promise<boolean> {
const upper = currency?.toUpperCase();
if (upper !== "ETB" && upper !== "USD") return false;
const setting = await this.get();
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
}
/** Flip either toggle; an omitted field leaves that currency unchanged. */
async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
updatedById?: string | null,
): Promise<ManualPaymentSetting> {
const current = await this.get();
await this.repository.update(current.id, {
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
updatedById: updatedById ?? null,
});
const updated = await this.get();
this.logger.warn(
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
);
return updated;
}
}

View File

@@ -0,0 +1,20 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
import { ManualPaymentSettingsController } from "./manual-payment-settings.controller";
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
/**
* Global so billing can inject {@link ManualPaymentSettingsService} to gate
* the manual-settlement worklist and confirmation endpoint without importing
* this module (and without a cycle, since this module needs nothing back).
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ManualPaymentSetting])],
controllers: [ManualPaymentSettingsController],
providers: [ManualPaymentSettingsService],
exports: [ManualPaymentSettingsService],
})
export class PaymentSettingsModule {}