diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts index 7685cf8..e476548 100644 --- a/backend/src/__tests__/pnl.test.ts +++ b/backend/src/__tests__/pnl.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; * **차량 판매건별 손익** — 권한과 「달 가르기」. * * ① 돈에 관한 표라 **보는 것부터** 권한을 건다(`pnl.view`), 적는 것은 `pnl.manage` - * ② 「입력 필요」에 서는 시점은 **특장사 수락**이다 — 계약만 끝난 건은 아직 만들 차가 없다 + * ② 「입력 필요」에 서는 시점은 **계약 체결**이다 — 전자서명 완료 또는 서면계약 스캔본 등록(2026-09-17) * ③ 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다 * ④ 계약서 금액(공급가액·계약금·VAT)은 줄을 만들 때 채우고, 그 뒤로는 **사람이 고칠 때만** 바뀐다 * ⑤ 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다 @@ -42,12 +42,14 @@ async function contractedQuote(price = 1_000_000): Promise { return q.id; } -/** 특장사가 **수락한** 건 — 여기서부터 「입력 필요」다 */ -async function acceptedQuote(acceptedAt = new Date()): Promise { +/** + * **계약이 체결된** 건 — 여기서부터 「입력 필요」다(2026-09-17). + * `method` 로 전자서명(EMAIL)과 서면계약 스캔본(PAPER)을 고른다 — 둘 다 같은 자리에 선다. + */ +async function signedQuote(signedAt = new Date(), method: 'EMAIL' | 'PAPER' = 'EMAIL'): Promise { 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; } @@ -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 } } }); @@ -90,25 +93,48 @@ 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); @@ -116,7 +142,7 @@ describe.runIf(live)('손익 — 언제부터 「입력 필요」인가', () => 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); @@ -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); @@ -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: '확인완료' }); @@ -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(); @@ -186,7 +212,7 @@ 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); @@ -194,13 +220,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-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' }); // 사유 없이는 못 지운다 @@ -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 }); @@ -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); diff --git a/backend/src/scripts/pnl-dummy.ts b/backend/src/scripts/pnl-dummy.ts index 43f0ddb..9a48228 100644 --- a/backend/src/scripts/pnl-dummy.ts +++ b/backend/src/scripts/pnl-dummy.ts @@ -1,5 +1,5 @@ /** - * **로컬 확인용 더미** — 「입력 필요」에 설 건(특장사 수락 완료)을 만든다. + * **로컬 확인용 더미** — 「입력 필요」에 설 건(계약 체결 완료)을 만든다. * * ⚠️ 로컬에서 손으로만 돌린다(배포·시드에 들어가지 않는다). 운영 DB 에는 절대 돌리지 말 것. * `npx tsx src/scripts/pnl-dummy.ts [개수]` @@ -35,8 +35,16 @@ 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', @@ -44,7 +52,7 @@ async function main() { 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}건을 「입력 필요」에 세웠다.`); } diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index 5eb28df..045980a 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -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; @@ -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 { 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) { @@ -212,7 +221,7 @@ export async function pnlPending(): Promise { 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 ? { diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index 50759de..5034a12 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -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 */ diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx index b8111c3..7a67013 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -353,8 +353,8 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft } {open ? '▾' : '▸'} {item.quote_no ?? `#${item.quote_id}`} {item.customer ?? '—'} - {/* 특장사가 수락한 날 — 이 순서(오래된 것 먼저)가 곧 처리 순서다 */} - {tf('수락 {0}', item.accepted_on ?? '—')} + {/* 계약이 체결된 날 — 이 순서(오래된 것 먼저)가 곧 처리 순서다 */} + {tf('계약 {0}', item.contracted_on ?? '—')} {item.supply_default === null ? '—' : won(item.supply_default)} {!open && {t('입력')}} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 19f0266..b86333f 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -1320,9 +1320,9 @@ export const EN: Record = { * 쓰는 법: tf('{0} 대화', name) */ export const EN_FMT: Record = { + '계약 {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}',