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'),
|
addedBy: text('added_by'),
|
||||||
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
notes: text('notes'),
|
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) => [
|
(table) => [
|
||||||
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
|
|||||||
addedBy: interestBerths.addedBy,
|
addedBy: interestBerths.addedBy,
|
||||||
addedAt: interestBerths.addedAt,
|
addedAt: interestBerths.addedAt,
|
||||||
notes: interestBerths.notes,
|
notes: interestBerths.notes,
|
||||||
|
priceOverride: interestBerths.priceOverride,
|
||||||
|
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
|
||||||
mooringNumber: berths.mooringNumber,
|
mooringNumber: berths.mooringNumber,
|
||||||
area: berths.area,
|
area: berths.area,
|
||||||
status: berths.status,
|
status: berths.status,
|
||||||
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
|
|||||||
.delete(interestBerths)
|
.delete(interestBerths)
|
||||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
.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