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 { 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'));
|
||||
|
||||
@@ -84,3 +85,44 @@ describe('applyBulkBerthPrices', () => {
|
||||
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