From c49cf4adbbb6777f8e46b5ca4d682656dd4bc9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Thu, 17 Sep 2026 10:17:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(pnl):=20=EC=9E=90=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EC=9E=85=20=EC=B9=B8=EB=8F=84=20=EA=B3=A0=EC=B9=A0=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EA=B2=8C=20=E2=80=94=20=EB=B0=94=ED=83=95=EC=83=89?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=EB=A7=8C=20=EC=A7=81=EC=A0=91=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B3=BC=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 무엇이 바뀌나(지시) 공급가액 · VAT · 공급대가 · 계약금을 **「입력 필요」 폼과 아래 월별 표(휴대폰 카드 포함) 모두에서** 고친다. 실제 세금계산서가 계약과 다른 일이 있었다. 한 번 계약서에서 채우고, 그 뒤로는 **사람이 고칠 때만** 바뀐다 — 단가표를 나중에 고쳐도 이미 나간 줄이 저절로 다시 계산되지는 않는다(이건 그대로다). 고객명 · 입금 차액 · 수익 · 수익률은 그대로 글자다(고객 기록·계산 결과라 고칠 대상이 아니다). ## 계산값을 고치는 법 VAT·공급대가는 **저장하지 않는 계산값**이라, 고치면 뿌리를 바꾼다. · **공급가액**을 고치면 VAT 는 식(10% 버림)으로 돌아가 따라온다 · **VAT** 를 고치면 그 값을 붙잡아 둔다(`vat_override`) — 계산서가 반올림이면 1원이 다르다. 식과 같은 값이면 붙잡지 않는다(그래야 공급가액을 바꿀 때 따라온다) · **공급대가**를 고치면 공급가액을 되짚고, 1원이 남으면 VAT 로 돌린다 — **적은 공급대가가 한 원도 다르지 않게** 그대로 보인다 ## 직접 입력과 자동 기입의 구분 **바탕 하나로만** 가른다 — 직접 적는 칸은 흰 바탕, 자동 기입 칸은 앱의 카드 배경(`--card`). 테두리·높이·글자는 같다. `transparent` 로 두면 흰 표 위에서 흰색이 되어 구분이 사라져서(실제로 그랬다) 색을 못 박았다. 곁들여, 앱에 없는 토큰(`--soft`)을 짐작한 회색으로 쓰던 자리를 `--card` 로 바꿨다. ## DB `order_pnl.vat_override` 칸을 **더한다**(null = 식). 기존 칸·줄은 건드리지 않는다. ## 검사 · shared — VAT 를 고치면 공급대가·입금 차액·합계가 따른다 · 식과 같은 VAT 는 붙잡지 않는다 · 공급대가를 고치면 적은 값 그대로 보인다(여러 금액으로 한 원까지) · 백엔드 — 자동 칸을 고친다 · null 로 식에 돌아간다 · 처음 만들 때 보낸 금액이 계약서 값을 이긴다 · 보기 권한으로는 못 고친다 (예전의 「계약서 금액은 굳는다 — 보내도 안 바뀐다」 검사는 **지시가 바뀌어** 새 규칙으로 갈았다) Co-Authored-By: Claude Opus 5 --- .../migration.sql | 5 + backend/prisma/schema.prisma | 2 + backend/src/__tests__/pnl.test.ts | 42 ++++-- backend/src/services/pnl.ts | 31 +++-- frontend/src/api/pnl.ts | 8 +- frontend/src/components/PnlTab.tsx | 125 ++++++++++++++---- shared/finance/pnl.test.ts | 34 ++++- shared/finance/pnl.ts | 41 +++++- 8 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 backend/prisma/migrations/20260917010000_pnl_vat_override/migration.sql diff --git a/backend/prisma/migrations/20260917010000_pnl_vat_override/migration.sql b/backend/prisma/migrations/20260917010000_pnl_vat_override/migration.sql new file mode 100644 index 0000000..706bc37 --- /dev/null +++ b/backend/prisma/migrations/20260917010000_pnl_vat_override/migration.sql @@ -0,0 +1,5 @@ +-- 손익 — 자동 기입 칸도 사람이 고칠 수 있게 됐다(2026-09-17 지시). +-- 공급가액·계약금은 이미 칸이 있다. VAT 는 **저장하지 않는 계산값**이라, 고친 값을 담을 칸만 더한다. +-- 비어 있으면(null) 예전처럼 엑셀 식(공급가액 × 10% 버림)을 쓴다. 기존 줄은 모두 null 로 시작한다. +-- ⚠️ 칸을 더하기만 한다 — 기존 칸·줄은 건드리지 않는다. +ALTER TABLE "order_pnl" ADD COLUMN IF NOT EXISTS "vat_override" BIGINT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fcdf4d0..25518fb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -480,6 +480,8 @@ model OrderPnl { /// 공급가액(VAT 별도)·계약금 — **계약서에서 가져와 굳힌다.** 화면에서 고치지 않는다(2026-09-16 지시) supply_amount BigInt @default(0) deposit BigInt @default(0) + /// 손으로 고친 VAT — 없으면 엑셀 식(공급가액 × 10% 버림)을 쓴다. 세금계산서가 반올림이면 1원이 다르다 + vat_override BigInt? /// 캐피탈 — 경영관리가 직접 적는다 capital BigInt @default(0) diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts index 76eb677..7685cf8 100644 --- a/backend/src/__tests__/pnl.test.ts +++ b/backend/src/__tests__/pnl.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; * ① 돈에 관한 표라 **보는 것부터** 권한을 건다(`pnl.view`), 적는 것은 `pnl.manage` * ② 「입력 필요」에 서는 시점은 **특장사 수락**이다 — 계약만 끝난 건은 아직 만들 차가 없다 * ③ 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다 - * ④ 계약서 금액(공급가액·계약금)은 **굳는다** — 화면에서도 서버에서도 못 고친다 + * ④ 계약서 금액(공급가액·계약금·VAT)은 줄을 만들 때 채우고, 그 뒤로는 **사람이 고칠 때만** 바뀐다 * ⑤ 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다 */ process.env['ALLOW_TEST_PERMISSION_BYPASS'] = 'false'; @@ -164,17 +164,39 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다', expect(r.body.data.invoice_on).toBe('2026-09-12'); }, 30_000); - it('🔴 계약서 금액은 굳는다 — 보내도 안 바뀐다(화면에서 빼는 것만으로는 막은 것이 아니다)', async () => { + it('🔴 자동 기입 칸도 사람이 고친다 — 공급가액·계약금·VAT(2026-09-17 지시)', async () => { const id = await acceptedQuote(); const made = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-14' }); - const supply = made.body.data.supply_amount as number; - const deposit = made.body.data.deposit as number; - - const hack = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper) - .send({ supply_amount: 1, deposit: 2, biz_name: '바꿔치기' }); - expect(hack.status).toBe(200); - expect(hack.body.data.supply_amount, '공급가액이 바뀌었다').toBe(supply); - expect(hack.body.data.deposit, '계약금이 바뀌었다').toBe(deposit); + expect(made.body.data.vat_override, '처음에는 식을 쓴다').toBeNull(); + + // 실제 세금계산서가 계약과 달랐다 — 고친다 + const fix = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper) + .send({ supply_amount: 18_463_636, deposit: 500_000, vat_override: 1_846_364 }); + expect(fix.status, JSON.stringify(fix.body)).toBe(200); + expect(fix.body.data.supply_amount).toBe(18_463_636); + expect(fix.body.data.deposit).toBe(500_000); + expect(fix.body.data.vat_override).toBe(1_846_364); + + // VAT 를 비우면(null) 식으로 돌아간다 + const back = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ vat_override: null }); + expect(back.body.data.vat_override).toBeNull(); + // 다른 칸만 고쳐도 고친 금액은 그대로다(부분 저장) + const memo = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ memo: 'x' }); + expect(memo.body.data.supply_amount).toBe(18_463_636); + }, 30_000); + + it('🔴 처음 만들 때 사람이 보낸 금액이 계약서 값을 이긴다', async () => { + const id = await acceptedQuote(); + 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(); + 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 () => { diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index b2e403a..5eb28df 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -26,6 +26,8 @@ export interface PnlRow { invoice_on: string | null; supply_amount: number; deposit: number; + /** 손으로 고친 VAT — null 이면 식(버림 10%) */ + vat_override: number | null; capital: number; deposit_paid_on: string | null; capital_paid_on: string | null; @@ -55,6 +57,7 @@ export interface PnlPending { */ draft: { biz_name: string | null; + vat_override: number | null; capital: number; deposit_paid_on: string | null; capital_paid_on: string | null; @@ -69,7 +72,7 @@ const day = (d: Date | null | undefined): string | null => (d ? d.toISOString(). /** DB 행 + 견적을 화면 모양으로 */ type RowWithQuote = { quote_id: number; invoice_on: Date | null; biz_name: string | null; - supply_amount: bigint; deposit: bigint; capital: bigint; + supply_amount: bigint; deposit: bigint; vat_override: bigint | null; capital: bigint; deposit_paid_on: Date | null; capital_paid_on: Date | null; cost: bigint; cost_source: string; memo: string | null; updated_by: string | null; voided_at: Date | null; voided_by: string | null; void_reason: string | null; @@ -91,6 +94,7 @@ function toRow(r: RowWithQuote): PnlRow { invoice_on: day(r.invoice_on), supply_amount: n(r.supply_amount), deposit: n(r.deposit), + vat_override: r.vat_override === null ? null : n(r.vat_override), capital: n(r.capital), deposit_paid_on: day(r.deposit_paid_on), capital_paid_on: day(r.capital_paid_on), @@ -188,7 +192,7 @@ export async function pnlPending(): Promise { id: true, quote_no: true, created_at: true, customer: { select: { name: true } }, order: { select: { accepted_at: true } }, - pnl: { select: { supply_amount: true, deposit: true, biz_name: true, capital: true, deposit_paid_on: true, capital_paid_on: true, cost: true, memo: 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' } }, @@ -213,6 +217,7 @@ export async function pnlPending(): Promise { deposit_default: deposit, draft: kept ? { biz_name: kept.biz_name, + vat_override: kept.vat_override === null ? null : n(kept.vat_override), capital: n(kept.capital), deposit_paid_on: day(kept.deposit_paid_on), capital_paid_on: day(kept.capital_paid_on), @@ -228,13 +233,17 @@ export async function pnlPending(): Promise { /** * 화면이 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장). * - * ⚠️ **공급가액·계약금은 여기 없다.** 계약서에서 가져와 줄을 만들 때 굳히고, 그 뒤로는 고치지 않는다 - * (2026-09-16 지시 — 고쳐야 하는 예외가 생기면 그때 다시 본다). 화면에서 빼는 것만으로는 - * 막은 것이 아니라 **서버가 받지 않는다.** + * **자동 기입 칸(공급가액·계약금·VAT)도 고칠 수 있다**(2026-09-17 지시 — 실제 계산서가 계약과 다른 일이 있었다). + * 다만 **저절로 다시 계산되지는 않는다** — 줄을 만들 때 계약서에서 한 번 채우고, 그 뒤로 바뀌는 것은 + * 사람이 고칠 때뿐이다. 단가표를 나중에 고쳐도 이미 나간 줄이 소급해 바뀌지 않는다. */ export interface PnlPatch { invoice_on?: string | null; biz_name?: string | null; + supply_amount?: number; + deposit?: number; + /** null 을 보내면 식(버림 10%)으로 되돌린다 */ + vat_override?: number | null; capital?: number; deposit_paid_on?: string | null; capital_paid_on?: string | null; @@ -269,6 +278,9 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro const put = (key: string, v: unknown) => { if (v !== undefined) data[key] = v; }; put('invoice_on', dateOf(patch.invoice_on)); put('biz_name', text(patch.biz_name, 120)); + put('supply_amount', money(patch.supply_amount)); + put('deposit', money(patch.deposit)); + put('vat_override', patch.vat_override === null ? null : money(patch.vat_override)); put('capital', money(patch.capital)); put('deposit_paid_on', dateOf(patch.deposit_paid_on)); put('capital_paid_on', dateOf(patch.capital_paid_on)); @@ -276,10 +288,10 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro put('memo', text(patch.memo, 500)); if (!exists) { - // 계약서 금액은 **줄을 만들 때 한 번** 굳힌다 — 그 뒤로는 어떤 요청도 이 둘을 못 바꾼다 + // 처음 만들 때 계약서에서 채운다 — 사람이 함께 보낸 값이 있으면 그 값이 이긴다(고친 것이다) const d = await contractDefaults(quoteId); - data['supply_amount'] = BigInt(d.supply ?? 0); - data['deposit'] = BigInt(d.deposit); + if (data['supply_amount'] === undefined) data['supply_amount'] = BigInt(d.supply ?? 0); + if (data['deposit'] === undefined) data['deposit'] = BigInt(d.deposit); await prisma.orderPnl.create({ data: { quote_id: quoteId, ...data } as never }); } else { await prisma.orderPnl.update({ where: { quote_id: quoteId }, data: data as never }); @@ -339,7 +351,7 @@ export async function pnlSummary(ym: string): Promise { 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 pick = { supply_amount: true, deposit: true, vat_override: 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 }), @@ -347,6 +359,7 @@ export async function pnlSummary(ym: string): Promise { ]); const num = (r: typeof rows[number]) => ({ supply_amount: n(r.supply_amount), deposit: n(r.deposit), capital: n(r.capital), cost: n(r.cost), + vat_override: r.vat_override === null ? null : n(r.vat_override), }); const inMonth = (r: typeof rows[number]) => !!r.invoice_on && r.invoice_on >= new Date(from) && r.invoice_on < new Date(to); diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index 2983f65..50759de 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -13,6 +13,8 @@ export interface PnlRow { invoice_on: string | null supply_amount: number deposit: number + /** 손으로 고친 VAT — null 이면 식(공급가액 × 10% 버림) */ + vat_override: number | null capital: number deposit_paid_on: string | null capital_paid_on: string | null @@ -40,6 +42,7 @@ export interface PnlPending { /** 임시저장해 둔 값 — 다시 열면 적어 둔 대로 열린다. 없으면 null */ draft: { biz_name: string | null + vat_override: number | null capital: number deposit_paid_on: string | null capital_paid_on: string | null @@ -58,10 +61,11 @@ export interface PnlView { /** * 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장). - * ⚠️ **공급가액·계약금은 없다** — 계약서에서 가져와 굳힌 값이라 고치지 않는다(서버도 받지 않는다). + * 자동 기입 칸(공급가액·계약금·VAT)도 고칠 수 있다(2026-09-17). `vat_override: null` 은 식으로 되돌린다. */ export type PnlPatch = Partial> async function jsonOf(res: Response): Promise { diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx index 527a625..b8111c3 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { t, tf } from '../i18n' import { fetchPnl, savePnl, voidPnl, unvoidPnl, type PnlRow, type PnlPending, type PnlPatch } from '../api/pnl' -import { deriveP, sumP, monthOf } from '@shared/finance/pnl' +import { deriveP, sumP, monthOf, fromGross, vatOverrideFor } from '@shared/finance/pnl' import { usePermission } from './PermGate' import { useIsMobile } from '../hooks/useIsMobile' import { useEscapeClose } from '../lib/escClose' @@ -87,7 +87,8 @@ export function PnlTab() { ? { ...p, supply_default: saved.supply_amount, deposit_default: saved.deposit, draft: { - biz_name: saved.biz_name, capital: saved.capital, cost: saved.cost, memo: saved.memo, + biz_name: saved.biz_name, vat_override: saved.vat_override, + capital: saved.capital, cost: saved.cost, memo: saved.memo, deposit_paid_on: saved.deposit_paid_on, capital_paid_on: saved.capital_paid_on, }, } @@ -270,6 +271,10 @@ interface Draft { invoice_on: string /** 사업자명 — 적을 수도, 안 적을 수도 있다 */ biz_name: string + /** 자동 기입(계약서) — 고칠 수 있다 */ + supply_amount: number + deposit: number + vat_override: number | null capital: number deposit_paid_on: string capital_paid_on: string @@ -295,19 +300,20 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft } const [d, setD] = useState(() => ({ invoice_on: '', biz_name: item.draft?.biz_name ?? '', + supply_amount: item.supply_default ?? 0, + deposit: item.deposit_default, + vat_override: item.draft?.vat_override ?? null, capital: item.draft?.capital ?? 0, deposit_paid_on: item.draft?.deposit_paid_on ?? '', capital_paid_on: item.draft?.capital_paid_on ?? '', cost: item.draft?.cost ?? 0, memo: item.draft?.memo ?? '', })) - /** 계약서에서 오는 값 — 보여만 준다 */ - const fixed = { supply_amount: item.supply_default ?? 0, deposit: item.deposit_default } const [busy, setBusy] = useState<'draft' | 'file' | null>(null) const [err, setErr] = useState('') const [saved, setSaved] = useState(false) const set = (k: K, v: Draft[K]) => { setSaved(false); setD(x => ({ ...x, [k]: v })) } - const calc = deriveP({ ...fixed, capital: d.capital, cost: d.cost }) + const calc = deriveP(d) /** * **임시저장**과 **등록**은 적는 내용이 같고 발행일만 다르다. @@ -322,6 +328,9 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft } const patch = { invoice_on: d.invoice_on || null, biz_name: d.biz_name.trim() || null, + supply_amount: d.supply_amount, + deposit: d.deposit, + vat_override: d.vat_override, capital: d.capital, deposit_paid_on: d.deposit_paid_on || null, capital_paid_on: d.capital_paid_on || null, @@ -364,11 +373,28 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft } set('biz_name', e.target.value)} /> - {won(fixed.supply_amount)} - {won(calc.vat)} + {/* + 자동 기입(계약서) 칸 — **고칠 수 있다**(2026-09-17). 직접 적는 칸과는 **배경으로만** 가른다 + (자동 = 배경 없음, 직접 = 흰 바탕). 공급가액을 바꾸면 VAT 는 식으로 돌아가 따라오고, + 공급대가를 바꾸면 공급가액을 되짚는다 — 적은 공급대가가 한 원도 다르지 않게. + */} + + setD(x => ({ ...x, supply_amount: v, vat_override: null }))} /> + + + set('vat_override', vatOverrideFor(d.supply_amount, v))} /> + - {won(calc.gross)} - {won(fixed.deposit)} + + setD(x => ({ ...x, ...fromGross(v) }))} /> + + + set('deposit', v)} /> + set('capital', v)} /> @@ -430,12 +456,16 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft } * 금액 칸(입력 중) — 치는 대로 값이 올라간다. 「입력 필요」의 셈(VAT·공급대가·수익)이 * 타자에 맞춰 따라와야 다 적고 나서 맞는지 확인할 수 있다. */ -function MoneyField({ value, disabled, label, onChange }: { +function MoneyField({ value, disabled, label, onChange, auto, strong }: { value: number; disabled?: boolean; label: string; onChange: (v: number) => void + /** 자동 기입 칸 — 배경을 걷는다(직접 적는 칸은 흰 바탕) */ + auto?: boolean + strong?: boolean }) { return ( onChange(Number(e.target.value.replace(/[^0-9]/g, '') || 0))} /> @@ -443,14 +473,18 @@ function MoneyField({ value, disabled, label, onChange }: { } /** 금액 칸 — 세 자리마다 쉼표로 보여 주고 숫자로 돌려준다. 칸을 벗어날 때 저장한다 */ -function MoneyCell({ value, disabled, label, dense, onSave }: { +function MoneyCell({ value, disabled, label, dense, onSave, auto, strong }: { value: number; disabled?: boolean; label: string; dense?: boolean; onSave: (v: number) => void + /** 자동 기입 칸 — 배경을 걷는다(직접 적는 칸은 흰 바탕) */ + auto?: boolean + strong?: boolean }) { const [draft, setDraft] = useState(null) const shown = draft ?? (value ? value.toLocaleString('ko-KR') : '') + const base = dense ? (auto ? s.moneyCellAuto : s.moneyCell) : (auto ? s.moneyAuto : s.moneyInput) return ( setDraft(e.target.value.replace(/[^0-9]/g, ''))} onBlur={() => { @@ -607,10 +641,23 @@ function TableRow({ row, canEdit, onWrite, onVoid }: { put({ invoice_on: v })} /> - {won(row.supply_amount)} - {won(d.vat)} - {won(d.gross)} - {won(row.deposit)} + {/* 자동 기입 칸 — 고칠 수 있다. 직접 적는 칸과는 배경으로만 가른다(자동 = 배경 없음) */} + + put({ supply_amount: v, vat_override: null })} /> + + + put({ vat_override: vatOverrideFor(row.supply_amount, v) })} /> + + + put(fromGross(v))} /> + + + put({ deposit: v })} /> + put({ capital: v })} /> @@ -755,11 +802,23 @@ function RowCard({ row, canEdit, onWrite, onVoid }: {
put({ invoice_on: v })} /> put({ biz_name: v })} /> - {/* 계약서에서 오는 값 — 보여만 준다 */} - {won(row.supply_amount)} - {won(d.vat)} - {won(d.gross)} - {won(row.deposit)} + {/* 자동 기입 칸 — 고칠 수 있다(배경 없음 = 자동) */} + + put({ supply_amount: v, vat_override: null })} /> + + + put({ vat_override: vatOverrideFor(row.supply_amount, v) })} /> + + + put(fromGross(v))} /> + + + put({ deposit: v })} /> + put({ capital: v })} /> put({ deposit_paid_on: v })} /> put({ capital_paid_on: v })} /> @@ -828,8 +887,17 @@ const CELL: React.CSSProperties = { ...CONTROL, height: 'var(--h-control-sm)', minHeight: 'var(--h-control-sm)', fontSize: 'var(--fs-label)', padding: '0 7px', } +/** + * **자동 기입 칸의 바탕** — 앱의 카드 배경(`--card`)이다. + * + * 「입력 필요」 폼은 이 색 판 위에 있어 「배경 없음」으로 보이고, 흰 표·흰 카드 위에서도 같은 색으로 보인다. + * `transparent` 로 두면 흰 표 위에서는 흰색이 되어 **직접 적는 칸과 구분이 사라진다**(표에서 실제로 그랬다). + * 직접 적는 칸은 흰 바탕 — 둘을 가르는 것은 **바탕 하나뿐**이다. + */ +const AUTO_BG = 'var(--card)' + /** 못 적는 칸 — 테두리는 그대로 두고 흐리게. 적을 수 있는 칸처럼 보이면 눌러 보다가 헛수고한다 */ -const OFF: React.CSSProperties = { background: 'var(--soft, #F7F7F4)', color: 'var(--muted)', cursor: 'default' } +const OFF: React.CSSProperties = { background: 'var(--card)', color: 'var(--muted)', cursor: 'default' } /** * 계산해서 보여만 주는 값 — 적는 칸과 **같은 자리**를 차지한다. @@ -895,7 +963,7 @@ const s: Record = { // 적는 중에는 그 건만 — 묶을 높이가 없다(폼이 잘리면 안 된다) pendListOne: { display: 'flex', flexDirection: 'column', gap: 2 }, pendItem: { borderTop: 'var(--hairline)' }, - pendItemOpen: { borderTop: 'var(--hairline)', background: 'var(--soft, #FAFAF7)', borderRadius: 'var(--r-sm)', padding: '2px 6px 8px' }, + pendItemOpen: { borderTop: 'var(--hairline)', background: 'var(--card)', borderRadius: 'var(--r-sm)', padding: '2px 6px 8px' }, pendHead: { display: 'flex', alignItems: 'baseline', gap: 8, width: '100%', border: 'none', background: 'none', padding: '7px 0', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'var(--fs-label)', textAlign: 'left', flexWrap: 'wrap' }, /* * **버튼은 줄마다 같은 자리에 선다.** 앞 글자 길이에 따라 좌우로 흔들리면 줄을 훑을 때 @@ -935,6 +1003,12 @@ const s: Record = { // ── 폼(입력 필요 · 휴대폰 카드 · 원가 창) ── moneyInput: { ...CONTROL, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, + /* + * **자동 기입 칸**(계약서에서 온 값) — 고칠 수 있지만 **배경을 걷어** 직접 적는 칸(흰 바탕)과 가른다 + * (2026-09-17 지시). 테두리·높이·글자는 같다 — 다른 것은 바탕 하나뿐이어야 「무엇이 다른지」가 읽힌다. + */ + moneyAuto: { ...CONTROL, background: AUTO_BG, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, + strongNum: { fontWeight: 700, color: 'var(--dark)' }, textInput: CONTROL, textInputWide: CONTROL, dateInput: { ...CONTROL, minWidth: 0, justifyContent: 'space-between' }, @@ -951,6 +1025,7 @@ const s: Record = { // ── 표(PC·태블릿) — 칸이 열셋이라 한 단계 촘촘하게 ── moneyCell: { ...CELL, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, + moneyCellAuto: { ...CELL, background: AUTO_BG, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, textCell: CELL, textCellWide: CELL, dateCell: { ...CELL, minWidth: 0, justifyContent: 'space-between' }, @@ -961,7 +1036,7 @@ const s: Record = { cards: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)' }, rowCard: { border: 'var(--hairline)', borderRadius: 'var(--r-sm)', padding: 'var(--sp-2)', display: 'flex', flexDirection: 'column', gap: 6 }, // 삭제된 카드 — 표의 회색 줄과 같은 뜻이다 - rowCardDead: { border: '1px solid var(--req)', borderRadius: 'var(--r-sm)', padding: 'var(--sp-2)', display: 'flex', flexDirection: 'column', gap: 6, background: 'var(--soft, #FAFAF7)', color: 'var(--muted)' }, + rowCardDead: { border: '1px solid var(--req)', borderRadius: 'var(--r-sm)', padding: 'var(--sp-2)', display: 'flex', flexDirection: 'column', gap: 6, background: 'var(--card)', color: 'var(--muted)' }, voidBar: { display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 6, borderBottom: 'var(--hairline)' }, cardFoot: { gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }, rowCardHead: { display: 'flex', alignItems: 'baseline', gap: 6, border: 'none', background: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', width: '100%' }, diff --git a/shared/finance/pnl.test.ts b/shared/finance/pnl.test.ts index b837bbd..db69cec 100644 --- a/shared/finance/pnl.test.ts +++ b/shared/finance/pnl.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { vatOf, grossOf, payDiffOf, deriveP, sumP, monthBounds, isYearMonth, supplyFromGross } from './pnl'; +import { vatOf, grossOf, payDiffOf, deriveP, sumP, monthBounds, isYearMonth, supplyFromGross, fromGross, vatOverrideFor } from './pnl'; /** * **엑셀과 같은 숫자가 나오는가.** @@ -79,6 +79,38 @@ describe('계약서 금액에서 공급가액 되짚기', () => { }); }); +describe('자동 칸을 사람이 고칠 때(2026-09-17)', () => { + it('🔴 VAT 를 고치면 공급대가·입금 차액이 그 값을 따른다', () => { + const d = deriveP({ supply_amount: 18_463_636, deposit: 400_000, capital: 0, vat_override: 1_846_364 }); + expect(d.vat).toBe(1_846_364); + expect(d.gross).toBe(20_310_000); + expect(d.pay_diff).toBe(20_310_000 - 400_000); + }); + + it('🔴 식과 같은 VAT 는 「고친 값」이 아니다 — 공급가액을 바꾸면 따라와야 한다', () => { + expect(vatOverrideFor(18_960_000, 1_896_000)).toBeNull(); + expect(vatOverrideFor(18_960_000, 1_896_001)).toBe(1_896_001); + }); + + it('🔴 공급대가를 고치면 **적은 값 그대로** 보인다 — 1원도 다르지 않다', () => { + for (const g of [20_310_000, 20_856_000, 33_333_333, 7, 1_000_001]) { + const r = fromGross(g); + expect(deriveP({ supply_amount: r.supply_amount, deposit: 0, capital: 0, vat_override: r.vat_override }).gross, String(g)).toBe(g); + } + // 식으로 딱 떨어지면 VAT 를 고친 것으로 두지 않는다 + expect(fromGross(20_856_000)).toEqual({ supply_amount: 18_960_000, vat_override: null }); + }); + + it('🔴 합계도 고친 VAT 를 쓴다', () => { + const z = sumP([ + { supply_amount: 100, deposit: 0, capital: 0, vat_override: 11 }, + { supply_amount: 100, deposit: 0, capital: 0 }, + ]); + expect(z.vat).toBe(21); + expect(z.gross).toBe(221); + }); +}); + describe('달 가르기', () => { it('🔴 12월 다음은 다음 해 1월이다', () => { expect(monthBounds('2026-12')).toEqual({ from: '2026-12-01', to: '2027-01-01' }); diff --git a/shared/finance/pnl.ts b/shared/finance/pnl.ts index 3422aaa..e88393d 100644 --- a/shared/finance/pnl.ts +++ b/shared/finance/pnl.ts @@ -85,19 +85,54 @@ export interface PnlBase { deposit: number; capital: number; cost?: number; + /** + * **손으로 고친 VAT** — 없으면(null) 엑셀 식(버림 10%)을 쓴다(2026-09-17 지시로 자동 칸도 고칠 수 있게 됐다). + * 세금계산서를 끊는 쪽이 반올림하면 1원이 다르다 — 그때 실제 계산서 값을 적는다. + */ + vat_override?: number | null; +} + +/** 그 줄의 VAT — 고친 값이 있으면 그것, 없으면 식 */ +export function vatFor(row: Pick): number { + return row.vat_override ?? vatOf(row.supply_amount); } export function deriveP(row: PnlBase): PnlDerived { const profit = profitOf(row.supply_amount, row.cost ?? 0); + const vat = vatFor(row); + const gross = (row.supply_amount || 0) + vat; return { - vat: vatOf(row.supply_amount), - gross: grossOf(row.supply_amount), - pay_diff: payDiffOf(row.supply_amount, row.deposit, row.capital), + vat, + gross, + pay_diff: gross - (row.capital || 0) - (row.deposit || 0), profit, margin: marginOf(row.supply_amount, profit), }; } +/** + * **공급대가를 고쳤을 때** 공급가액·VAT 를 되짚는다. + * + * 공급대가는 저장하지 않는 값이라, 사람이 고치면 그 뿌리(공급가액)를 바꿔야 한다. + * 공급가액은 딱 맞는 값을 고르고(`supplyFromGross`), 그래도 1원이 남으면 **VAT 를 고친 것으로** 둔다 — + * 그래야 적은 공급대가가 **한 원도 다르지 않게** 그대로 보인다. + */ +export function fromGross(gross: number): { supply_amount: number; vat_override: number | null } { + const g = Math.max(0, Math.round(gross || 0)); + const supply = supplyFromGross(g); + const vat = g - supply; + return { supply_amount: supply, vat_override: vat === vatOf(supply) ? null : vat }; +} + +/** + * **VAT 를 고쳤을 때** — 식과 같으면 고친 것이 아니다(null 로 되돌린다). + * 식과 같은 값을 「고친 값」으로 붙잡아 두면, 나중에 공급가액을 바꿔도 VAT 가 안 따라온다. + */ +export function vatOverrideFor(supply: number, vat: number): number | null { + const v = Math.max(0, Math.round(vat || 0)); + return v === vatOf(supply) ? null : v; +} + /** 여러 줄의 합 — 엑셀 20행(합계)과 같다. 합계도 줄과 **같은 함수**로 낸다 */ export function sumP(rows: PnlBase[]) { const z = {