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
82 changes: 54 additions & 28 deletions backend/src/__tests__/pnl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
* **차량 판매건별 손익** — 권한과 「달 가르기」.
*
* ① 돈에 관한 표라 **보는 것부터** 권한을 건다(`pnl.view`), 적는 것은 `pnl.manage`
* ② 「입력 필요」에 서는 시점은 **특장사 수락**이다 — 계약만 끝난 건은 아직 만들 차가 없다
* ② 「입력 필요」에 서는 시점은 **계약 체결**이다 — 전자서명 완료 또는 서면계약 스캔본 등록(2026-09-17)
* ③ 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다
* ④ 계약서 금액(공급가액·계약금·VAT)은 줄을 만들 때 채우고, 그 뒤로는 **사람이 고칠 때만** 바뀐다
* ⑤ 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다
Expand Down Expand Up @@ -42,12 +42,14 @@ async function contractedQuote(price = 1_000_000): Promise<number> {
return q.id;
}

/** 특장사가 **수락한** 건 — 여기서부터 「입력 필요」다 */
async function acceptedQuote(acceptedAt = new Date()): Promise<number> {
/**
* **계약이 체결된** 건 — 여기서부터 「입력 필요」다(2026-09-17).
* `method` 로 전자서명(EMAIL)과 서면계약 스캔본(PAPER)을 고른다 — 둘 다 같은 자리에 선다.
*/
async function signedQuote(signedAt = new Date(), method: 'EMAIL' | 'PAPER' = 'EMAIL'): Promise<number> {
const id = await contractedQuote();
await prisma!.quote.update({ where: { id }, data: { status: 'ordered' } });
await prisma!.order.create({
data: { quote_id: id, maker_org_id: 'ORG_BRAIN', assigned_at: acceptedAt, accepted_at: acceptedAt },
await prisma!.purchaseContract.create({
data: { quote_id: id, signing_method: method, status: 'COMPLETED', completed_at: signedAt, customer_snapshot: {} },
});
return id;
}
Expand Down Expand Up @@ -76,6 +78,7 @@ afterAll(async () => {
if (!prisma) return;
await prisma.orderPnl.deleteMany({ where: { quote_id: { in: quotes } } });
await prisma.order.deleteMany({ where: { quote_id: { in: quotes } } });
await prisma.purchaseContract.deleteMany({ where: { quote_id: { in: quotes } } });
await prisma.quote.deleteMany({ where: { id: { in: quotes } } });
await prisma.customer.deleteMany({ where: { id: customerId } });
await prisma.accessControl.deleteMany({ where: { subject_ref: { in: USERS } } });
Expand All @@ -90,33 +93,56 @@ describe.runIf(live)('손익 — 권한', () => {

it('🔴 보기 권한만 있으면 읽되 적지는 못한다', async () => {
expect((await request(app).get('/api/v1/pnl').set('Cookie', looker)).status).toBe(200);
const id = await acceptedQuote();
const id = await signedQuote();
const w = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', looker).send({ invoice_on: '2026-09-10' });
expect(w.status, '보기 권한으로 적혔다').toBe(403);
}, 30_000);
});

describe.runIf(live)('손익 — 언제부터 「입력 필요」인가', () => {
it('🔴 계약만 끝난 건은 아직 아니다 — **특장사가 수락해야** 선다', async () => {
const only = await contractedQuote();
const before = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
expect(before.body.data.pending.some((p: { quote_id: number }) => p.quote_id === only), '계약만 끝난 건이 벌써 떴다').toBe(false);

const accepted = await acceptedQuote();
const after = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
expect(after.body.data.pending.some((p: { quote_id: number }) => p.quote_id === accepted), '수락한 건이 안 떴다').toBe(true);
it('🔴 전자서명이 끝나면 선다 — 서명 요청만 보낸 건은 아직 아니다', async () => {
// 서명 요청만 나간 건(SENT) — 체결이 아니다
const sentOnly = await contractedQuote();
await prisma!.purchaseContract.create({
data: { quote_id: sentOnly, signing_method: 'EMAIL', status: 'SENT', sent_at: new Date(), customer_snapshot: {} },
});
// 계약 줄이 아예 없는 건(상태만 계약완료) — 체결 기록이 없으면 세우지 않는다
const noRow = await contractedQuote();

const signed = await signedQuote(new Date(), 'EMAIL');
const v = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
const ids = v.body.data.pending.map((p: { quote_id: number }) => p.quote_id);
expect(ids, '서명 요청만 보낸 건이 떴다').not.toContain(sentOnly);
expect(ids, '체결 기록이 없는 건이 떴다').not.toContain(noRow);
expect(ids, '전자서명이 끝난 건이 안 떴다').toContain(signed);
}, 30_000);

it('🔴 서면계약 스캔본을 올려도 선다 — 전자서명과 같은 자리', async () => {
const paper = await signedQuote(new Date(), 'PAPER');
const v = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
const p = v.body.data.pending.find((x: { quote_id: number }) => x.quote_id === paper);
expect(p, '서면계약 건이 안 떴다').toBeTruthy();
expect(p.contracted_on, '체결일이 안 실렸다').toMatch(/^\d{4}-\d{2}-\d{2}$/);
}, 30_000);

it('🔴 특장사 수락 여부와 상관없다 — 체결만 되면 선다', async () => {
const id = await signedQuote();
// 주문이 아예 없다(배정 전)
expect(await prisma!.order.findUnique({ where: { quote_id: id } })).toBeNull();
const v = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
expect(v.body.data.pending.some((p: { quote_id: number }) => p.quote_id === id)).toBe(true);
}, 30_000);

it('🔴 오래 기다린 것이 맨 위다 — 밀린 건이 아래로 묻히면 안 된다', async () => {
const old = await acceptedQuote(new Date('2020-01-02T00:00:00Z'));
it('🔴 오래 기다린 것이 맨 위다 — 체결일 기준, 밀린 건이 아래로 묻히면 안 된다', async () => {
const old = await signedQuote(new Date('2020-01-02T00:00:00Z'));
const v = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
expect(v.body.data.pending[0]?.quote_id, '가장 오래 기다린 건이 맨 위가 아니다').toBe(old);
}, 30_000);
});

describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다', () => {
it('🔴 발행일을 적으면 그 달 표로 들어가고 「입력 필요」에서 빠진다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();

const before = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper);
expect(before.status).toBe(200);
Expand All @@ -140,7 +166,7 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
}, 60_000);

it('🔴 발행일을 지우면 「입력 필요」로 돌아온다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-11' });
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: null });
const v = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper);
Expand All @@ -155,7 +181,7 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
}, 30_000);

it('🔴 적은 값은 그대로 남는다 — 비운 칸은 건드리지 않는다(부분 저장)', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-12', capital: 5_000_000, memo: '확인필요' });
// 비고만 고친다 — 금액이 0 으로 밀리면 안 된다
const r = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ memo: '확인완료' });
Expand All @@ -165,7 +191,7 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
}, 30_000);

it('🔴 자동 기입 칸도 사람이 고친다 — 공급가액·계약금·VAT(2026-09-17 지시)', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
const made = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-14' });
expect(made.body.data.vat_override, '처음에는 식을 쓴다').toBeNull();

Expand All @@ -186,21 +212,21 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
}, 30_000);

it('🔴 처음 만들 때 사람이 보낸 금액이 계약서 값을 이긴다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
const r = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper)
.send({ supply_amount: 1_234_000, deposit: 111_000 });
expect(r.body.data.supply_amount).toBe(1_234_000);
expect(r.body.data.deposit).toBe(111_000);
}, 30_000);

it('🔴 보기 권한만으로는 금액을 고치지 못한다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-14' });
expect((await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', looker).send({ supply_amount: 1 })).status).toBe(403);
}, 30_000);

it('🔴 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-11-03' });

// 사유 없이는 못 지운다
Expand All @@ -225,13 +251,13 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
}, 30_000);

it('🔴 보기 권한만으로는 지우지 못한다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-11-04' });
expect((await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', looker).send({ reason: 'x' })).status).toBe(403);
}, 30_000);

it('🔴 임시저장 — 발행일 없이 적어 두면 「입력 필요」에 남고, 다시 열면 그대로 열린다', async () => {
const id = await acceptedQuote();
const id = await signedQuote();
// 발행일 없이 저장한다(임시저장)
const draft = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper)
.send({ biz_name: '임시상사', capital: 7_777_000, memo: '확인 중', cost: 1_000_000 });
Expand Down Expand Up @@ -264,10 +290,10 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
const a0 = before.body.data.all_total.count as number;

// ① 이번 달에 한 건 — 둘 다 하나씩 는다
const now = await acceptedQuote();
const now = await signedQuote();
await request(app).put(`/api/v1/pnl/${now}`).set('Cookie', keeper).send({ invoice_on: `${ym}-15` });
// ② 아주 지난 달에 한 건 — **전체만** 는다
const old = await acceptedQuote();
const old = await signedQuote();
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);
Expand Down
16 changes: 12 additions & 4 deletions backend/src/scripts/pnl-dummy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* **로컬 확인용 더미** — 「입력 필요」에 설 건(특장사 수락 완료)을 만든다.
* **로컬 확인용 더미** — 「입력 필요」에 설 건(계약 체결 완료)을 만든다.
*
* ⚠️ 로컬에서 손으로만 돌린다(배포·시드에 들어가지 않는다). 운영 DB 에는 절대 돌리지 말 것.
* `npx tsx src/scripts/pnl-dummy.ts [개수]`
Expand Down Expand Up @@ -35,16 +35,24 @@ async function main() {
},
select: { id: true },
});
// 수락일을 하루씩 벌려 둔다 — 「오래된 것이 맨 위」를 눈으로 확인할 수 있게
const acceptedAt = new Date(Date.now() - (i + 1) * 86_400_000);
// 체결일을 하루씩 벌려 둔다 — 「오래된 것이 맨 위」를 눈으로 확인할 수 있게
const signedAt = new Date(Date.now() - (i + 1) * 86_400_000);
// 「입력 필요」는 **체결된 계약 줄**을 본다 — 짝수는 전자서명, 홀수는 서면계약으로 섞는다
await prisma.purchaseContract.create({
data: {
quote_id: quote.id, signing_method: i % 2 === 0 ? 'EMAIL' : 'PAPER',
status: 'COMPLETED', completed_at: signedAt, customer_snapshot: { name },
},
});
const acceptedAt = signedAt;
await prisma.order.create({
data: {
quote_id: quote.id, maker_org_id: 'ORG_BRAIN',
assigned_at: acceptedAt, accepted_at: acceptedAt,
delivery_due: new Date(Date.now() + 20 * 86_400_000),
},
});
console.log(` · 견적 #${quote.id} · ${name} · 수락 ${acceptedAt.toISOString().slice(0, 10)}`);
console.log(` · 견적 #${quote.id} · ${name} · 체결 ${signedAt.toISOString().slice(0, 10)}`);
}
console.log(`더미 ${n}건을 「입력 필요」에 세웠다.`);
}
Expand Down
33 changes: 21 additions & 12 deletions backend/src/services/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export interface PnlPending {
quote_id: number;
quote_no: string | null;
customer: string | null;
/** 특장사가 **수락한** 날 — 이때부터 「입력 필요」에 선다 */
accepted_on: string | null;
/** 계약이 **체결된** 날(전자서명 완료 또는 서면계약 스캔본 등록) — 이때부터 「입력 필요」에 선다 */
contracted_on: string | null;
/** 계약서에서 가져온 값들 — 「입력 필요」 칸에 그대로 보여 준다(고칠 수 없다) */
supply_default: number | null;
deposit_default: number;
Expand Down Expand Up @@ -172,31 +172,40 @@ export async function contractDefaults(quoteId: number): Promise<{ supply: numbe
const DEFAULTS_LIMIT = 60;

/**
* 아직 발행일을 안 적은 건 — **특장사가 수락한 뒤부터** 여기 선다(2026-09-16 지시).
* 아직 발행일을 안 적은 건 — **계약이 체결된 뒤부터** 여기 선다(2026-09-17 지시).
*
* 계약만 끝난 건은 아직 만들지도 않은 차라 세금계산서를 끊을 일이 없다.
* 특장사가 수락하면 그때부터 실제로 만들기 시작하므로, 그 시점을 기준으로 삼는다.
* 체결 = 전자서명 완료 **또는** 서면계약 스캔본 등록. 두 길 모두 계약 줄을 `COMPLETED` 로 남기고
* 체결 시각(`completed_at`)을 적는다 — 그 줄 하나로 두 길을 함께 본다.
* (그 전에는 특장사 수락 시점이었다. 계약만 끝나도 세금계산서 준비는 시작되므로 앞당겼다.)
*
* ⚠️ 견적 상태(contracted 이상)로 고르지 않는다 — 상태는 되돌리기·재배정으로 오가지만,
* **체결됐다는 사실**은 계약 줄에만 정확히 남는다.
*
* **오래된 것이 맨 위**다 — 먼저 처리할 순서이고, 밀린 건이 아래로 묻히면 안 된다.
*/
export async function pnlPending(): Promise<PnlPending[]> {
if (!prisma) return [];
const quotes = await prisma.quote.findMany({
const found = await prisma.quote.findMany({
where: {
...VISIBLE,
// 수락한 주문만 — 취소(배정 취소·주문 삭제)된 건은 수락 기록이 지워지거나 canceled 로 남는다
order: { is: { accepted_at: { not: null }, canceled_at: null } },
contracts: { some: { status: 'COMPLETED' } },
OR: [{ pnl: { is: null } }, { pnl: { invoice_on: null } }],
},
select: {
id: true, quote_no: true, created_at: true,
customer: { select: { name: true } },
order: { select: { accepted_at: true } },
// 체결 시각 — 여러 번 체결됐으면(재계약) 가장 이른 것이 기다린 시간이다
contracts: {
where: { status: 'COMPLETED' },
orderBy: { completed_at: 'asc' }, take: 1,
select: { completed_at: true },
},
pnl: { select: { supply_amount: true, deposit: true, vat_override: true, biz_name: true, capital: true, deposit_paid_on: true, capital_paid_on: true, cost: true, memo: true } },
},
// 수락이 오래된 것부터 — 화면에서 다시 정렬하지 않게 여기서 못 박는다
orderBy: { order: { accepted_at: 'asc' } },
});
// 계약 줄의 시각으로 줄 세운다(관계 칸으로는 DB 가 정렬하지 못한다) — 시각이 없으면 견적을 만든 날
const signedAt = (q: typeof found[number]) => (q.contracts[0]?.completed_at ?? q.created_at).getTime();
const quotes = [...found].sort((a, b) => signedAt(a) - signedAt(b) || a.id - b.id);

const out: PnlPending[] = [];
for (const q of quotes) {
Expand All @@ -212,7 +221,7 @@ export async function pnlPending(): Promise<PnlPending[]> {
quote_id: q.id,
quote_no: q.quote_no,
customer: q.customer?.name ?? null,
accepted_on: day(q.order?.accepted_at ?? null) ?? day(q.created_at),
contracted_on: day(q.contracts[0]?.completed_at ?? null) ?? day(q.created_at),
supply_default: supply,
deposit_default: deposit,
draft: kept ? {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export interface PnlPending {
quote_id: number
quote_no: string | null
customer: string | null
/** 특장사가 **수락한** 날 — 이때부터 「입력 필요」에 선다 */
accepted_on: string | null
/** 계약이 **체결된** 날(전자서명 완료 또는 서면계약 스캔본 등록) — 이때부터 「입력 필요」에 선다 */
contracted_on: string | null
supply_default: number | null
deposit_default: number
/** 임시저장해 둔 값 — 다시 열면 적어 둔 대로 열린다. 없으면 null */
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/PnlTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,8 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft }
<span style={s.caret}>{open ? '▾' : '▸'}</span>
<span style={s.pendNo}>{item.quote_no ?? `#${item.quote_id}`}</span>
<span style={s.pendName}>{item.customer ?? '—'}</span>
{/* 특장사가 수락한 날 — 이 순서(오래된 것 먼저)가 곧 처리 순서다 */}
<span style={s.pendSub}>{tf('수락 {0}', item.accepted_on ?? '—')}</span>
{/* 계약이 체결된 날 — 이 순서(오래된 것 먼저)가 곧 처리 순서다 */}
<span style={s.pendSub}>{tf('계약 {0}', item.contracted_on ?? '—')}</span>
<span style={s.pendAmount}>{item.supply_default === null ? '—' : won(item.supply_default)}</span>
{!open && <span style={s.pendCta}>{t('입력')}</span>}
</button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1320,9 +1320,9 @@ export const EN: Record<string, string> = {
* 쓰는 법: tf('{0} 대화', name)
*/
export const EN_FMT: Record<string, string> = {
'계약 {0}': 'Signed {0}',
'{0} 손익 삭제': 'Void P&L · {0}',
'삭제 {0}건': '{0} voided',
'수락 {0}': 'Accepted {0}',
'{0}일 경과': '{0}d over',
'{0}일 남음': '{0}d left',
'{0} 일정': 'Schedule for {0}',
Expand Down
Loading