feat(berths): CM-2 — price-reconcile API (list + bulk apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
|
||||||
|
*
|
||||||
|
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
|
||||||
|
* integration tests can call it directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
approvals: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
berthId: z.string().min(1),
|
||||||
|
price: z.number().nonnegative(),
|
||||||
|
currency: z.string().min(1).max(8),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, bodySchema);
|
||||||
|
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { postHandler } from './handlers';
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('berths', 'edit', postHandler));
|
||||||
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
|
||||||
|
*
|
||||||
|
* In handlers.ts so integration tests can call them directly, bypassing the
|
||||||
|
* auth/permission middleware (per CLAUDE.md "Route handler exports").
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (_req, ctx) => {
|
||||||
|
try {
|
||||||
|
const data = await listPriceReconciliation(ctx.portId);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { getHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('berths', 'edit', getHandler));
|
||||||
@@ -21,7 +21,8 @@ import { db } from '@/lib/db';
|
|||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
|
||||||
import { makeBerth, makePort } from '../helpers/factories';
|
import { makeBerth, makeFullPermissions, makePort } from '../helpers/factories';
|
||||||
|
import { makeMockCtx, makeMockRequest } from '../helpers/route-tester';
|
||||||
|
|
||||||
const A1_PDF = readFileSync(path.join(process.cwd(), 'berth_pdf_example/Berth_Spec_Sheet_A1.pdf'));
|
const A1_PDF = readFileSync(path.join(process.cwd(), 'berth_pdf_example/Berth_Spec_Sheet_A1.pdf'));
|
||||||
|
|
||||||
@@ -84,3 +85,44 @@ describe('applyBulkBerthPrices', () => {
|
|||||||
expect(b!.price).toBeNull(); // untouched
|
expect(b!.price).toBeNull(); // untouched
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('price-reconcile route handlers', () => {
|
||||||
|
it('GET lists rows and POST apply writes the approved price', async () => {
|
||||||
|
const { getHandler } = await import('@/app/api/v1/berths/price-reconcile/handlers');
|
||||||
|
const { postHandler } = await import('@/app/api/v1/berths/price-reconcile/apply/handlers');
|
||||||
|
|
||||||
|
const port = await makePort();
|
||||||
|
const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } });
|
||||||
|
await uploadBerthPdf({
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
buffer: A1_PDF,
|
||||||
|
fileName: 'Berth_Spec_Sheet_A1.pdf',
|
||||||
|
uploadedBy: 'test-user',
|
||||||
|
});
|
||||||
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
|
|
||||||
|
const listRes = await getHandler(
|
||||||
|
makeMockRequest('GET', 'http://t/api/v1/berths/price-reconcile'),
|
||||||
|
ctx,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const listJson = (await listRes.json()) as {
|
||||||
|
data: Array<{ mooringNumber: string; parsedPrice: number | null }>;
|
||||||
|
};
|
||||||
|
expect(listJson.data.find((r) => r.mooringNumber === 'A1')?.parsedPrice).toBe(3880800);
|
||||||
|
|
||||||
|
const applyRes = await postHandler(
|
||||||
|
makeMockRequest('POST', 'http://t/api/v1/berths/price-reconcile/apply', {
|
||||||
|
body: { approvals: [{ berthId: berth.id, price: 3880800, currency: 'USD' }] },
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const applyJson = (await applyRes.json()) as { data: { updated: number } };
|
||||||
|
expect(applyJson.data.updated).toBe(1);
|
||||||
|
|
||||||
|
const [b] = await db.select().from(berths).where(eq(berths.id, berth.id));
|
||||||
|
expect(Number(b!.price)).toBe(3880800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user