Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions backend/src/__tests__/pnl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,36 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
expect((await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', looker).send({ reason: 'x' })).status).toBe(403);
}, 30_000);

it('🔴 요약 — 이번 달과 전체를 한 번에 주고, 삭제한 줄은 빼고 센다', async () => {
const ym = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 7);
const before = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper);
expect(before.status, JSON.stringify(before.body)).toBe(200);
expect(before.body.data.month).toBe(ym);
const m0 = before.body.data.month_total.count as number;
const a0 = before.body.data.all_total.count as number;

// ① 이번 달에 한 건 — 둘 다 하나씩 는다
const now = await acceptedQuote();
await request(app).put(`/api/v1/pnl/${now}`).set('Cookie', keeper).send({ invoice_on: `${ym}-15` });
// ② 아주 지난 달에 한 건 — **전체만** 는다
const old = await acceptedQuote();
await request(app).put(`/api/v1/pnl/${old}`).set('Cookie', keeper).send({ invoice_on: '2020-03-04' });

const after = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper);
expect(after.body.data.month_total.count, '이번 달에 지난 달 건이 섞였다').toBe(m0 + 1);
expect(after.body.data.all_total.count, '전체가 지난 달 건을 놓쳤다').toBe(a0 + 2);

// ③ 삭제하면 둘 다 빠진다 — 줄은 표에 남지만 숫자는 아니다
await request(app).post(`/api/v1/pnl/${now}/void`).set('Cookie', keeper).send({ reason: '시험' });
const dead = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper);
expect(dead.body.data.month_total.count).toBe(m0);
expect(dead.body.data.all_total.count).toBe(a0 + 1);
}, 60_000);

it('🔴 요약도 보기 권한이 있어야 본다', async () => {
expect((await request(app).get('/api/v1/pnl/summary').set('Cookie', outside)).status).toBe(403);
}, 30_000);

it('🔴 없는 견적에는 줄을 만들지 않는다', async () => {
const r = await request(app).put('/api/v1/pnl/99999999').set('Cookie', keeper).send({ invoice_on: '2026-09-13' });
expect(r.status).toBe(404);
Expand Down
11 changes: 10 additions & 1 deletion backend/src/routes/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Router } from 'express';
import type { Request, Response } from 'express';
import { rbac, requirePermission } from '../middleware/rbac.js';
import { isYearMonth } from '@buildup-ev/shared/finance/pnl';
import { pnlOfMonth, pnlPending, pnlMonths, savePnl, voidPnl, unvoidPnl, type PnlPatch } from '../services/pnl.js';
import { pnlOfMonth, pnlPending, pnlMonths, pnlSummary, savePnl, voidPnl, unvoidPnl, type PnlPatch } from '../services/pnl.js';

export const pnlRouter = Router();

Expand Down Expand Up @@ -38,6 +38,15 @@ pnlRouter.get('/', ...view, async (req: Request, res: Response): Promise<void> =
} catch (e) { fail(res, 'GET /', e); }
});

// ── GET /pnl/summary — 대시보드 카드용 요약(이번 달 + 전체) ────────────────
// ⚠️ `/:quoteId` 보다 **먼저** 둔다. 뒤에 두면 'summary' 가 견적 id 로 읽힌다
pnlRouter.get('/summary', ...view, async (req: Request, res: Response): Promise<void> => {
try {
const q = req.query['month'];
res.json({ data: await pnlSummary(isYearMonth(q) ? q : thisMonth()) });
} catch (e) { fail(res, 'GET /summary', e); }
});

// ── PUT /pnl/:quoteId — 줄 하나를 적는다(없으면 만든다) ──────────────────────
pnlRouter.put('/:quoteId', ...manage, async (req: Request, res: Response): Promise<void> => {
const quoteId = Number(req.params['quoteId']);
Expand Down
41 changes: 40 additions & 1 deletion backend/src/services/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/
import { prisma } from '../lib/prisma.js';
import { calcQuote } from '@buildup-ev/shared/pricing';
import { monthBounds, supplyFromGross } from '@buildup-ev/shared/finance/pnl';
import { monthBounds, supplyFromGross, sumP } from '@buildup-ev/shared/finance/pnl';
import { buildQuoteParams, quoteExtraFromInputs, type CustomerInput } from './quote-calc.js';
import { VISIBLE } from '../lib/visibility.js';

Expand Down Expand Up @@ -299,6 +299,45 @@ async function readPnl(quoteId: number): Promise<PnlRow | null> {
return saved ? toRow(saved as unknown as RowWithQuote) : null;
}

/**
* **요약만** — 대시보드 카드가 쓴다.
*
* 이번 달과 전체를 **한 번에** 준다. 토글을 누를 때마다 다시 부르면 기다림이 생기는데,
* 요약은 줄을 싣지 않아 가볍다(줄 수백 개를 내려보내고 화면에서 더하는 쪽이 오히려 무겁다).
*
* ⚠️ 삭제한 줄은 빼고 센다. 셈은 손익 탭과 **같은 함수**(`sumP`)다 — 두 화면 숫자가 갈리면 안 된다.
*/
export interface PnlSummary {
month: string;
/** 발행일을 아직 안 적은 건 수 — 카드에서 「지금 할 일」이다 */
pending: number;
month_total: ReturnType<typeof sumP>;
all_total: ReturnType<typeof sumP>;
}

export async function pnlSummary(ym: string): Promise<PnlSummary> {
const empty = sumP([]);
if (!prisma) return { month: ym, pending: 0, month_total: empty, all_total: empty };
const { from, to } = monthBounds(ym);
const pick = { supply_amount: true, deposit: true, capital: true, cost: true, invoice_on: true } as const;
const [rows, pending] = await Promise.all([
// 삭제한 줄과 발행일이 없는 줄은 어느 달의 숫자도 아니다
prisma.orderPnl.findMany({ where: { voided_at: null, invoice_on: { not: null } }, select: pick }),
pnlPending(),
]);
const num = (r: typeof rows[number]) => ({
supply_amount: n(r.supply_amount), deposit: n(r.deposit), capital: n(r.capital), cost: n(r.cost),
});
const inMonth = (r: typeof rows[number]) =>
!!r.invoice_on && r.invoice_on >= new Date(from) && r.invoice_on < new Date(to);
return {
month: ym,
pending: pending.length,
month_total: sumP(rows.filter(inMonth).map(num)),
all_total: sumP(rows.map(num)),
};
}

/** 줄이 있는 달들 — 화면의 달 고르개가 「있는 달」만 보여 준다 */
export async function pnlMonths(): Promise<string[]> {
if (!prisma) return [];
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/api/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,32 @@ async function jsonOf<T>(res: Response): Promise<T> {
return (await res.json() as { data: T }).data
}

/** 합계 한 벌 — 손익 탭의 합계와 같은 셈이다(서버가 같은 함수로 낸다) */
export interface PnlTotals {
count: number
supply_amount: number
vat: number
gross: number
deposit: number
capital: number
pay_diff: number
cost: number
profit: number
margin: number | null
}

/** 대시보드 카드용 — 이번 달과 전체를 **한 번에** 받는다(토글이 기다리지 않게) */
export interface PnlSummary {
month: string
pending: number
month_total: PnlTotals
all_total: PnlTotals
}

export function fetchPnlSummary(): Promise<PnlSummary> {
return fetch('/api/v1/pnl/summary', { credentials: 'include' }).then(jsonOf<PnlSummary>)
}

export function fetchPnl(month?: string): Promise<PnlView> {
const qs = month ? `?month=${encodeURIComponent(month)}` : ''
return fetch(`/api/v1/pnl${qs}`, { credentials: 'include' }).then(jsonOf<PnlView>)
Expand Down
Loading
Loading