mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior
928 lines
33 KiB
TypeScript
928 lines
33 KiB
TypeScript
import { BaseRepository } from '@edr/api-common';
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
|
|
|
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { FileRecord } from '../files/entities/file.entity';
|
|
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
|
import { Contract } from './entities/contract.entity';
|
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
|
import {
|
|
ContractDocReviewStatus,
|
|
ContractDocumentReview,
|
|
} from './entities/contract-document-review.entity';
|
|
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
|
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
|
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
|
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
|
|
|
/**
|
|
* Booking statuses that release whatever the booking was holding — contract
|
|
* capacity, the one-time active slot, the cancel gate. Everything else counts
|
|
* as a live booking.
|
|
*/
|
|
export const TERMINAL_BOOKING_STATUSES = [
|
|
'EXPIRED',
|
|
'CANCELLED',
|
|
'COMPLETED',
|
|
'REJECTED',
|
|
];
|
|
|
|
export interface ContractListFilterOptions {
|
|
statuses?: string[];
|
|
status?: string;
|
|
companyId?: string;
|
|
companyProfileId?: string;
|
|
contractKind?: string;
|
|
serviceTypeId?: string;
|
|
freightType?: string;
|
|
tradeDirection?: string;
|
|
/** Per-user trade-direction scope — `[]` matches nothing. */
|
|
tradeDirections?: string[];
|
|
paymentCurrency?: string;
|
|
customsClearingEnabled?: boolean;
|
|
/** true → only contracts with at least one uploaded clearance document. */
|
|
hasClearanceDocuments?: boolean;
|
|
createdFrom?: string;
|
|
createdTo?: string;
|
|
originYardId?: string;
|
|
destinationYardId?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ContractsRepository extends BaseRepository<Contract> {
|
|
constructor(
|
|
@InjectRepository(Contract)
|
|
repository: Repository<Contract>,
|
|
private readonly dataSource: DataSource,
|
|
) {
|
|
super(repository);
|
|
}
|
|
|
|
/** Find a contract by its human-readable reference number. */
|
|
findByReference(reference: string): Promise<Contract | null> {
|
|
return this.repository.findOne({ where: { reference } });
|
|
}
|
|
|
|
/**
|
|
* Highest NNNNN sequence already issued for `CTR-<year>-…` references.
|
|
* Includes soft-deleted contracts — their references still occupy the unique
|
|
* index, so the next number must move past them. (A created-at count drifts
|
|
* below the issued sequence after any delete and then collides forever.)
|
|
*/
|
|
async maxReferenceSequence(year: number): Promise<number> {
|
|
const row = await this.repository
|
|
.createQueryBuilder('contract')
|
|
.withDeleted()
|
|
.select(
|
|
"COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)",
|
|
'max',
|
|
)
|
|
.where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` })
|
|
.getRawOne<{ max: string | number | null }>();
|
|
return Number(row?.max ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Non-terminal contracts for the same company + service type, with routes and
|
|
* cargo scope loaded — candidates for the duplicate-contract check on
|
|
* create() (which also compares operation type, kind and scope). Terminal
|
|
* filtering happens in JS via isEffectivelyExpired (also covers the
|
|
* date-passed-but-not-yet-cron-flipped case).
|
|
*/
|
|
async findDuplicateCandidates(
|
|
companyId: string,
|
|
serviceTypeId: string,
|
|
): Promise<Contract[]> {
|
|
return this.repository
|
|
.createQueryBuilder('contract')
|
|
.leftJoinAndSelect('contract.routes', 'routes')
|
|
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
|
.where('contract.deleted_at IS NULL')
|
|
.andWhere('contract.company_id = :companyId', { companyId })
|
|
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
|
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
|
})
|
|
// A ONE_TIME contract allows a single booking, so once that booking is
|
|
// PAID the contract is spent and can never carry another shipment.
|
|
// Without this it kept blocking new requests on the same service type +
|
|
// route until its validity lapsed — locking a customer out of a lane for
|
|
// the rest of the term after one completed shipment.
|
|
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
|
|
// booking must keep the contract blocking, otherwise a customer holds an
|
|
// unpaid booking and requests an identical contract alongside it.
|
|
.andWhere(
|
|
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
|
SELECT 1 FROM freight.bookings b
|
|
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
|
AND b.payment_status = 'PAID'
|
|
))`,
|
|
)
|
|
.getMany();
|
|
}
|
|
|
|
/**
|
|
* Nightly expiry sweep: flips lapsed contracts to EXPIRED. Returns the
|
|
* number of rows updated (for cron logging).
|
|
*/
|
|
async expireLapsedContracts(): Promise<number> {
|
|
const result = await this.repository
|
|
.createQueryBuilder()
|
|
.update(Contract)
|
|
.set({ status: 'EXPIRED' })
|
|
.where('deleted_at IS NULL')
|
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
|
now: new Date(),
|
|
})
|
|
.execute();
|
|
return result.affected ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Same-row version of expireLapsedContracts, for lazy flips on read/booking
|
|
* paths — flips this one contract to EXPIRED if it's lapsed and not already
|
|
* terminal. No-op (returns false) if the contract isn't actually lapsed, so
|
|
* callers can call this unconditionally without a pre-check.
|
|
*/
|
|
async expireIfLapsed(id: string): Promise<boolean> {
|
|
const result = await this.repository
|
|
.createQueryBuilder()
|
|
.update(Contract)
|
|
.set({ status: 'EXPIRED' })
|
|
.where('id = :id', { id })
|
|
.andWhere('deleted_at IS NULL')
|
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
|
now: new Date(),
|
|
})
|
|
.execute();
|
|
return (result.affected ?? 0) > 0;
|
|
}
|
|
|
|
/**
|
|
* Live contracts whose validity ends between `days` and `days + 1` days from
|
|
* now — the slice the daily expiry-reminder cron warns about. The window is
|
|
* rolling and exactly 24h wide, so consecutive daily runs tile it without
|
|
* gaps or overlaps: each contract is picked up by exactly one run and the
|
|
* customer is notified once, with no "already reminded" flag to store.
|
|
*/
|
|
async findExpiringInDays(days: number): Promise<Contract[]> {
|
|
const now = Date.now();
|
|
return this.repository
|
|
.createQueryBuilder('contract')
|
|
.where('contract.deleted_at IS NULL')
|
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
|
})
|
|
.andWhere('contract.contract_valid_until >= :from', {
|
|
from: new Date(now + days * 86_400_000),
|
|
})
|
|
.andWhere('contract.contract_valid_until < :to', {
|
|
to: new Date(now + (days + 1) * 86_400_000),
|
|
})
|
|
.getMany();
|
|
}
|
|
|
|
/** Find a contract by ID with all child collections, service type, company and files. */
|
|
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
|
if (!id) return null;
|
|
|
|
const contract = await this.repository
|
|
.createQueryBuilder('contract')
|
|
.leftJoinAndSelect('contract.routes', 'routes')
|
|
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
|
|
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
|
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
|
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
|
|
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
|
|
.leftJoinAndSelect('contract.signatures', 'signatures')
|
|
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
|
|
.leftJoinAndSelect('contract.approvalSteps', 'approvalSteps')
|
|
.leftJoinAndSelect('contract.serviceType', 'serviceType')
|
|
.leftJoinAndSelect('contract.company', 'company')
|
|
.where('contract.id = :id', { id })
|
|
.leftJoinAndMapMany(
|
|
'contract.files',
|
|
FileRecord,
|
|
'file',
|
|
// Superseded versions are soft-deleted, not dropped — keep them out of
|
|
// the live file list (a manual join condition is not filtered for us).
|
|
"file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL",
|
|
)
|
|
.getOne();
|
|
|
|
return contract ?? null;
|
|
}
|
|
|
|
/** Paginated list with optional multi-status filter (API tab queues). */
|
|
async findAllPaginated(
|
|
options: ContractListFilterOptions & {
|
|
page: number;
|
|
pageSize: number;
|
|
search?: string;
|
|
sortBy?: string;
|
|
sortOrder?: 'ASC' | 'DESC';
|
|
},
|
|
): Promise<{
|
|
items: Contract[];
|
|
total: number;
|
|
meta: {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasNextPage: boolean;
|
|
hasPreviousPage: boolean;
|
|
};
|
|
}> {
|
|
const page = options.page;
|
|
const pageSize = options.pageSize;
|
|
|
|
const qb = this.repository
|
|
.createQueryBuilder('contract')
|
|
.leftJoinAndSelect('contract.company', 'company')
|
|
.leftJoinAndSelect('contract.serviceType', 'serviceType')
|
|
.leftJoinAndSelect('contract.routes', 'routes')
|
|
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
|
|
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
|
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
|
.where('contract.deleted_at IS NULL');
|
|
|
|
this.applyListFilters(qb, options);
|
|
|
|
// Free-text search across contract reference and customer (company) name.
|
|
// Applied here (not in applyListFilters) because only this query joins the
|
|
// `company` alias — the summary-metrics query builder does not.
|
|
if (options.search) {
|
|
qb.andWhere(
|
|
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
|
|
{ search: `%${options.search}%` },
|
|
);
|
|
}
|
|
|
|
const sortField =
|
|
options.sortBy === 'contractValidUntil'
|
|
? 'contract.contractValidUntil'
|
|
: 'contract.createdAt';
|
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
|
|
|
const [items, total] = await qb
|
|
.skip((page - 1) * pageSize)
|
|
.take(pageSize)
|
|
.getManyAndCount();
|
|
|
|
// Attach the generated contract PDF to each row so list/home can offer a
|
|
// direct download. Loaded separately to keep pagination counts correct.
|
|
await this.attachContractFiles(items);
|
|
await this.attachClearancePhases(items);
|
|
await this.attachRejectionNotes(items);
|
|
|
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
|
return {
|
|
items,
|
|
total,
|
|
meta: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
totalPages,
|
|
hasNextPage: page < totalPages,
|
|
hasPreviousPage: page > 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load contract-resource files for the given contracts and attach them to
|
|
* `contract.files`. Kept separate from the paginated query so the one-to-many
|
|
* join doesn't inflate the page count.
|
|
*/
|
|
private async attachContractFiles(contracts: Contract[]): Promise<void> {
|
|
if (contracts.length === 0) return;
|
|
const ids = contracts.map((c) => c.id);
|
|
const files = await this.dataSource.getRepository(FileRecord).find({
|
|
where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() },
|
|
});
|
|
const byContract = new Map<string, FileRecord[]>();
|
|
for (const file of files) {
|
|
const list = byContract.get(file.resourceId) ?? [];
|
|
list.push(file);
|
|
byContract.set(file.resourceId, list);
|
|
}
|
|
for (const contract of contracts) {
|
|
contract.files = byContract.get(contract.id) ?? [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach each contract's persisted clearance phase (latest cycle's
|
|
* current_phase) so list consumers can show step-accurate customer actions
|
|
* ("Pay duty & upload slip" vs generic "Update clearance") without a
|
|
* per-contract clearance-view request. One query per page, like
|
|
* `attachContractFiles`.
|
|
*/
|
|
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
|
if (contracts.length === 0) return;
|
|
const ids = contracts.map((c) => c.id);
|
|
const rows: Array<{
|
|
contract_id: string;
|
|
current_phase: string | null;
|
|
booking_id: string | null;
|
|
booking_status: string | null;
|
|
}> = await this.dataSource.query(
|
|
`SELECT DISTINCT ON (ccc.contract_id)
|
|
ccc.contract_id, ccc.current_phase,
|
|
b.id AS booking_id, b.status AS booking_status
|
|
FROM freight.contract_clearance_cycles ccc
|
|
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
|
|
WHERE ccc.contract_id = ANY($1)
|
|
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
|
|
[ids],
|
|
);
|
|
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
|
|
for (const contract of contracts) {
|
|
const row = byContract.get(contract.id);
|
|
contract.clearancePhase = row?.current_phase ?? null;
|
|
contract.latestCycleBookingId = row?.booking_id ?? null;
|
|
contract.latestCycleBookingStatus = row?.booking_status ?? null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach the latest REJECTION review-note body to each REJECTED contract so
|
|
* list consumers (portal rows, backoffice queues) can show why without a
|
|
* per-contract detail fetch. One query per page, like `attachContractFiles`.
|
|
*/
|
|
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
|
|
const rejected = contracts.filter((c) => c.status === 'REJECTED');
|
|
if (rejected.length === 0) return;
|
|
const ids = rejected.map((c) => c.id);
|
|
const rows: Array<{ contract_id: string; body: string }> =
|
|
await this.dataSource.query(
|
|
`SELECT DISTINCT ON (contract_id) contract_id, body
|
|
FROM freight.contract_review_notes
|
|
WHERE contract_id = ANY($1)
|
|
AND note_type = 'REJECTION'
|
|
AND deleted_at IS NULL
|
|
ORDER BY contract_id, created_at DESC`,
|
|
[ids],
|
|
);
|
|
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
|
|
for (const contract of rejected) {
|
|
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
|
|
}
|
|
}
|
|
|
|
async getStatusCounts(): Promise<Record<string, number>> {
|
|
const rows = await this.repository
|
|
.createQueryBuilder('contract')
|
|
.select('contract.status', 'status')
|
|
.addSelect('COUNT(*)::int', 'count')
|
|
.where('contract.deleted_at IS NULL')
|
|
.groupBy('contract.status')
|
|
.getRawMany<{ status: string; count: string }>();
|
|
|
|
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
|
}
|
|
|
|
async getListSummaryMetrics(
|
|
options: ContractListFilterOptions & {
|
|
page: number;
|
|
pageSize: number;
|
|
needsActionStatuses: readonly string[];
|
|
},
|
|
): Promise<{ inQueue: number; onThisPage: number; needsAction: number }> {
|
|
const baseQb = () => {
|
|
const qb = this.repository
|
|
.createQueryBuilder('contract')
|
|
.where('contract.deleted_at IS NULL');
|
|
this.applyListFilters(qb, options);
|
|
return qb;
|
|
};
|
|
|
|
const inQueue = await baseQb().getCount();
|
|
|
|
const needsAction = await baseQb()
|
|
.andWhere('contract.status IN (:...needsActionStatuses)', {
|
|
needsActionStatuses: [...options.needsActionStatuses],
|
|
})
|
|
.getCount();
|
|
|
|
const offset = (options.page - 1) * options.pageSize;
|
|
const onThisPage = Math.min(options.pageSize, Math.max(0, inQueue - offset));
|
|
|
|
return { inQueue, onThisPage, needsAction };
|
|
}
|
|
|
|
/**
|
|
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
|
* own filter doesn't hide its own sibling values (see class doc on
|
|
* `getFacets`). Every other list/summary/count caller passes nothing.
|
|
*/
|
|
private applyListFilters(
|
|
qb: SelectQueryBuilder<Contract>,
|
|
options: ContractListFilterOptions,
|
|
omit?: keyof ContractListFilterOptions | 'status',
|
|
): void {
|
|
if (omit !== 'status') {
|
|
if (options.statuses?.length) {
|
|
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
|
} else if (options.status) {
|
|
qb.andWhere('contract.status = :status', { status: options.status });
|
|
}
|
|
}
|
|
if (options.companyId) {
|
|
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
|
}
|
|
if (options.companyProfileId) {
|
|
qb.andWhere('contract.company_profile_id = :companyProfileId', {
|
|
companyProfileId: options.companyProfileId,
|
|
});
|
|
}
|
|
if (omit !== 'contractKind' && options.contractKind) {
|
|
qb.andWhere('contract.contract_kind = :contractKind', {
|
|
contractKind: options.contractKind,
|
|
});
|
|
}
|
|
if (options.customsClearingEnabled !== undefined) {
|
|
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
|
|
customsClearingEnabled: options.customsClearingEnabled,
|
|
});
|
|
}
|
|
if (options.hasClearanceDocuments) {
|
|
qb.andWhere(
|
|
'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' +
|
|
'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)',
|
|
);
|
|
}
|
|
if (options.serviceTypeId) {
|
|
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
|
serviceTypeId: options.serviceTypeId,
|
|
});
|
|
}
|
|
if (omit !== 'freightType' && options.freightType) {
|
|
qb.andWhere('contract.freight_type = :freightType', {
|
|
freightType: options.freightType,
|
|
});
|
|
}
|
|
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
|
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
|
tradeDirection: options.tradeDirection,
|
|
});
|
|
}
|
|
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
|
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
|
}
|
|
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
|
|
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
|
paymentCurrency: options.paymentCurrency,
|
|
});
|
|
}
|
|
if (options.createdFrom) {
|
|
qb.andWhere('contract.created_at >= :createdFrom', {
|
|
createdFrom: options.createdFrom,
|
|
});
|
|
}
|
|
if (options.createdTo) {
|
|
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
|
|
}
|
|
// Routes are one-to-many (a contract can list several lanes), so origin
|
|
// and destination each need their own EXISTS — a plain join would
|
|
// duplicate the contract row per matching route.
|
|
if (omit !== 'originYardId' && options.originYardId) {
|
|
qb.andWhere(
|
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
|
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
|
'AND cr_o.origin_yard_id = :originYardId)',
|
|
{ originYardId: options.originYardId },
|
|
);
|
|
}
|
|
if (omit !== 'destinationYardId' && options.destinationYardId) {
|
|
qb.andWhere(
|
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
|
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
|
'AND cr_d.destination_yard_id = :destinationYardId)',
|
|
{ destinationYardId: options.destinationYardId },
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
|
* column, each with every OTHER active filter applied but its own
|
|
* predicate omitted — so selecting `status=SUBMITTED` still shows
|
|
* `APPROVED: 8` in the status popover (to switch), while the freightType
|
|
* popover reflects only the SUBMITTED-scoped set. Supersedes
|
|
* `getStatusCounts`, which ignores the active filter entirely.
|
|
*/
|
|
async getFacets(options: ContractListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
|
return computeFacets(
|
|
() => this.repository.createQueryBuilder('contract').where('contract.deleted_at IS NULL'),
|
|
(qb, omit) => this.applyListFilters(qb, options, omit as keyof ContractListFilterOptions),
|
|
{
|
|
status: 'contract.status',
|
|
contractKind: 'contract.contract_kind',
|
|
freightType: 'contract.freight_type',
|
|
tradeDirection: 'contract.trade_direction',
|
|
paymentCurrency: 'contract.payment_currency',
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Approval steps ─────────────────────────────────────────────────────────
|
|
|
|
/** Lowest-order pending approval step (sequential enforcement). */
|
|
async findNextPendingApprovalStep(
|
|
contractId: string,
|
|
): Promise<ContractApprovalStep | null> {
|
|
return this.dataSource.getRepository(ContractApprovalStep).findOne({
|
|
where: { contractId, status: 'PENDING' },
|
|
order: { stepOrder: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async findApprovalStepById(
|
|
contractId: string,
|
|
stepId: string,
|
|
): Promise<ContractApprovalStep | null> {
|
|
return this.dataSource.getRepository(ContractApprovalStep).findOne({
|
|
where: { contractId, id: stepId },
|
|
});
|
|
}
|
|
|
|
/** Mark an approval step complete. */
|
|
async completeApprovalStep(
|
|
stepId: string,
|
|
actorId: string,
|
|
status: 'APPROVED' | 'REJECTED',
|
|
note?: string,
|
|
): Promise<void> {
|
|
await this.dataSource.getRepository(ContractApprovalStep).update(stepId, {
|
|
status,
|
|
actedByStaffId: actorId,
|
|
actedAt: new Date(),
|
|
note,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send-back reset: every step at or after `fromStepOrder` returns to PENDING
|
|
* with its actor/verdict cleared, so the chain re-runs from that stage. The
|
|
* send-back reason lives in the review-note trail, not on the wiped steps.
|
|
*/
|
|
async resetApprovalStepsFrom(
|
|
contractId: string,
|
|
fromStepOrder: number,
|
|
): Promise<void> {
|
|
await this.dataSource
|
|
.getRepository(ContractApprovalStep)
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null })
|
|
.where('contract_id = :contractId', { contractId })
|
|
.andWhere('step_order >= :fromStepOrder', { fromStepOrder })
|
|
.execute();
|
|
}
|
|
|
|
/** Check if all approval steps are approved. */
|
|
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
|
|
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({
|
|
where: { contractId, status: 'PENDING' },
|
|
});
|
|
return pending === 0;
|
|
}
|
|
|
|
/** Persist a contract approval step (instantiated at staff accept). */
|
|
async createApprovalStep(
|
|
data: Partial<ContractApprovalStep>,
|
|
): Promise<ContractApprovalStep> {
|
|
const repo = this.dataSource.getRepository(ContractApprovalStep);
|
|
return repo.save(repo.create(data));
|
|
}
|
|
|
|
// ── Signatures ──────────────────────────────────────────────────────────────
|
|
|
|
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
|
return this.dataSource.getRepository(ContractSignature).find({
|
|
where: { contractId },
|
|
relations: ['signatureFile', 'stampFile'],
|
|
order: { signedAt: 'ASC' },
|
|
});
|
|
}
|
|
|
|
findSignature(
|
|
contractId: string,
|
|
role: ContractSignerRole,
|
|
): Promise<ContractSignature | null> {
|
|
return this.dataSource.getRepository(ContractSignature).findOne({
|
|
where: { contractId, role },
|
|
relations: ['signatureFile', 'stampFile'],
|
|
});
|
|
}
|
|
|
|
async saveSignature(data: Partial<ContractSignature>): Promise<ContractSignature> {
|
|
const repo = this.dataSource.getRepository(ContractSignature);
|
|
const existing = await repo.findOne({
|
|
where: { contractId: data.contractId!, role: data.role! },
|
|
});
|
|
if (existing) {
|
|
Object.assign(existing, data);
|
|
return repo.save(existing);
|
|
}
|
|
return repo.save(repo.create(data));
|
|
}
|
|
|
|
// ── Review notes ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Bookings on the contract that have not reached a terminal state. Gates the
|
|
* customer's own contract cancellation (a contract carrying live cargo may
|
|
* not be cancelled) and is surfaced on the detail response so the portal can
|
|
* disable the button instead of failing the call.
|
|
*/
|
|
async countActiveBookings(contractId: string): Promise<number> {
|
|
return this.dataSource
|
|
.getRepository(Booking)
|
|
.createQueryBuilder('b')
|
|
.where('b.contract_id = :contractId', { contractId })
|
|
.andWhere('b.status NOT IN (:...terminal)', {
|
|
terminal: TERMINAL_BOOKING_STATUSES,
|
|
})
|
|
.getCount();
|
|
}
|
|
|
|
/**
|
|
* The live shipment booking on a contract, newest first.
|
|
*
|
|
* The clearance view historically reached the booking through
|
|
* `currentCycle().bookingId`, but a cycle row is not created on every path —
|
|
* an FCFS export booking and a GL drawdown both reach
|
|
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
|
|
* clearance page loses the booking's status entirely. This resolves it from
|
|
* the bookings themselves, which is the authoritative link (bookings carry
|
|
* contract_id), and is used as the fallback when the cycle has no booking.
|
|
*/
|
|
async findLatestBookingForContract(
|
|
contractId: string,
|
|
): Promise<Booking | null> {
|
|
return this.dataSource
|
|
.getRepository(Booking)
|
|
.createQueryBuilder('b')
|
|
.where('b.contract_id = :contractId', { contractId })
|
|
.andWhere('b.status NOT IN (:...terminal)', {
|
|
terminal: TERMINAL_BOOKING_STATUSES,
|
|
})
|
|
.orderBy('b.created_at', 'DESC')
|
|
.getOne();
|
|
}
|
|
|
|
async createReviewNote(
|
|
contractId: string,
|
|
body: string,
|
|
noteType: ContractReviewNoteType,
|
|
authorUserId?: string,
|
|
authorRole?: string,
|
|
): Promise<ContractReviewNote> {
|
|
const repo = this.dataSource.getRepository(ContractReviewNote);
|
|
return repo.save(
|
|
repo.create({
|
|
contractId,
|
|
body,
|
|
noteType,
|
|
authorUserId: authorUserId ?? null,
|
|
authorRole: authorRole ?? null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
|
|
async findReviewNotes(
|
|
contractId: string,
|
|
noteType: ContractReviewNoteType,
|
|
): Promise<ContractReviewNote[]> {
|
|
return this.dataSource.getRepository(ContractReviewNote).find({
|
|
where: { contractId, noteType },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async findLatestReviewNote(
|
|
contractId: string,
|
|
noteType?: ContractReviewNoteType,
|
|
): Promise<ContractReviewNote | null> {
|
|
const repo = this.dataSource.getRepository(ContractReviewNote);
|
|
return repo.findOne({
|
|
where: noteType ? { contractId, noteType } : { contractId },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
// ── Pre-booking clearance document reviews ────────────────────────────────────
|
|
|
|
findDocumentReviews(
|
|
contractId: string,
|
|
cycleId?: string | null,
|
|
): Promise<ContractDocumentReview[]> {
|
|
return this.dataSource.getRepository(ContractDocumentReview).find({
|
|
where:
|
|
cycleId !== undefined
|
|
? { contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId }
|
|
: { contractId },
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
|
|
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload. Keyed
|
|
* on (contractId, clearanceCycleId, settingCode, fileKey).
|
|
*/
|
|
async upsertDocumentReviewPending(input: {
|
|
contractId: string;
|
|
clearanceCycleId?: string | null;
|
|
settingCode: string;
|
|
fileKey: string;
|
|
fileRecordId: string;
|
|
uploadedByRole?: 'CUSTOMER' | 'GL_ET' | 'GL_DJ';
|
|
}): Promise<void> {
|
|
const repo = this.dataSource.getRepository(ContractDocumentReview);
|
|
const cycleId = input.clearanceCycleId ?? null;
|
|
const existing = await repo.findOne({
|
|
where: {
|
|
contractId: input.contractId,
|
|
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
|
|
settingCode: input.settingCode,
|
|
fileKey: input.fileKey,
|
|
},
|
|
});
|
|
if (existing) {
|
|
await repo.update(existing.id, {
|
|
fileRecordId: input.fileRecordId,
|
|
status: 'PENDING',
|
|
note: null,
|
|
reviewedByStaffId: null,
|
|
reviewedAt: null,
|
|
});
|
|
return;
|
|
}
|
|
await repo.save(
|
|
repo.create({
|
|
contractId: input.contractId,
|
|
clearanceCycleId: cycleId,
|
|
settingCode: input.settingCode,
|
|
fileKey: input.fileKey,
|
|
fileRecordId: input.fileRecordId,
|
|
status: 'PENDING',
|
|
uploadedByRole: input.uploadedByRole ?? 'CUSTOMER',
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
|
async setDocumentReviewStatus(input: {
|
|
contractId: string;
|
|
clearanceCycleId?: string | null;
|
|
settingCode: string;
|
|
fileKey: string;
|
|
status: ContractDocReviewStatus;
|
|
staffId: string;
|
|
note?: string;
|
|
}): Promise<void> {
|
|
const repo = this.dataSource.getRepository(ContractDocumentReview);
|
|
const cycleId = input.clearanceCycleId ?? null;
|
|
const existing = await repo.findOne({
|
|
where: {
|
|
contractId: input.contractId,
|
|
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
|
|
settingCode: input.settingCode,
|
|
fileKey: input.fileKey,
|
|
},
|
|
});
|
|
const patch = {
|
|
status: input.status,
|
|
note: input.note ?? null,
|
|
reviewedByStaffId: input.staffId,
|
|
reviewedAt: new Date(),
|
|
};
|
|
if (existing) {
|
|
await repo.update(existing.id, patch);
|
|
return;
|
|
}
|
|
await repo.save(
|
|
repo.create({
|
|
contractId: input.contractId,
|
|
clearanceCycleId: cycleId,
|
|
settingCode: input.settingCode,
|
|
fileKey: input.fileKey,
|
|
...patch,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Clearance cycles ──────────────────────────────────────────────────────────
|
|
|
|
/** The current (latest, non-completed) clearance cycle for a contract. */
|
|
async currentCycle(contractId: string): Promise<ContractClearanceCycle | null> {
|
|
return this.dataSource.getRepository(ContractClearanceCycle).findOne({
|
|
where: { contractId },
|
|
order: { cycleNumber: 'DESC' },
|
|
});
|
|
}
|
|
|
|
/** Open a new clearance cycle (incrementing cycle_number). */
|
|
async openCycle(
|
|
contractId: string,
|
|
cycleNumber: number,
|
|
): Promise<ContractClearanceCycle> {
|
|
const repo = this.dataSource.getRepository(ContractClearanceCycle);
|
|
return repo.save(
|
|
repo.create({
|
|
contractId,
|
|
cycleNumber,
|
|
status: 'AWAITING_DOCUMENTS',
|
|
}),
|
|
);
|
|
}
|
|
|
|
async setCycleStatus(
|
|
cycleId: string,
|
|
status: string,
|
|
fields: Partial<
|
|
Pick<
|
|
ContractClearanceCycle,
|
|
| 'bookingId'
|
|
| 'clearanceReadyAt'
|
|
| 'completedAt'
|
|
| 'dutyRequired'
|
|
| 'vesselDepartureDate'
|
|
| 'roAmendmentRequestedAt'
|
|
| 'roHoldReason'
|
|
| 'currentPhase'
|
|
>
|
|
> = {},
|
|
): Promise<void> {
|
|
await this.dataSource
|
|
.getRepository(ContractClearanceCycle)
|
|
.update(cycleId, { status, ...fields } as never);
|
|
}
|
|
|
|
async updateCycle(
|
|
cycleId: string,
|
|
fields: Partial<
|
|
Pick<
|
|
ContractClearanceCycle,
|
|
| 'dutyRequired'
|
|
| 'vesselDepartureDate'
|
|
| 'vesselArrivalDate'
|
|
| 'doCollectedDate'
|
|
| 'roAmendmentRequestedAt'
|
|
| 'roHoldReason'
|
|
| 'currentPhase'
|
|
| 'status'
|
|
| 'preClearanceFinalizedAt'
|
|
| 'completedAt'
|
|
| 'transitAssigneeRequestedAt'
|
|
| 'transitAssigneeRequestedByUserId'
|
|
| 'transitAssigneeRequestNote'
|
|
| 'transitAssigneeName'
|
|
| 'transitAssigneeAssignedAt'
|
|
| 'transitAssigneeAssignedByUserId'
|
|
>
|
|
>,
|
|
): Promise<void> {
|
|
await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never);
|
|
}
|
|
|
|
/** Link the GL-created booking to a clearance cycle. */
|
|
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
|
await this.dataSource
|
|
.getRepository(ContractClearanceCycle)
|
|
.update(cycleId, { bookingId });
|
|
}
|
|
|
|
// ── Rate snapshots ──────────────────────────────────────────────────────────
|
|
|
|
async createRateSnapshot(
|
|
data: Partial<ContractRateSnapshot>,
|
|
): Promise<ContractRateSnapshot> {
|
|
const repo = this.dataSource.getRepository(ContractRateSnapshot);
|
|
return repo.save(repo.create(data));
|
|
}
|
|
|
|
async clearRateSnapshots(contractId: string): Promise<void> {
|
|
await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId });
|
|
}
|
|
|
|
findRateSnapshots(contractId: string): Promise<ContractRateSnapshot[]> {
|
|
return this.dataSource.getRepository(ContractRateSnapshot).find({
|
|
where: { contractId },
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
}
|
|
}
|