feat(interests): CM-2 Part B — interest_berths price override (data + resolver)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
-- CM-2 Part B: per-interest, per-berth deal-price override.
|
||||
-- Null = use the berth's canonical list price (berths.price). When set, this
|
||||
-- supersedes the list price for THIS interest's generated documents
|
||||
-- (resolved in eoi-context via resolveBerthPriceForInterest).
|
||||
ALTER TABLE interest_berths
|
||||
ADD COLUMN IF NOT EXISTS price_override numeric,
|
||||
ADD COLUMN IF NOT EXISTS price_override_currency text;
|
||||
@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
|
||||
addedBy: text('added_by'),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
notes: text('notes'),
|
||||
// CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use
|
||||
// the berth's canonical list price. Does not touch berths.price.
|
||||
priceOverride: numeric('price_override'),
|
||||
priceOverrideCurrency: text('price_override_currency'),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
||||
|
||||
@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
|
||||
addedBy: interestBerths.addedBy,
|
||||
addedAt: interestBerths.addedAt,
|
||||
notes: interestBerths.notes,
|
||||
priceOverride: interestBerths.priceOverride,
|
||||
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
|
||||
.delete(interestBerths)
|
||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||
}
|
||||
|
||||
// ─── Per-interest price override (CM-2 Part B) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the effective price for a berth in the context of an interest. The
|
||||
* deal-specific override (when set) supersedes the berth's canonical list
|
||||
* price; the override carries its own currency, falling back to the base
|
||||
* currency when null. Pure — safe to unit-test without a DB.
|
||||
*/
|
||||
export function resolveBerthPriceForInterest(
|
||||
override: { priceOverride: string | null; priceOverrideCurrency: string | null },
|
||||
base: { price: string | null; priceCurrency: string },
|
||||
): { price: string | null; currency: string } {
|
||||
if (override.priceOverride != null) {
|
||||
return {
|
||||
price: override.priceOverride,
|
||||
currency: override.priceOverrideCurrency ?? base.priceCurrency,
|
||||
};
|
||||
}
|
||||
return { price: base.price, currency: base.priceCurrency };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, when `price` is null) the deal-specific price for one
|
||||
* (interest, berth). Tenant-scoped: the interest must belong to `portId`.
|
||||
* Does not touch `berths.price`.
|
||||
*/
|
||||
export async function setBerthPriceOverride(
|
||||
interestId: string,
|
||||
berthId: string,
|
||||
price: number | null,
|
||||
currency: string | null,
|
||||
portId: string,
|
||||
): Promise<void> {
|
||||
const interestRow = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
});
|
||||
if (!interestRow) throw new NotFoundError('Interest');
|
||||
await db
|
||||
.update(interestBerths)
|
||||
.set({
|
||||
priceOverride: price == null ? null : String(price),
|
||||
priceOverrideCurrency: price == null ? null : (currency ?? 'USD'),
|
||||
})
|
||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||
}
|
||||
|
||||
32
tests/unit/services/interest-berths-price.test.ts
Normal file
32
tests/unit/services/interest-berths-price.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { resolveBerthPriceForInterest } from '@/lib/services/interest-berths.service';
|
||||
|
||||
describe('resolveBerthPriceForInterest', () => {
|
||||
it('uses the override when present', () => {
|
||||
expect(
|
||||
resolveBerthPriceForInterest(
|
||||
{ priceOverride: '1000000', priceOverrideCurrency: 'EUR' },
|
||||
{ price: '3880800', priceCurrency: 'USD' },
|
||||
),
|
||||
).toEqual({ price: '1000000', currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the base list price when no override', () => {
|
||||
expect(
|
||||
resolveBerthPriceForInterest(
|
||||
{ priceOverride: null, priceOverrideCurrency: null },
|
||||
{ price: '3880800', priceCurrency: 'USD' },
|
||||
),
|
||||
).toEqual({ price: '3880800', currency: 'USD' });
|
||||
});
|
||||
|
||||
it('uses the base currency when the override currency is null', () => {
|
||||
expect(
|
||||
resolveBerthPriceForInterest(
|
||||
{ priceOverride: '900000', priceOverrideCurrency: null },
|
||||
{ price: '3880800', priceCurrency: 'USD' },
|
||||
),
|
||||
).toEqual({ price: '900000', currency: 'USD' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user