From e0ed0d31943eb385fe89980c1305c05628e855a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 15:06:21 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(pnl):=20=EC=B0=A8=EB=9F=89=20=ED=8C=90?= =?UTF-8?q?=EB=A7=A4=EA=B1=B4=EB=B3=84=20=EC=86=90=EC=9D=B5=20=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EC=9B=94=EB=B3=84=20=EA=B8=B0=EB=A1=9D=C2=B7?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20+=20=EC=83=81=EB=8B=A8=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경영관리가 쓰는 엑셀(차량판매_손익_양식)을 그대로 옮겼다. 달을 가르는 기준은 **세금계산서 발행일**이고, 발행일을 적는 순간 그 달 표로 들어간다. ## 화면 셋 · **요약** — 「주문 진행」처럼 맨 위에 그 달 전체(건수·공급가액·VAT·공급대가· 계약금·캐피탈·입금 차액·원가·수익·수익률) · **입력 필요** — 발행일을 아직 안 적은 건을 맨 위에 따로 모은다. 여기서 날짜를 적으면 그 달 표로 자동으로 들어간다 · **그 달의 표** — 적으면서 보는 자리. 칸을 벗어나면 저장한다(줄마다 따로) — 「저장」 버튼을 누르게 하면 스무 줄을 고칠 때 안 누른 줄이 생긴다 PC·태블릿은 표(칸이 열셋이라 태블릿은 옆으로 민다), 휴대폰은 줄마다 카드다. 접힌 카드에도 **입금 차액과 수익**은 보인다 — 그게 이 표를 보는 이유다. ## 어디서 오는 값인가 · 공급가액 = 계약서상 **특장 결제금액**에서 VAT 를 덜어 낸 값. VAT 가 버림이라 그냥 1.1 로 나누면 1원이 새서, 딱 맞는 값을 골라 채운다(supplyFromGross) · 계약금 = 시스템에 등록된 특장 계약금. **고칠 수 있다** · 캐피탈·입금일·비고 = 경영관리가 직접 적는다 · 원가(외주·사급·내부·기타) = **별도 시스템에서 불러올 자리**(구성 중). 그때까지는 손으로 적고, `cost_source` 로 손으로 적은 값인지 남긴다 ⚠️ **처음 만들 때 계약 금액을 굳힌다.** 단가표를 나중에 고쳤다고 이미 세금계산서가 나간 줄의 금액이 소급해 바뀌면 안 된다(발주서 금액을 얼리는 것과 같은 이유). ⚠️ VAT·공급대가·입금 차액·수익은 **저장하지 않는다.** 적어 두면 공급가액을 고쳤을 때 둘이 어긋나고 어느 쪽이 맞는지 알 수 없어진다. 화면과 서버가 같은 함수로 낸다 (shared/finance/pnl — 엑셀의 실제 줄로 검사한다). ## 권한 기능모듈 둘을 더했다 — `pnl.view`(조회) · `pnl.manage`(입력). 돈에 관한 표라 **보는 것부터** 권한을 건다. 경영관리·마스터 자리에 기본으로 켠다. ## DB `order_pnl` 표를 **새로 더한다.** 기존 표·칸은 건드리지 않는다. Co-Authored-By: Claude Opus 5 --- .../20260916050000_order_pnl/migration.sql | 41 ++ .../20260916060000_pnl_modules/migration.sql | 19 + backend/prisma/schema.prisma | 157 +++-- backend/src/__tests__/pnl.test.ts | 141 +++++ backend/src/__tests__/sales-stats.test.ts | 22 +- backend/src/app.ts | 4 +- backend/src/lib/permissions.ts | 1 + backend/src/routes/pnl.ts | 58 ++ backend/src/services/pnl.ts | 284 +++++++++ db/seed/access_control.csv | 4 + db/seed/feature_module.csv | 2 + frontend/src/api/pnl.ts | 74 +++ frontend/src/components/PnlTab.tsx | 557 ++++++++++++++++++ frontend/src/i18n/en.ts | 29 + frontend/src/pages/AdminPage.tsx | 9 +- shared/finance/pnl.test.ts | 98 +++ shared/finance/pnl.ts | 144 +++++ shared/package.json | 3 +- shared/rbac/presets.ts | 7 +- 19 files changed, 1585 insertions(+), 69 deletions(-) create mode 100644 backend/prisma/migrations/20260916050000_order_pnl/migration.sql create mode 100644 backend/prisma/migrations/20260916060000_pnl_modules/migration.sql create mode 100644 backend/src/__tests__/pnl.test.ts create mode 100644 backend/src/routes/pnl.ts create mode 100644 backend/src/services/pnl.ts create mode 100644 frontend/src/api/pnl.ts create mode 100644 frontend/src/components/PnlTab.tsx create mode 100644 shared/finance/pnl.test.ts create mode 100644 shared/finance/pnl.ts diff --git a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql new file mode 100644 index 0000000..e7a3c4a --- /dev/null +++ b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql @@ -0,0 +1,41 @@ +-- **차량 판매건별 손익**(2026-09-16 지시) — 경영관리가 월 단위로 적고 보는 표. +-- 지금 쓰는 엑셀(차량판매_손익_양식.xlsx)을 그대로 옮긴다. 기준 달은 **세금계산서 발행일**이다. +-- +-- ⚠️ 새 표만 더한다. 기존 표·칸은 건드리지 않는다. +-- ⚠️ VAT·공급대가·입금 차액은 **적지 않는다** — 엑셀과 같은 식으로 그때그때 낸다. +-- 적어 두면 공급가액을 고쳤을 때 둘이 어긋나고, 어느 쪽이 맞는지 알 수 없어진다. +CREATE TABLE IF NOT EXISTS "order_pnl" ( + "id" SERIAL PRIMARY KEY, + -- 판매 한 건 = 견적 하나. 한 건에 손익 줄은 하나뿐이다 + "quote_id" INTEGER NOT NULL UNIQUE, + -- 세금계산서 발행일 — **이 날짜가 몇 월 표에 들어갈지를 정한다.** 비어 있으면 「입력 필요」에 남는다 + "invoice_on" DATE, + -- 사업자명 — 고객명과 다를 수 있다(개인 이름으로 계약하고 상호로 발행). 처음엔 고객명으로 채운다 + "biz_name" VARCHAR(120), + -- 공급가액(VAT 별도) — 계약서상 특장가격에서 처음 값을 채우고, 그 뒤로는 적힌 값이 정본이다 + "supply_amount" BIGINT NOT NULL DEFAULT 0, + -- 계약금 — 시스템에 등록된 특장 계약금으로 채우되 고칠 수 있다 + "deposit" BIGINT NOT NULL DEFAULT 0, + -- 캐피탈 — 경영관리가 직접 적는다 + "capital" BIGINT NOT NULL DEFAULT 0, + -- 입금일 — 계약금과 캐피탈을 따로 적는다 + "deposit_paid_on" DATE, + "capital_paid_on" DATE, + -- 원가 — **별도 시스템에서 불러올 자리다**(구성 중). 그때까지는 손으로 적는다. + -- 어디서 온 값인지 남겨 두어야, 나중에 자동으로 채울 때 손으로 적은 값을 함부로 덮지 않는다 + "cost_outsourcing" BIGINT NOT NULL DEFAULT 0, + "cost_supply" BIGINT NOT NULL DEFAULT 0, + "cost_internal" BIGINT NOT NULL DEFAULT 0, + "cost_etc" BIGINT NOT NULL DEFAULT 0, + "cost_source" VARCHAR(20) NOT NULL DEFAULT 'manual', + "cost_memo" VARCHAR(500), + -- 비고 — 엑셀의 「비고 (사업자등록증 확인 필요)」 칸 + "memo" VARCHAR(500), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" VARCHAR(120), + CONSTRAINT "order_pnl_quote_id_fkey" FOREIGN KEY ("quote_id") REFERENCES "quote"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- 월별 조회가 이 표의 거의 전부다 +CREATE INDEX IF NOT EXISTS "order_pnl_invoice_on_idx" ON "order_pnl"("invoice_on"); diff --git a/backend/prisma/migrations/20260916060000_pnl_modules/migration.sql b/backend/prisma/migrations/20260916060000_pnl_modules/migration.sql new file mode 100644 index 0000000..fa80e3d --- /dev/null +++ b/backend/prisma/migrations/20260916060000_pnl_modules/migration.sql @@ -0,0 +1,19 @@ +-- 손익 기능모듈 둘(조회·관리)과 경영관리·마스터 자리의 기본 구성(2026-09-16). +-- 돈에 관한 표라 **보는 것부터** 권한을 건다 — 주문 진행 현황과 성격이 다르다. +INSERT INTO "feature_module" ("code", "name", "surface", "sort_order", "active") +VALUES + ('pnl.view', '손익 조회', '관리자', 23, true), + ('pnl.manage', '손익 관리', '관리자', 24, true) +ON CONFLICT ("code") DO NOTHING; + +-- 자리 기본값. 모듈 행이 아직 없으면 건너뛴다(빈 DB 검증에서 외래키에 걸리지 않게) +INSERT INTO "access_control" ("subject_type", "subject_ref", "module_code", "enabled", "memo") +SELECT 'preset'::"SubjectType", v.ref, v.code, true, '프리셋 기본값 — 기능모듈 화면에서 고친다' +FROM (VALUES + ('exec', 'pnl.view'), + ('exec', 'pnl.manage'), + ('master', 'pnl.view'), + ('master', 'pnl.manage') +) AS v(ref, code) +WHERE EXISTS (SELECT 1 FROM "feature_module" fm WHERE fm."code" = v.code) +ON CONFLICT ("subject_type", "subject_ref", "module_code") DO NOTHING; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6ed0f96..9a2bb9b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -48,28 +48,28 @@ enum UserStatus { } model User { - email String @id @db.VarChar(120) - org_code String @db.VarChar(30) + email String @id @db.VarChar(120) + org_code String @db.VarChar(30) /// 주 역할 — 로그인 후 첫 화면·배지의 기준(하나뿐) - role Role + role Role /// 겸직 — 주 역할 외에 더 가진 역할. 권한은 역할들의 합집합으로 본다. - extra_roles Role[] @default([]) - name String @db.VarChar(60) - phone String? @db.VarChar(20) - status UserStatus @default(invited) - must_change_pw Boolean @default(true) - invited_by String? @db.VarChar(120) - active Boolean @default(true) - created_at DateTime @default(now()) - password_hash String? @db.VarChar(80) - login_attempts Int @default(0) - locked_until DateTime? + extra_roles Role[] @default([]) + name String @db.VarChar(60) + phone String? @db.VarChar(20) + status UserStatus @default(invited) + must_change_pw Boolean @default(true) + invited_by String? @db.VarChar(120) + active Boolean @default(true) + created_at DateTime @default(now()) + password_hash String? @db.VarChar(80) + login_attempts Int @default(0) + locked_until DateTime? /// 이 시각 이전에 발급된 로그인 토큰은 무효 — 비밀번호 변경·관리자 초기화 때 찍는다 sessions_valid_after DateTime? // DEV: master surface switcher — true인 계정만 surface 전환 토글 표시 (테스트용) - is_master Boolean @default(false) + is_master Boolean @default(false) /// 역할 프리셋 — 관리자 안의 자리(영업관리·PM·생산관리·경영관리·마스터). 비어 있으면 역할 기본값 그대로(shared/rbac/presets) - admin_preset String? @db.VarChar(20) + admin_preset String? @db.VarChar(20) org Org @relation(fields: [org_code], references: [code]) inviter User? @relation("Invites", fields: [invited_by], references: [email]) @@ -462,6 +462,52 @@ model Customer { /// assigned 배정완료 — 관리자가 특장사 배정 /// ordered 주문진행 — 특장사가 수락 /// completed 완료 — 특장사가 모든 공정 완료 +/// **차량 판매건별 손익**(2026-09-16) — 경영관리가 월 단위로 적고 보는 표. +/// 지금 쓰는 엑셀(차량판매_손익_양식)을 그대로 옮겼다. 달을 가르는 기준은 **세금계산서 발행일**이다. +/// +/// ⚠️ VAT·공급대가·입금 차액은 **칸이 없다.** 엑셀과 같은 식으로 읽을 때 낸다 — +/// 적어 두면 공급가액을 고쳤을 때 둘이 어긋나고, 어느 쪽이 맞는지 알 수 없어진다. +model OrderPnl { + id Int @id @default(autoincrement()) + /// 판매 한 건 = 견적 하나 + quote_id Int @unique + + /// 세금계산서 발행일 — **이 날짜가 몇 월 표에 들어갈지를 정한다.** 비어 있으면 「입력 필요」에 남는다 + invoice_on DateTime? @db.Date + /// 사업자명 — 고객명과 다를 수 있다(개인 이름으로 계약하고 상호로 발행) + biz_name String? @db.VarChar(120) + + /// 공급가액(VAT 별도). 처음엔 계약서상 특장가격에서 채우고, 그 뒤로는 적힌 값이 정본이다 + supply_amount BigInt @default(0) + /// 계약금 — 시스템에 등록된 특장 계약금으로 채우되 고칠 수 있다 + deposit BigInt @default(0) + /// 캐피탈 — 경영관리가 직접 적는다 + capital BigInt @default(0) + + deposit_paid_on DateTime? @db.Date + capital_paid_on DateTime? @db.Date + + /// 원가 — **별도 시스템에서 불러올 자리**(구성 중). 그때까지는 손으로 적는다 + cost_outsourcing BigInt @default(0) + cost_supply BigInt @default(0) + cost_internal BigInt @default(0) + cost_etc BigInt @default(0) + /// 'manual' = 손으로 적었다 / 'system' = 원가 시스템이 채웠다. 자동으로 채울 때 손으로 적은 값을 덮지 않기 위해 남긴다 + cost_source String @default("manual") @db.VarChar(20) + cost_memo String? @db.VarChar(500) + + /// 비고 — 엑셀의 「비고 (사업자등록증 확인 필요)」 칸 + memo String? @db.VarChar(500) + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.VarChar(120) + + quote Quote @relation(fields: [quote_id], references: [id]) + + @@index([invoice_on]) + @@map("order_pnl") +} + enum QuoteStatus { draft confirmed @@ -473,40 +519,40 @@ enum QuoteStatus { } model Quote { - id Int @id @default(autoincrement()) - quote_no String? @unique @db.VarChar(10) - customer_id Int? - model_code String @db.VarChar(30) - selections Json @default("{}") - inputs Json @default("{}") // 총견적서 입력시트 스냅샷(구분·소상공인·지역·면세·선수금비율·할부개월수·영업용번호판 등) - supply_price Int? - final_price Int? - status QuoteStatus @default(draft) + id Int @id @default(autoincrement()) + quote_no String? @unique @db.VarChar(10) + customer_id Int? + model_code String @db.VarChar(30) + selections Json @default("{}") + inputs Json @default("{}") // 총견적서 입력시트 스냅샷(구분·소상공인·지역·면세·선수금비율·할부개월수·영업용번호판 등) + supply_price Int? + final_price Int? + status QuoteStatus @default(draft) /// sales = 영업 작성 상담 견적 / public = 고객이 공개 화면에서 직접 접수한 문의 - source String @default("sales") @db.VarChar(10) - sales_user_id String? @db.VarChar(120) - org_id String? @db.VarChar(30) + source String @default("sales") @db.VarChar(10) + sales_user_id String? @db.VarChar(120) + org_id String? @db.VarChar(30) /// 담당 영업이 배정을 **수락한** 시각. NULL = 수락 대기(또는 배정 전). 재배정 시 NULL 로 돌린다. - sales_accepted_at DateTime? + sales_accepted_at DateTime? /// 「배정 요청」 — 서명 완료 뒤 영업이 서명본을 확인하고 누른 시각. 이 값이 있어야 관리자가 제작 배정한다. /// 배정 거부·주문 삭제로 되돌아온 건은 이미 요청된 것으로 두어 바로 재배정한다. - assign_requested_at DateTime? - assign_requested_by String? @db.VarChar(120) + assign_requested_at DateTime? + assign_requested_by String? @db.VarChar(120) /// 「배정 거부」 — 관리자가 배정 요청을 사유와 함께 돌려보냈다. 영업이 다시 요청하면 비운다(이력은 quote_change_log) assign_rejected_at DateTime? - assign_rejected_by String? @db.VarChar(120) - assign_reject_reason String? @db.VarChar(500) + assign_rejected_by String? @db.VarChar(120) + assign_reject_reason String? @db.VarChar(500) /// 숨김 — **지우지 않고 화면에서만 감춘다.** null 이면 보인다. /// ⚠️ 임시저장(draft)만 숨길 수 있다. 확정 이상은 퍼널 통계가 왜곡되고 /// 진행 중인 거래가 화면에서 사라진다. 계약이 붙어 있으면 무조건 거부. - hidden_at DateTime? - hidden_by String? @db.VarChar(120) - created_at DateTime @default(now()) + hidden_at DateTime? + hidden_by String? @db.VarChar(120) + created_at DateTime @default(now()) /// 마지막으로 고친 시각 — **견적서에 찍히는 날짜**가 이 값이다. /// 옵션·고객정보·입력을 고치면 그 날짜로 다시 찍혀야 한다. 예전엔 created_at 을 찍어 /// 몇 번을 고쳐도 처음 만든 날이 나갔다(제보). /// Prisma 가 update 마다 자동으로 채운다(@updatedAt). - updated_at DateTime @default(now()) @updatedAt + updated_at DateTime @default(now()) @updatedAt // 참고용 메일 발송(견적서·계약서 PDF 첨부) 이력 — 전자서명(purchase_contract)과는 별개 채널. // 관리자 견적 목록에서 '보냈는지/언제/누구에게' 를 확인하는 용도. 마지막 발송 기준. @@ -529,6 +575,7 @@ model Quote { contracts PurchaseContract[] email_logs QuoteEmailLog[] po_draft PoDraft? + pnl OrderPnl? @@map("quote") } @@ -638,15 +685,15 @@ model PoDraft { // 주문 상태 순서(앱 레벨 강제): 제작착수→구조변경→튜닝신청→안전검사→튜닝승인→인도완료 model Order { - id Int @id @default(autoincrement()) - quote_id Int @unique - maker_org_id String? @db.VarChar(30) - assigned_at DateTime? + id Int @id @default(autoincrement()) + quote_id Int @unique + maker_org_id String? @db.VarChar(30) + assigned_at DateTime? /// 특장사가 수락하며 약속한 납기일. 발주일로부터 15영업일 이내(발주서 특이사항). /// 1단계에서 order_step[po_accepted].planned_at 으로 옮긴다. /// 이 발주의 납기 한도(영업일). **배정할 때 얼려 둔다** — /// 상수를 바꿔도 이미 나간 발주서의 「N일 이내」 문구가 소급해 바뀌지 않는다. - due_limit_days Int? + due_limit_days Int? /// **차량이 언제 도착하는지** — 관리자가 특장사에게 알려 주는 날짜. /// /// 특장사가 완료 처리하는 「차량 도착」 단계와 다르다. 이건 **예정**이고, @@ -656,25 +703,25 @@ model Order { /// **마지막으로 거부한 특장사.** 거부하면 배정(`maker_org_id`)이 풀리는데, 그 특장사가 /// 「거부됨」으로 이 건을 계속 보고 관리자와 이야기할 수 있어야 한다(날짜를 맞춰 다시 /// 배정하려면 대화가 필요하다). 다른 특장사로 재배정되면 그 특장사에게서 사라진다. - rejected_by_org String? @db.VarChar(30) - car_arrival_planned_at DateTime? @db.Date - car_arrival_set_by String? @db.VarChar(120) - car_arrival_set_at DateTime? - delivery_due DateTime? @db.Date + rejected_by_org String? @db.VarChar(30) + car_arrival_planned_at DateTime? @db.Date + car_arrival_set_by String? @db.VarChar(120) + car_arrival_set_at DateTime? + delivery_due DateTime? @db.Date /// 특장사가 **수락하며 처음 약속한** 납기일 — 관리자가 처음 바꿀 때 옮겨 적는다(안 바뀐 주문은 비어 있다) - delivery_due_original DateTime? @db.Date + delivery_due_original DateTime? @db.Date /// 관리자가 납기일을 마지막으로 바꾼 사람·시각 - delivery_due_changed_by String? @db.VarChar(120) + delivery_due_changed_by String? @db.VarChar(120) delivery_due_changed_at DateTime? /// 발주 수락 시각 — 납기 한도의 기산점 - accepted_at DateTime? - created_at DateTime @default(now()) + accepted_at DateTime? + created_at DateTime @default(now()) // 구조변경 서류 바인딩용 차량 정보 (특장사 입력) - vehicle_info Json? - due_notices OrderDueNotice[] + vehicle_info Json? + due_notices OrderDueNotice[] /// 부가작업(공장 출고 뒤 우리 쪽 작업) — 특장사에게는 싣지 않는다 - addon_steps OrderAddonStep[] - addon OrderAddon? + addon_steps OrderAddonStep[] + addon OrderAddon? /// 특장사가 거부한 시각. 거부하면 배정이 풀려 다른 특장사에 다시 맡길 수 있다. rejected_at DateTime? diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts new file mode 100644 index 0000000..7c6322f --- /dev/null +++ b/backend/src/__tests__/pnl.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +/** + * **차량 판매건별 손익** — 권한과 「달 가르기」. + * + * ① 돈에 관한 표라 **보는 것부터** 권한을 건다(`pnl.view`), 적는 것은 `pnl.manage` + * ② 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다 + * ③ 처음 만들 때 계약서 금액을 **굳힌다** — 나중에 단가를 고쳐도 이미 나간 줄은 그대로다 + */ +process.env['ALLOW_TEST_PERMISSION_BYPASS'] = 'false'; + +const request = (await import('supertest')).default; +const { createApp } = await import('../app.js'); +const { prisma } = await import('../lib/prisma.js'); +const { authCookie } = await import('./helpers.js'); + +const app = createApp(); +const live = !!prisma; +const KEEPER = 'pnl-keeper@example.invalid'; // pnl.manage 있음 +const LOOKER = 'pnl-looker@example.invalid'; // pnl.view 만 +const OUTSIDE = 'pnl-none@example.invalid'; // 손익 권한 없음 +const USERS = [KEEPER, LOOKER, OUTSIDE]; +const keeper = authCookie(KEEPER, 'ADMIN', 'ORG_HQ'); +const looker = authCookie(LOOKER, 'ADMIN', 'ORG_HQ'); +const outside = authCookie(OUTSIDE, 'ADMIN', 'ORG_HQ'); + +let customerId = 0; +const quotes: number[] = []; + +async function contractedQuote(price = 1_000_000): Promise { + const q = await prisma!.quote.create({ + data: { + model_code: 'PV5_OPENBED', selections: {}, inputs: {}, status: 'contracted', + customer_id: customerId, final_price: price, + }, + select: { id: true }, + }); + quotes.push(q.id); + return q.id; +} + +beforeAll(async () => { + if (!prisma) return; + const mods = await prisma.featureModule.findMany({ select: { code: true } }); + for (const email of USERS) { + await prisma.user.upsert({ + where: { email }, update: { active: true, status: 'active' }, + create: { email, name: '손익시험', role: 'ADMIN', extra_roles: [], org_code: 'ORG_HQ', active: true, status: 'active', password_hash: 'x' }, + }); + for (const m of mods) { + const enabled = + m.code === 'pnl.manage' ? email === KEEPER + : m.code === 'pnl.view' ? email !== OUTSIDE + : true; + await prisma.accessControl.upsert({ + where: { subject_type_subject_ref_module_code: { subject_type: 'user', subject_ref: email, module_code: m.code } }, + update: { enabled }, create: { subject_type: 'user', subject_ref: email, module_code: m.code, enabled }, + }); + } + } + customerId = (await prisma.customer.create({ data: { name: '손익_시험고객' } })).id; +}); + +afterAll(async () => { + if (!prisma) return; + await prisma.orderPnl.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 } } }); + await prisma.user.deleteMany({ where: { email: { in: USERS } } }); + process.env['ALLOW_TEST_PERMISSION_BYPASS'] = 'true'; +}); + +describe.runIf(live)('손익 — 권한', () => { + it('🔴 손익 권한이 없으면 보지도 못한다 — 돈에 관한 표다', async () => { + expect((await request(app).get('/api/v1/pnl').set('Cookie', outside)).status).toBe(403); + }); + + it('🔴 보기 권한만 있으면 읽되 적지는 못한다', async () => { + expect((await request(app).get('/api/v1/pnl').set('Cookie', looker)).status).toBe(200); + const id = await contractedQuote(); + 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 id = await contractedQuote(); + + const before = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper); + expect(before.status).toBe(200); + expect(before.body.data.pending.some((p: { quote_id: number }) => p.quote_id === id), '새 계약이 입력 필요에 없다').toBe(true); + + const put = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-10', supply_amount: 18_960_000, deposit: 400_000, capital: 20_450_000 }); + expect(put.status, JSON.stringify(put.body)).toBe(200); + + const sep = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper); + const row = sep.body.data.rows.find((r: { quote_id: number }) => r.quote_id === id); + expect(row, '9월 표에 없다').toBeTruthy(); + expect(row.supply_amount).toBe(18_960_000); + expect(sep.body.data.pending.some((p: { quote_id: number }) => p.quote_id === id), '입력 필요에 그대로 남았다').toBe(false); + + // 발행일을 다른 달로 바꾸면 그 달로 옮겨간다 + await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-10-02' }); + const sep2 = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper); + expect(sep2.body.data.rows.some((r: { quote_id: number }) => r.quote_id === id), '9월에 남아 있다').toBe(false); + const oct = await request(app).get('/api/v1/pnl?month=2026-10').set('Cookie', keeper); + expect(oct.body.data.rows.some((r: { quote_id: number }) => r.quote_id === id), '10월로 옮겨오지 않았다').toBe(true); + }, 60_000); + + it('🔴 발행일을 지우면 「입력 필요」로 돌아온다', async () => { + const id = await contractedQuote(); + 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); + expect(v.body.data.rows.some((r: { quote_id: number }) => r.quote_id === id)).toBe(false); + expect(v.body.data.pending.some((p: { quote_id: number }) => p.quote_id === id)).toBe(true); + }, 30_000); + + it('🔴 이상한 달 값은 이번 달로 떨어진다 — 주소창 값을 믿지 않는다', async () => { + const v = await request(app).get('/api/v1/pnl?month=2026-13').set('Cookie', keeper); + expect(v.status).toBe(200); + expect(v.body.data.month).toMatch(/^\d{4}-(0[1-9]|1[0-2])$/); + }, 30_000); + + it('🔴 적은 값은 그대로 남는다 — 비운 칸은 건드리지 않는다(부분 저장)', async () => { + const id = await contractedQuote(); + await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-12', supply_amount: 5_000_000, memo: '확인필요' }); + // 비고만 고친다 — 금액이 0 으로 밀리면 안 된다 + const r = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ memo: '확인완료' }); + expect(r.body.data.supply_amount).toBe(5_000_000); + expect(r.body.data.memo).toBe('확인완료'); + expect(r.body.data.invoice_on).toBe('2026-09-12'); + }, 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); + }, 30_000); +}); diff --git a/backend/src/__tests__/sales-stats.test.ts b/backend/src/__tests__/sales-stats.test.ts index 7e76b3f..ec35a9e 100644 --- a/backend/src/__tests__/sales-stats.test.ts +++ b/backend/src/__tests__/sales-stats.test.ts @@ -154,17 +154,23 @@ describe.runIf(live)('영업 성과 집계', () => { const A = await user(); const B = await user(); const c = await customer('성과시험_공동고객'); - const totalCustomers = async () => (await salesStatsFull({})).total.customers.confirmed; - - const before = await totalCustomers(); - await quote({ user: A, customerId: c, status: 'confirmed', log: [['confirmed', day('2026-09-07')]] }); - expect(await totalCustomers(), '새 고객 한 명이 늘어야 한다').toBe(before + 1); + /* + * 합계는 **DB 전체**를 센다 — 다른 시험 파일이 같은 순간 견적을 만들면 숫자가 흔들린다. + * 그래서 이 시험만 쓰는 **먼 날짜**로 창을 좁힌다(그 날 확정된 건은 여기서 만든 둘뿐이다). + */ + const on = day('2031-03-07'); + const win = { from: new Date('2031-03-07T00:00:00Z'), to: new Date('2031-03-07T23:59:59Z') }; + const totalCustomers = async () => (await salesStatsFull(win)).total.customers.confirmed; + + expect(await totalCustomers(), '이 창에는 아직 아무것도 없어야 한다').toBe(0); + await quote({ user: A, customerId: c, status: 'confirmed', log: [['confirmed', on]] }); + expect(await totalCustomers(), '새 고객 한 명이 늘어야 한다').toBe(1); // 같은 고객을 다른 영업도 맡았다 — 계정별로는 각자 1명이지만 **합계는 그대로 1명**이다 - await quote({ user: B, customerId: c, status: 'confirmed', log: [['confirmed', day('2026-09-07')]] }); - expect(await totalCustomers(), '합계를 더해서 만들면 한 사람이 두 명이 된다').toBe(before + 1); + await quote({ user: B, customerId: c, status: 'confirmed', log: [['confirmed', on]] }); + expect(await totalCustomers(), '합계를 더해서 만들면 한 사람이 두 명이 된다').toBe(1); - const { rows } = await salesStatsFull({}); + const { rows } = await salesStatsFull(win); const mine = rows.filter(r => r.sales_user_id === A || r.sales_user_id === B); expect(mine.map(r => r.customers.confirmed), '계정별로는 각자 한 명').toEqual([1, 1]); }, 30_000); diff --git a/backend/src/app.ts b/backend/src/app.ts index f9d5f80..4182bff 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import { docsRouter } from './routes/docs.js'; import { pushRouter } from './routes/push.js'; import { authRouter } from './routes/auth.js'; import { devAuthRouter, devAutoLoginEnabled } from './routes/dev-auth.js'; +import { pnlRouter } from './routes/pnl.js'; import { usersRouter } from './routes/users.js'; import { accessControlRouter } from './routes/access-control.js'; import { weightConstantsRouter } from './routes/weight-constants.js'; @@ -105,7 +106,8 @@ export function createApp() { app.use('/api/v1/notifications', notificationsRouter); app.use('/api/v1/checklists', checklistsRouter); app.use('/api/v1/option-db', optionDbRouter); // 기준데이터 CRUD + 감사이력 (ADMIN 전용) - app.use('/api/v1/stats', statsRouter); // 영업 성과 (SALES=본인만 / ADMIN=전체) + app.use('/api/v1/stats', statsRouter); + app.use('/api/v1/pnl', pnlRouter); // 차량 판매건별 손익 (경영관리) app.use('/api/v1/feature-modules', featureModulesRouter); app.use('/api/v1/models', modelsRouter); app.use('/api/v1/quotes', quotesRouter); diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index cef8c11..f31fcbc 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -84,6 +84,7 @@ export function mergePermissions( * 영업관리·경영관리는 `addon.view` 만 가진다(보기 전용). */ if (codes.includes('addon.manage') && !codes.includes('addon.view')) codes.push('addon.view'); + if (codes.includes('pnl.manage') && !codes.includes('pnl.view')) codes.push('pnl.view'); if (codes.includes('basedata.manage')) { for (const c of ['basedata.weights', 'basedata.dims', 'basedata.optiondb', 'basedata.makerprice', 'basedata.holiday']) { if (!codes.includes(c)) codes.push(c); diff --git a/backend/src/routes/pnl.ts b/backend/src/routes/pnl.ts new file mode 100644 index 0000000..d2bcdc3 --- /dev/null +++ b/backend/src/routes/pnl.ts @@ -0,0 +1,58 @@ +/** + * 차량 판매건별 손익 — /api/v1/pnl/* + * + * 경영관리가 월 단위로 적고 보는 표(2026-09-16 지시). 달을 가르는 기준은 **세금계산서 발행일**이다. + * + * 권한: `pnl.view` 로 보고, `pnl.manage` 로 적는다. 돈에 관한 표라 **보는 것부터** 권한을 건다 — + * 매출·원가·수익은 주문 진행 현황과 성격이 다르다. + */ +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, type PnlPatch } from '../services/pnl.js'; + +export const pnlRouter = Router(); + +const view = [rbac('ADMIN'), requirePermission('pnl.view')] as const; +const manage = [rbac('ADMIN'), requirePermission('pnl.manage')] as const; + +function fail(res: Response, where: string, e: unknown) { + console.error(`[pnl] ${where}`, e); + res.status(500).json({ error: { code: 'INTERNAL', message: '손익 조회 중 오류가 발생했습니다.' } }); +} + +/** 이번 달(한국 기준) — 화면이 달을 안 주면 여기서 정한다 */ +function thisMonth(): string { + const kst = new Date(Date.now() + 9 * 60 * 60 * 1000); + return kst.toISOString().slice(0, 7); +} + +// ── GET /pnl?month=YYYY-MM — 그 달의 줄 + 「입력 필요」 + 달 목록 ───────────── +pnlRouter.get('/', ...view, async (req: Request, res: Response): Promise => { + try { + const q = req.query['month']; + const month = isYearMonth(q) ? q : thisMonth(); + const [rows, pending, months] = await Promise.all([pnlOfMonth(month), pnlPending(), pnlMonths()]); + res.json({ data: { month, rows, pending, months } }); + } catch (e) { fail(res, 'GET /', e); } +}); + +// ── PUT /pnl/:quoteId — 줄 하나를 적는다(없으면 만든다) ────────────────────── +pnlRouter.put('/:quoteId', ...manage, async (req: Request, res: Response): Promise => { + const quoteId = Number(req.params['quoteId']); + if (!Number.isInteger(quoteId)) { + res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 견적 id' } }); return; + } + try { + const row = await savePnl(quoteId, (req.body ?? {}) as PnlPatch, req.auth?.email ?? 'unknown'); + if (!row) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; } + res.json({ data: row }); + } catch (e) { + // 없는 견적에 줄을 만들려 하면 외래키에 걸린다 — 500 이 아니라 404 로 답한다 + if (e && typeof e === 'object' && (e as { code?: string }).code === 'P2003') { + res.status(404).json({ error: { code: 'NOT_FOUND', message: '견적을 찾을 수 없습니다' } }); return; + } + fail(res, 'PUT /:quoteId', e); + } +}); diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts new file mode 100644 index 0000000..3d9934d --- /dev/null +++ b/backend/src/services/pnl.ts @@ -0,0 +1,284 @@ +/** + * **차량 판매건별 손익** — 경영관리가 월 단위로 적고 보는 표(2026-09-16 지시). + * + * 달을 가르는 기준은 **세금계산서 발행일**이다. 발행일을 적는 순간 그 달 표로 들어간다. + * 아직 안 적은 건은 「입력 필요」에 모여 있다가, 적으면 사라진다. + * + * ⚠️ **처음 만들 때 계약 금액을 굳힌다.** 공급가액·계약금은 계약서에서 가져와 채우되, + * 그 뒤로는 적힌 값이 정본이다. 단가표를 나중에 고쳤다고 이미 세금계산서가 나간 줄의 + * 금액이 소급해 바뀌면 안 된다 — 발주서 금액을 얼려 두는 것과 같은 이유다. + */ +import { prisma } from '../lib/prisma.js'; +import { calcQuote } from '@buildup-ev/shared/pricing'; +import { monthBounds, supplyFromGross } from '@buildup-ev/shared/finance/pnl'; +import { buildQuoteParams, quoteExtraFromInputs, type CustomerInput } from './quote-calc.js'; +import { VISIBLE } from '../lib/visibility.js'; + +/** 손익 줄 하나 — 화면이 읽는 모양. 금액은 **수(number)** 로 내려보낸다(BigInt 는 JSON 이 못 싣는다) */ +export interface PnlRow { + quote_id: number; + quote_no: string | null; + customer: string | null; + biz_name: string | null; + sales_user_id: string | null; + maker_org: string | null; + invoice_on: string | null; + supply_amount: number; + deposit: number; + capital: number; + deposit_paid_on: string | null; + capital_paid_on: string | null; + cost_outsourcing: number; + cost_supply: number; + cost_internal: number; + cost_etc: number; + cost_source: string; + cost_memo: string | null; + memo: string | null; + updated_by: string | null; +} + +/** 아직 세금계산서 발행일을 안 적은 건 — 화면 맨 위 「입력 필요」 */ +export interface PnlPending { + quote_id: number; + quote_no: string | null; + customer: string | null; + contracted_on: string | null; + /** 계약서에서 가져온 공급가액(VAT 별도) — 저장 전 미리보기 */ + supply_default: number | null; + deposit_default: number; +} + +const n = (v: bigint | number | null | undefined): number => Number(v ?? 0); +const day = (d: Date | null | undefined): string | null => (d ? d.toISOString().slice(0, 10) : null); + +/** DB 행 + 견적을 화면 모양으로 */ +type RowWithQuote = { + quote_id: number; invoice_on: Date | null; biz_name: string | null; + supply_amount: bigint; deposit: bigint; capital: bigint; + deposit_paid_on: Date | null; capital_paid_on: Date | null; + cost_outsourcing: bigint; cost_supply: bigint; cost_internal: bigint; cost_etc: bigint; + cost_source: string; cost_memo: string | null; memo: string | null; updated_by: string | null; + quote: { + quote_no: string | null; sales_user_id: string | null; + customer: { name: string } | null; + order: { maker_org: { name: string } | null } | null; + }; +}; + +function toRow(r: RowWithQuote): PnlRow { + return { + quote_id: r.quote_id, + quote_no: r.quote.quote_no, + customer: r.quote.customer?.name ?? null, + biz_name: r.biz_name, + sales_user_id: r.quote.sales_user_id, + maker_org: r.quote.order?.maker_org?.name ?? null, + invoice_on: day(r.invoice_on), + supply_amount: n(r.supply_amount), + deposit: n(r.deposit), + capital: n(r.capital), + deposit_paid_on: day(r.deposit_paid_on), + capital_paid_on: day(r.capital_paid_on), + cost_outsourcing: n(r.cost_outsourcing), + cost_supply: n(r.cost_supply), + cost_internal: n(r.cost_internal), + cost_etc: n(r.cost_etc), + cost_source: r.cost_source, + cost_memo: r.cost_memo, + memo: r.memo, + updated_by: r.updated_by, + }; +} + +const QUOTE_PICK = { + quote_no: true, sales_user_id: true, + customer: { select: { name: true } }, + order: { select: { maker_org: { select: { name: true } } } }, +} as const; + +/** 그 달에 세금계산서가 나간 줄들 */ +export async function pnlOfMonth(ym: string): Promise { + if (!prisma) return []; + const { from, to } = monthBounds(ym); + const rows = await prisma.orderPnl.findMany({ + where: { invoice_on: { gte: new Date(from), lt: new Date(to) } }, + orderBy: [{ invoice_on: 'asc' }, { quote_id: 'asc' }], + include: { quote: { select: QUOTE_PICK } }, + }); + return rows.map(r => toRow(r as unknown as RowWithQuote)); +} + +/** + * 계약서에서 가져오는 처음 값 — **특장 결제금액**에서 공급가액(VAT 별도)과 계약금을 뽑는다. + * + * `body_payment` 는 VAT 포함이라 1.1 로 나눠 공급가액을 낸다. 계약금은 특장 계약금 그대로다. + * 계산에 실패하면(옛 견적·단가 누락) **null 을 돌려준다** — 0 으로 채우면 「0원짜리 판매」가 된다. + */ +export async function contractDefaults(quoteId: number): Promise<{ supply: number | null; deposit: number; biz_name: string | null }> { + if (!prisma) return { supply: null, deposit: 0, biz_name: null }; + /* + * ⚠️ **여기서 던지면 손익 화면 전체가 안 뜬다.** 「입력 필요」는 수십 줄을 한 번에 세는데, + * 그 중 한 건이 옛 견적·단가 누락으로 못 세는 일이 실제로 있다. 한 줄을 못 세면 그 줄만 + * 비워 두고 손으로 적게 한다 — 나머지 줄까지 같이 잃지 않는다. + */ + const q = await prisma.quote.findUnique({ where: { id: quoteId }, include: { customer: true } }).catch(() => null); + if (!q) return { supply: null, deposit: 0, biz_name: null }; + const inp = (q.inputs ?? {}) as Record; + const customer: CustomerInput = { + name: q.customer?.name, + biz_type: inp['biz_type'] as string | undefined, + is_sosang: inp['is_sosang'] as boolean | undefined, + region: inp['region'] as string | undefined, + has_transport_license: inp['has_transport_license'] as boolean | undefined, + diesel_status: inp['diesel_status'] as string | undefined, + has_biz_plate: inp['has_biz_plate'] as boolean | undefined, + tax_exempt_type: inp['tax_exempt_type'] as string | undefined, + }; + /** 계약서에 적힌 상호 — 없으면 고객명으로 시작한다(경영관리가 고친다) */ + const bizName = (inp['contract_party'] as string | undefined)?.trim() || q.customer?.name || null; + try { + // 보조금·세율은 **견적을 만든 해** 기준이다 — 올해 표로 다시 세면 작년 계약 금액이 바뀐다 + const calcYear = q.created_at.getFullYear(); + const params = await buildQuoteParams( + q.model_code, (q.selections ?? {}) as Record, customer, quoteExtraFromInputs(inp), calcYear, + ); + const total = calcQuote(params); + // 특장 결제금액(VAT 포함) → 공급가액(VAT 별도). 그냥 나누면 VAT 버림 때문에 1원이 샌다 + const supply = total.body_payment > 0 ? supplyFromGross(total.body_payment) : null; + return { supply, deposit: Math.max(0, Math.round(total.body_deposit || 0)), biz_name: bizName }; + } catch { + // 옛 견적·단가 누락으로 다시 셀 수 없는 건이 있다. 그럴 땐 손으로 적게 둔다 + return { supply: null, deposit: 0, biz_name: bizName }; + } +} + +/** 값을 다시 셀 만큼만 — 「입력 필요」가 수백 줄이면 계산을 건너뛴다(그때는 손으로 적는다) */ +const DEFAULTS_LIMIT = 60; + +/** + * 아직 발행일을 안 적은 건 — **계약 완료 이상**이면서 손익 줄이 없거나 발행일이 빈 것. + * 오래된 것부터 위에 둔다(먼저 처리할 순서). + */ +export async function pnlPending(): Promise { + if (!prisma) return []; + const quotes = await prisma.quote.findMany({ + where: { + ...VISIBLE, + status: { in: ['contracted', 'assigned', 'ordered', 'completed'] }, + OR: [{ pnl: { is: null } }, { pnl: { invoice_on: null } }], + }, + select: { + id: true, quote_no: true, created_at: true, + customer: { select: { name: true } }, + contracts: { orderBy: { created_at: 'desc' }, take: 1, select: { completed_at: true } }, + pnl: { select: { biz_name: true, supply_amount: true, deposit: true } }, + }, + orderBy: { created_at: 'asc' }, + }); + + const out: PnlPending[] = []; + for (const q of quotes) { + const kept = q.pnl; + let supply: number | null = kept ? n(kept.supply_amount) : null; + let deposit = kept ? n(kept.deposit) : 0; + // 이미 적어 둔 줄이 있으면 그 값을 쓴다 — 다시 세지 않는다(굳힌 값이 정본이다) + if (!kept && out.length < DEFAULTS_LIMIT) { + const d = await contractDefaults(q.id); + supply = d.supply; deposit = d.deposit; + } + out.push({ + quote_id: q.id, + quote_no: q.quote_no, + customer: q.customer?.name ?? null, + contracted_on: day(q.contracts[0]?.completed_at ?? null) ?? day(q.created_at), + supply_default: supply, + deposit_default: deposit, + }); + } + return out; +} + +/** 화면이 보낸 값 — 안 보낸 칸은 그대로 둔다(부분 저장) */ +export interface PnlPatch { + invoice_on?: string | null; + biz_name?: string | null; + supply_amount?: number; + deposit?: number; + capital?: number; + deposit_paid_on?: string | null; + capital_paid_on?: string | null; + cost_outsourcing?: number; + cost_supply?: number; + cost_internal?: number; + cost_etc?: number; + cost_memo?: string | null; + memo?: string | null; +} + +const money = (v: unknown): bigint | undefined => + typeof v === 'number' && Number.isFinite(v) ? BigInt(Math.max(0, Math.round(v))) : undefined; +const dateOf = (v: unknown): Date | null | undefined => { + if (v === null) return null; + if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return undefined; + const d = new Date(`${v}T00:00:00Z`); + return Number.isNaN(d.getTime()) ? undefined : d; +}; +const text = (v: unknown, max: number): string | null | undefined => { + if (v === null) return null; + if (typeof v !== 'string') return undefined; + return v.trim().slice(0, max) || null; +}; + +/** + * 줄 하나를 적는다(없으면 만든다). + * + * 처음 만들 때만 계약서에서 공급가액·계약금·사업자명을 채운다 — 화면이 값을 함께 보냈으면 그 값이 이긴다. + */ +export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Promise { + if (!prisma) return null; + const exists = await prisma.orderPnl.findUnique({ where: { quote_id: quoteId }, select: { id: true } }); + + const data: Record = { updated_by: by }; + 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('capital', money(patch.capital)); + put('deposit_paid_on', dateOf(patch.deposit_paid_on)); + put('capital_paid_on', dateOf(patch.capital_paid_on)); + put('cost_outsourcing', money(patch.cost_outsourcing)); + put('cost_supply', money(patch.cost_supply)); + put('cost_internal', money(patch.cost_internal)); + put('cost_etc', money(patch.cost_etc)); + put('cost_memo', text(patch.cost_memo, 500)); + put('memo', text(patch.memo, 500)); + + if (!exists) { + const d = await contractDefaults(quoteId); + if (data['supply_amount'] === undefined && d.supply !== null) data['supply_amount'] = BigInt(d.supply); + if (data['deposit'] === undefined) data['deposit'] = BigInt(d.deposit); + if (data['biz_name'] === undefined && d.biz_name) data['biz_name'] = d.biz_name; + await prisma.orderPnl.create({ data: { quote_id: quoteId, ...data } as never }); + } else { + await prisma.orderPnl.update({ where: { quote_id: quoteId }, data: data as never }); + } + + const saved = await prisma.orderPnl.findUnique({ + where: { quote_id: quoteId }, + include: { quote: { select: QUOTE_PICK } }, + }); + return saved ? toRow(saved as unknown as RowWithQuote) : null; +} + +/** 줄이 있는 달들 — 화면의 달 고르개가 「있는 달」만 보여 준다 */ +export async function pnlMonths(): Promise { + if (!prisma) return []; + const rows = await prisma.orderPnl.findMany({ + where: { invoice_on: { not: null } }, + select: { invoice_on: true }, + orderBy: { invoice_on: 'desc' }, + }); + return [...new Set(rows.map(r => day(r.invoice_on)!.slice(0, 7)))]; +} diff --git a/db/seed/access_control.csv b/db/seed/access_control.csv index 234684f..96faa7e 100644 --- a/db/seed/access_control.csv +++ b/db/seed/access_control.csv @@ -102,3 +102,7 @@ preset,master,quote.delete,Y,프리셋 기본값 — 기능모듈 화면에서 preset,master,quote.edit,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,stats.all,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,stats.own,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 +preset,exec,pnl.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 +preset,exec,pnl.manage,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 +preset,master,pnl.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 +preset,master,pnl.manage,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 diff --git a/db/seed/feature_module.csv b/db/seed/feature_module.csv index e468cc4..e4decba 100644 --- a/db/seed/feature_module.csv +++ b/db/seed/feature_module.csv @@ -25,3 +25,5 @@ basedata.optiondb,옵션DB 관리,관리자,19,Y basedata.makerprice,특장사 단가 관리,관리자,20,Y basedata.holiday,공휴일 관리,관리자,21,Y addon.view,부가작업 조회,관리자,22,Y +pnl.view,손익 조회,관리자,23,Y +pnl.manage,손익 관리,관리자,24,Y diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts new file mode 100644 index 0000000..a3f0be9 --- /dev/null +++ b/frontend/src/api/pnl.ts @@ -0,0 +1,74 @@ +/** + * 차량 판매건별 손익 — 경영관리 화면이 읽고 적는다. + * 권한은 서버가 정한다(`pnl.view` 로 보고 `pnl.manage` 로 적는다). + */ +export interface PnlRow { + quote_id: number + quote_no: string | null + customer: string | null + biz_name: string | null + sales_user_id: string | null + maker_org: string | null + invoice_on: string | null + supply_amount: number + deposit: number + capital: number + deposit_paid_on: string | null + capital_paid_on: string | null + cost_outsourcing: number + cost_supply: number + cost_internal: number + cost_etc: number + /** 'manual' = 손으로 적었다 / 'system' = 원가 시스템이 채웠다 */ + cost_source: string + cost_memo: string | null + memo: string | null + updated_by: string | null +} + +/** 아직 세금계산서 발행일을 안 적은 건 — 화면 맨 위 「입력 필요」 */ +export interface PnlPending { + quote_id: number + quote_no: string | null + customer: string | null + contracted_on: string | null + supply_default: number | null + deposit_default: number +} + +export interface PnlView { + month: string + rows: PnlRow[] + pending: PnlPending[] + /** 줄이 있는 달들 — 달 고르개가 「있는 달」을 함께 보여 준다 */ + months: string[] +} + +/** 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장) */ +export type PnlPatch = Partial> + +async function jsonOf(res: Response): Promise { + if (!res.ok) { + const b = await res.json().catch(() => ({})) as { error?: { message?: string } } + throw new Error(b.error?.message ?? `요청 실패: ${res.status}`) + } + return (await res.json() as { data: T }).data +} + +export function fetchPnl(month?: string): Promise { + const qs = month ? `?month=${encodeURIComponent(month)}` : '' + return fetch(`/api/v1/pnl${qs}`, { credentials: 'include' }).then(jsonOf) +} + +export function savePnl(quoteId: number, patch: PnlPatch): Promise { + return fetch(`/api/v1/pnl/${quoteId}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }).then(jsonOf) +} diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx new file mode 100644 index 0000000..fc44acd --- /dev/null +++ b/frontend/src/components/PnlTab.tsx @@ -0,0 +1,557 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { t, tf } from '../i18n' +import { fetchPnl, savePnl, type PnlRow, type PnlPending, type PnlPatch } from '../api/pnl' +import { deriveP, sumP, monthOf } from '@shared/finance/pnl' +import { usePermission } from './PermGate' +import { useIsMobile } from '../hooks/useIsMobile' +import { useEscapeClose } from '../lib/escClose' +import { BTN } from '../styles/buttons' +import { DateField } from './ui/DateField' + +/** + * **차량 판매건별 손익** — 경영관리가 월 단위로 적고 보는 표(2026-09-16 지시). + * + * 지금 쓰는 엑셀(차량판매_손익_양식)을 그대로 옮겼다. 달을 가르는 기준은 **세금계산서 발행일**이고, + * 발행일을 적는 순간 그 달 표로 들어간다. + * + * 화면은 세 덩이다. + * ① **요약** — 「주문 진행」 탭처럼 맨 위에 그 달 전체를 한 줄로 + * ② **입력 필요** — 발행일을 아직 안 적은 건. 여기서 날짜를 적으면 해당 달로 들어간다 + * ③ **그 달의 표** — 적으면서 보는 자리 + * + * 적는 방식은 **칸을 벗어나면 저장**이다(줄마다 따로). 「저장」 버튼을 누르게 하면 + * 스무 줄을 고칠 때 스무 번을 눌러야 하고, 안 누른 줄이 생긴다. + * + * ⚠️ VAT·공급대가·입금 차액·수익은 **적지 않는다** — 엑셀과 같은 식으로 그때그때 낸다 + * (shared/finance/pnl). 화면과 서버가 같은 함수를 쓴다. + */ +export function PnlTab() { + const isMobile = useIsMobile() + const canEdit = usePermission('pnl.manage') + const [view, setView] = useState> | null>(null) + const [month, setMonth] = useState(null) + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback((ym?: string) => { + setBusy(true); setErr('') + fetchPnl(ym) + .then(v => { setView(v); setMonth(v.month) }) + .catch(e => setErr(e instanceof Error ? e.message : t('손익을 불러오지 못했습니다'))) + .finally(() => setBusy(false)) + }, []) + useEffect(() => { load() }, [load]) + + /** 한 줄을 적는다. 저장된 줄이 이 달에 안 맞으면(발행일을 바꿨다) 다시 불러 자리를 옮긴다 */ + const write = useCallback(async (quoteId: number, patch: PnlPatch) => { + const saved = await savePnl(quoteId, patch) + const stillHere = saved.invoice_on && month && monthOf(saved.invoice_on) === month + if (!stillHere) { load(month ?? undefined); return saved } + setView(v => v && ({ + ...v, + rows: v.rows.some(r => r.quote_id === quoteId) + ? v.rows.map(r => (r.quote_id === quoteId ? saved : r)) + : [...v.rows, saved], + pending: v.pending.filter(p => p.quote_id !== quoteId), + })) + return saved + }, [month, load]) + + const rows = view?.rows ?? [] + const total = useMemo(() => sumP(rows), [rows]) + + return ( +
+ { setMonth(ym); load(ym) }} + /> + {err &&
{err}
} + + {/* ① 요약 — 그 달 전체 */} + + + {/* ② 입력 필요 — 맨 위에 따로 둔다. 여기서 발행일을 적으면 그 달 표로 들어간다 */} + + + {/* ③ 그 달의 표 */} +
+
+ {month ? tf('{0}년 {1}월', Number(month.slice(0, 4)), Number(month.slice(5, 7))) : t('손익')} + {tf('{0}건', rows.length)} +
+ {rows.length === 0 ? ( +
{t('이 달에 세금계산서가 발행된 건이 없습니다.')}
+ ) : isMobile ? ( +
+ {rows.map(r => )} +
+ ) : ( + + )} +
+
+ ) +} + +/* ── 달 고르개 ─────────────────────────────────────────────────────────── */ + +function shiftMonth(ym: string, n: number): string { + const [y, m] = ym.split('-').map(Number) as [number, number] + const d = new Date(y, m - 1 + n, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} + +function MonthBar({ month, months, busy, onPick }: { + month: string | null; months: string[]; busy: boolean; onPick: (ym: string) => void +}) { + if (!month) return
{t('불러오는 중…')}
+ const label = tf('{0}년 {1}월', Number(month.slice(0, 4)), Number(month.slice(5, 7))) + return ( +
+ + {label} + + {/* 줄이 있는 달만 — 빈 달을 하나씩 넘겨 가며 찾지 않아도 된다 */} + {months.length > 0 && ( + + )} + {busy && {t('불러오는 중…')}} +
+ ) +} + +/* ── 요약 ─────────────────────────────────────────────────────────────── */ + +const won = (n: number) => `₩${Math.round(n).toLocaleString('ko-KR')}` +const pct = (v: number | null) => (v === null ? '—' : `${(v * 100).toFixed(1)}%`) + +function SummaryBar({ total, isMobile }: { total: ReturnType; isMobile: boolean }) { + const tiles: { label: string; value: string; tone?: 'warn' | 'good' }[] = [ + { label: '발행 건수', value: tf('{0}건', total.count) }, + { label: '공급가액', value: won(total.supply_amount) }, + { label: 'VAT', value: won(total.vat) }, + { label: '공급대가', value: won(total.gross) }, + { label: '계약금', value: won(total.deposit) }, + { label: '캐피탈', value: won(total.capital) }, + // 아직 안 들어온 돈 — 음수가 정상이다 + { label: '입금 차액', value: won(total.pay_diff), tone: total.pay_diff < 0 ? 'warn' : undefined }, + { label: '원가', value: won(total.cost_total) }, + { label: '수익', value: `${won(total.profit)} · ${pct(total.margin)}`, tone: total.profit < 0 ? 'warn' : 'good' }, + ] + return ( +
+
+ {tiles.map(x => ( +
+
{t(x.label)}
+
{x.value}
+
+ ))} +
+
+ ) +} + +/* ── 입력 필요 ─────────────────────────────────────────────────────────── */ + +function PendingBox({ pending, canEdit, isMobile, onWrite }: { + pending: PnlPending[]; canEdit: boolean; isMobile: boolean + onWrite: (quoteId: number, patch: PnlPatch) => Promise +}) { + const [open, setOpen] = useState(true) + return ( +
0 ? s.cardAlert : s.card}> + + {open && (pending.length === 0 ? ( +
{t('발행일을 적어야 할 건이 없습니다.')}
+ ) : ( +
+ {pending.map(p => ( + + ))} +
+ ))} +
+ ) +} + +function PendingRow({ item, canEdit, isMobile, onWrite }: { + item: PnlPending; canEdit: boolean; isMobile: boolean + onWrite: (quoteId: number, patch: PnlPatch) => Promise +}) { + const [date, setDate] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + const save = async () => { + if (!date || busy) return + setBusy(true); setErr('') + try { await onWrite(item.quote_id, { invoice_on: date }) } + catch (e) { setErr(e instanceof Error ? e.message : t('저장하지 못했습니다')) } + finally { setBusy(false) } + } + return ( +
+ {item.quote_no ?? `#${item.quote_id}`} + {item.customer ?? '—'} + {item.contracted_on ?? '—'} + {item.supply_default === null ? '—' : won(item.supply_default)} + + + {err && {err}} +
+ ) +} + +/* ── 적는 칸 ──────────────────────────────────────────────────────────── */ + +/** 금액 칸 — 세 자리마다 쉼표로 보여 주고 숫자로 돌려준다. 칸을 벗어날 때 저장한다 */ +function MoneyCell({ value, disabled, label, onSave }: { + value: number; disabled?: boolean; label: string; onSave: (v: number) => void +}) { + const [draft, setDraft] = useState(null) + const shown = draft ?? (value ? value.toLocaleString('ko-KR') : '') + return ( + setDraft(e.target.value.replace(/[^0-9]/g, ''))} + onBlur={() => { + if (draft === null) return + const next = Number(draft || 0) + setDraft(null) + if (next !== value) onSave(next) + }} + /> + ) +} + +/** + * 날짜 칸 — **앱 한 벌(DateField)** 을 쓴다. 브라우저 기본 달력은 위치를 못 바꿔 + * 화면 아래쪽 줄에서 잘린다(ui-compact 검사가 이걸 지킨다). + * 날짜는 고르는 순간이 곧 확정이라 **고르면 바로 저장**한다. + */ +function DateCell({ value, disabled, label, onSave }: { + value: string | null; disabled?: boolean; label: string; onSave: (v: string | null) => void +}) { + return ( + { const next = v || null; if (next !== value) onSave(next) }} + /> + ) +} + +function TextCell({ value, disabled, label, onSave, wide }: { + value: string | null; disabled?: boolean; label: string; wide?: boolean; onSave: (v: string | null) => void +}) { + return ( + { + const next = e.target.value.trim() || null + if (next !== value) onSave(next) + }} + /> + ) +} + +/** 줄 하나를 적는 손잡이 — 표와 카드가 같은 것을 쓴다 */ +function useRowWriter(row: PnlRow, onWrite: (quoteId: number, patch: PnlPatch) => Promise) { + const [state, setState] = useState<'idle' | 'saving' | 'done' | 'fail'>('idle') + const put = (patch: PnlPatch) => { + setState('saving') + onWrite(row.quote_id, patch) + .then(() => { setState('done'); setTimeout(() => setState(x => (x === 'done' ? 'idle' : x)), 1200) }) + .catch(() => setState('fail')) + } + const mark = state === 'saving' ? '…' : state === 'done' ? '✓' : state === 'fail' ? '!' : '' + return { put, mark, failed: state === 'fail' } +} + +/* ── PC·태블릿 표 ─────────────────────────────────────────────────────── */ + +function RowTable({ rows, canEdit, onWrite }: { + rows: PnlRow[]; canEdit: boolean + onWrite: (quoteId: number, patch: PnlPatch) => Promise +}) { + const total = sumP(rows) + return ( + // 칸이 열셋이라 태블릿에서는 옆으로 민다 — 줄여서 읽을 수 없는 숫자를 만들지 않는다 +
+ + + + {['고객명', '사업자명', '발행일', '공급가액', 'VAT', '공급대가', '계약금', '캐피탈', '입금 차액', '입금일', '원가', '수익', '비고'] + .map(h => )} + + + + {rows.map(r => )} + + + + + + + + + + + + + + +
{t(h)}
{t('합계')}{won(total.supply_amount)}{won(total.vat)}{won(total.gross)}{won(total.deposit)}{won(total.capital)}{won(total.pay_diff)} + {won(total.cost_total)}{won(total.profit)} +
+
+ ) +} + +function TableRow({ row, canEdit, onWrite }: { + row: PnlRow; canEdit: boolean + onWrite: (quoteId: number, patch: PnlPatch) => Promise +}) { + const d = deriveP(row) + const { put, mark, failed } = useRowWriter(row, onWrite) + const [costOpen, setCostOpen] = useState(false) + const ro = !canEdit + return ( + + + {row.customer ?? '—'} + {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} + + put({ biz_name: v })} /> + put({ invoice_on: v })} /> + put({ supply_amount: v })} /> + {won(d.vat)} + {won(d.gross)} + put({ deposit: v })} /> + put({ capital: v })} /> + {won(d.pay_diff)} + +
{t('계약금')} + put({ deposit_paid_on: v })} />
+
{t('캐피탈')} + put({ capital_paid_on: v })} />
+ + + + {costOpen && setCostOpen(false)} onSave={put} />} + + {won(d.profit)} + put({ memo: v })} /> + + ) +} + +/* ── 휴대폰 카드 ──────────────────────────────────────────────────────── */ + +function RowCard({ row, canEdit, onWrite }: { + row: PnlRow; canEdit: boolean + onWrite: (quoteId: number, patch: PnlPatch) => Promise +}) { + const d = deriveP(row) + const { put, mark, failed } = useRowWriter(row, onWrite) + const [open, setOpen] = useState(false) + const [costOpen, setCostOpen] = useState(false) + const ro = !canEdit + return ( +
+ + {/* 접힌 채로도 「얼마 남았나」는 보여야 한다 — 그게 이 표를 보는 이유다 */} +
+ {t('입금 차액')} {won(d.pay_diff)} + {t('수익')} {won(d.profit)} +
+ {open && ( +
+ put({ biz_name: v })} /> + put({ invoice_on: v })} /> + put({ supply_amount: v })} /> + {won(d.vat)} + {won(d.gross)} + put({ deposit: v })} /> + put({ capital: v })} /> + put({ deposit_paid_on: v })} /> + put({ capital_paid_on: v })} /> + + + + put({ memo: v })} /> +
+ )} + {costOpen && setCostOpen(false)} onSave={put} />} +
+ ) +} + +function Field({ label, wide, children }: { label: string; wide?: boolean; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +/* ── 원가 ─────────────────────────────────────────────────────────────── */ + +/** + * 원가 — **별도 시스템에서 불러올 자리**(아직 만드는 중). 그때까지는 손으로 적는다. + * 넷으로 나눠 두는 이유는, 나중에 자동으로 채울 때 어느 칸에 무엇이 들어갈지 미리 정해 두기 위함이다. + */ +function CostModal({ row, canEdit, onClose, onSave }: { + row: PnlRow; canEdit: boolean; onClose: () => void; onSave: (patch: PnlPatch) => void +}) { + useEscapeClose(onClose) + const d = deriveP(row) + const parts: { key: keyof PnlPatch; label: string; value: number }[] = [ + { key: 'cost_outsourcing', label: '외주비', value: row.cost_outsourcing }, + { key: 'cost_supply', label: '사급비', value: row.cost_supply }, + { key: 'cost_internal', label: '내부비용', value: row.cost_internal }, + { key: 'cost_etc', label: '기타', value: row.cost_etc }, + ] + return ( +
{ if (ev.target === ev.currentTarget) onClose() }}> +
+
+ {tf('원가 · {0}', row.customer ?? String(row.quote_id))} + +
+
+ {parts.map(p => ( + + onSave({ [p.key]: v } as PnlPatch)} /> + + ))} + + onSave({ cost_memo: v })} /> + +
+ {t('총원가')} {won(d.cost_total)} + {t('수익')} {won(d.profit)} · {pct(d.margin)} +
+
{t('원가는 별도 시스템과 이어질 자리입니다. 지금은 직접 적습니다.')}
+
+
+
+ ) +} + +/* ── 모양 ─────────────────────────────────────────────────────────────── */ + +const cardBase: React.CSSProperties = { + background: '#fff', border: 'var(--hairline)', borderRadius: 'var(--r-md)', + padding: 'var(--sp-4)', display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', minWidth: 0, +} +const cellNum: React.CSSProperties = { fontVariantNumeric: 'tabular-nums', textAlign: 'right', whiteSpace: 'nowrap' } + +const s: Record = { + root: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-3)' }, + card: cardBase, + cardAlert: { ...cardBase, borderLeft: '3px solid var(--req)' }, + cardHead: { display: 'flex', alignItems: 'baseline', gap: 'var(--sp-2)' }, + cardHeadBtn: { + display: 'flex', alignItems: 'baseline', gap: 'var(--sp-2)', flexWrap: 'wrap', + border: 'none', background: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', + }, + cardTitle: { fontSize: 'var(--fs-section)', fontWeight: 'var(--fw-section)' as React.CSSProperties['fontWeight'], color: 'var(--dark)' }, + cardCount: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums' }, + cardCountWarn: { fontSize: 'var(--fs-caption)', color: 'var(--req)', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }, + caret: { color: 'var(--muted)', fontSize: 11 }, + + monthBar: { display: 'flex', alignItems: 'center', gap: 'var(--sp-2)', flexWrap: 'wrap' }, + navBtn: { border: 'var(--hairline)', background: '#fff', borderRadius: 6, width: 30, height: 30, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--dark)' }, + monthLabel: { fontSize: 'var(--fs-body)', fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', minWidth: 110, textAlign: 'center' }, + // 줄이 바뀌어도 폭을 다 먹지 않게 — 고르개가 화면 한 줄을 차지하면 달 이동 단추가 멀어진다 + monthSelect: { flex: '0 0 auto', maxWidth: 200, fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 8px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, + + sumGrid: { display: 'grid', gap: 'var(--sp-3)' }, + sumCell: { minWidth: 0 }, + sumLabel: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', whiteSpace: 'nowrap' }, + sumVal: { fontSize: 18, fontWeight: 700, color: 'var(--dark)', ...cellNum, textAlign: 'left' }, + sumValWarn: { fontSize: 18, fontWeight: 700, color: 'var(--req)', ...cellNum, textAlign: 'left' }, + sumValGood: { fontSize: 18, fontWeight: 700, color: 'var(--dark)', ...cellNum, textAlign: 'left' }, + + pendList: { display: 'flex', flexDirection: 'column', maxHeight: 260, overflowY: 'auto' }, + pendRow: { display: 'grid', gridTemplateColumns: '92px 1fr 90px 130px 150px 64px', alignItems: 'center', gap: 8, padding: '6px 0', borderTop: 'var(--hairline)', fontSize: 'var(--fs-label)' }, + pendRowMobile: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6, padding: '8px 0', borderTop: 'var(--hairline)', fontSize: 'var(--fs-label)' }, + pendNo: { fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + pendName: { color: 'var(--body)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + pendSub: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', whiteSpace: 'nowrap' }, + pendAmount: { ...cellNum, color: 'var(--body)', fontSize: 'var(--fs-caption)' }, + + tableWrap: { overflowX: 'auto' }, + table: { borderCollapse: 'collapse', width: '100%', minWidth: 1180 }, + th: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'left', padding: '6px 6px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, + td: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', verticalAlign: 'middle' }, + tdNum: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', ...cellNum }, + tdCalc: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--muted)', ...cellNum }, + tdCalcWarn: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--req)', fontWeight: 700, ...cellNum }, + tdCalcGood: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, ...cellNum }, + tfLabel: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)' }, + tfNum: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)', ...cellNum }, + tfNumWarn: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--req)', fontSize: 'var(--fs-label)', ...cellNum }, + + name: { display: 'block', color: 'var(--dark)', fontWeight: 700, whiteSpace: 'nowrap' }, + no: { display: 'block', color: 'var(--muted)', fontSize: 'var(--fs-caption)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + mark: { color: 'var(--lime)' }, + markFail: { color: 'var(--req)' }, + + moneyInput: { width: '100%', minWidth: 88, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, + dateInput: { boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '4px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, + textInput: { width: '100%', minWidth: 90, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, + textInputWide: { width: '100%', minWidth: 140, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, + payRow: { display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }, + payTag: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', width: 38, whiteSpace: 'nowrap' }, + costBtn: { border: 'var(--hairline)', background: '#fff', borderRadius: 'var(--r-sm)', padding: '5px 8px', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + + 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 }, + rowCardHead: { display: 'flex', alignItems: 'baseline', gap: 6, border: 'none', background: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', width: '100%' }, + rowCardSum: { display: 'flex', gap: 6, flexWrap: 'wrap' }, + cardAmount: { marginLeft: 'auto', ...cellNum, fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)' }, + chip: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', border: 'var(--hairline)', borderRadius: 999, padding: '1px 8px', whiteSpace: 'nowrap' }, + chipWarn: { fontSize: 'var(--fs-caption)', color: 'var(--req)', border: '1px solid var(--req)', borderRadius: 999, padding: '1px 8px', whiteSpace: 'nowrap' }, + chipGood: { fontSize: 'var(--fs-caption)', color: 'var(--dark)', border: '1px solid var(--dark)', borderRadius: 999, padding: '1px 8px', whiteSpace: 'nowrap' }, + fields: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8, paddingTop: 6, borderTop: 'var(--hairline)' }, + field: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }, + fieldWide: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, gridColumn: '1 / -1' }, + fieldLabel: { fontSize: 'var(--fs-caption)', color: 'var(--muted)' }, + calc: { fontSize: 'var(--fs-label)', color: 'var(--body)', ...cellNum, textAlign: 'left', padding: '5px 0' }, + + overlay: { position: 'fixed', inset: 0, background: 'var(--scrim)', zIndex: 1100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--sp-4)' }, + modal: { background: '#fff', borderRadius: 12, width: 'min(520px, 96vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 10px 40px rgba(22,24,15,.22)' }, + modalHead: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--sp-4)', borderBottom: 'var(--hairline)' }, + modalBody: { overflowY: 'auto', padding: 'var(--sp-4)', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }, + close: { border: 'none', background: 'none', fontSize: 16, color: 'var(--muted)', cursor: 'pointer', width: 36, height: 36 }, + costSum: { gridColumn: '1 / -1', display: 'flex', justifyContent: 'space-between', gap: 8, paddingTop: 8, borderTop: '1px solid var(--dark)', fontSize: 'var(--fs-label)', color: 'var(--dark)', flexWrap: 'wrap' }, + note: { gridColumn: '1 / -1', fontSize: 'var(--fs-caption)', color: 'var(--muted)', lineHeight: 1.6 }, + + muted: { fontSize: 'var(--fs-caption)', color: 'var(--muted)' }, + empty: { fontSize: 'var(--fs-label)', color: 'var(--muted)', padding: '10px 0' }, + err: { fontSize: 'var(--fs-label)', color: 'var(--warn)' }, +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index df042ac..fe46ed6 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -16,6 +16,34 @@ export const EN: Record = { '명': '', '건': '', '예정': 'Planned', + '손익': 'P&L', + '손익을 불러오지 못했습니다': 'Could not load the P&L', + '입력 필요': 'Needs input', + '세금계산서 발행일': 'Invoice date', + '세금계산서 발행일을 적으면 그 달 표로 들어갑니다': 'Entering the invoice date files the row into that month', + '발행일을 적어야 할 건이 없습니다.': 'Nothing is waiting for an invoice date.', + '이 달에 세금계산서가 발행된 건이 없습니다.': 'No invoices were issued this month.', + '기록이 있는 달': 'Months with records', + '발행 건수': 'Invoices', + '발행일': 'Invoiced', + '고객명': 'Customer', + '사업자명': 'Business name', + '공급대가': 'Total incl. VAT', + '계약금': 'Deposit', + '캐피탈': 'Financing', + '입금 차액': 'Unpaid balance', + '계약금 입금일': 'Deposit paid on', + '캐피탈 입금일': 'Financing paid on', + '원가': 'Cost', + '총원가': 'Total cost', + '원가 메모': 'Cost note', + '수익': 'Profit', + '외주비': 'Outsourcing', + '사급비': 'Supplied parts', + '내부비용': 'Internal', + '원가는 별도 시스템과 이어질 자리입니다. 지금은 직접 적습니다.': 'Costs will be fed from a separate system. For now they are entered by hand.', + '손익 조회 — 판매건별 매출·원가·수익 (보기 전용)': 'View P&L — revenue, cost and profit per sale (read-only)', + '손익 입력 — 세금계산서 발행일·입금·원가를 적는다': 'Enter P&L — invoice date, payments and costs', '배정할 건이 없습니다.': 'Nothing to assign.', '납기 경과 · 임박': 'Due · overdue', '납기가 급한 건이 없습니다.': 'No orders are close to their due date.', @@ -1237,6 +1265,7 @@ export const EN: Record = { * 쓰는 법: tf('{0} 대화', name) */ export const EN_FMT: Record = { + '원가 · {0}': 'Cost · {0}', '{0}일 경과': '{0}d over', '{0}일 남음': '{0}d left', '{0} 일정': 'Schedule for {0}', diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index b502977..02f148b 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -29,6 +29,7 @@ import { Header } from '../components/Header' import { OrderDetail } from '../components/OrderDetail' import { OrderUnassignModal } from '../components/OrderUnassignModal' import { AdminDashboard, dashboardsFor } from '../components/dashboard/AdminDashboard' +import { PnlTab } from '../components/PnlTab' import { AssignRejectModal } from '../components/AssignRejectModal' import { useOrderDeepLink, useViewDeepLink, type OrderDeepLink } from '../lib/deepLink' import { useBackClose } from '../lib/backClose' @@ -111,8 +112,10 @@ const MODULE_DESC: Record = { 'order.remove': '주문 치우기 — 목록에서 감춘다(행은 남는다)', 'notify.push': '앱 알림 받기 (알림함·휴대폰 팝업)', 'notify.assign': '구 「제작 배정 알림 메일」 — 이제 역할 프리셋이 정한다(쓰지 않음)', + 'pnl.view': '손익 조회 — 판매건별 매출·원가·수익 (보기 전용)', + 'pnl.manage': '손익 입력 — 세금계산서 발행일·입금·원가를 적는다', } -type TabKey = 'home' | 'quotes' | 'customers' | 'perf' | 'kanban' | 'files' | 'toggles' | 'accounts' | 'weights' | 'dims' | 'optiondb' | 'makerprice' | 'checklist' | 'holidays' +type TabKey = 'home' | 'quotes' | 'customers' | 'perf' | 'pnl' | 'kanban' | 'files' | 'toggles' | 'accounts' | 'weights' | 'dims' | 'optiondb' | 'makerprice' | 'checklist' | 'holidays' function fmtPrice(n: number) { return n ? `₩${n.toLocaleString()}` : '—' } function fmtDate(s: string) { return s ? s.slice(0, 10) : '—' } @@ -2151,6 +2154,8 @@ export function AdminPage() { // 눌러서 「권한이 없습니다」를 보게 두면 왜 있는 버튼인지 알 수 없다. const perm = { stats: usePermission('stats.own'), + /* 손익 — 돈에 관한 표라 **보는 것부터** 권한을 건다(관리 권한은 보기를 겸한다) */ + pnl: usePermission('pnl.view'), orders: usePermission('order.view'), accounts: usePermission('account.manage'), checklist: usePermission('checklist.manage'), @@ -2171,6 +2176,7 @@ export function AdminPage() { { key: 'quotes', label: t('견적 목록'), show: true }, { key: 'customers', label: t('고객'), show: perm.customers }, { key: 'perf', label: t('영업 성과'), show: perm.stats }, + { key: 'pnl', label: t('손익'), show: perm.pnl }, { key: 'kanban', label: t('주문 진행'), show: perm.orders }, { key: 'checklist', label: t('체크리스트'), show: perm.checklist }, { key: 'files', label: t('파일'), show: perm.orders }, @@ -2212,6 +2218,7 @@ export function AdminPage() { {activeTab === 'quotes' && } {activeTab === 'customers' && } {activeTab === 'perf' && } + {activeTab === 'pnl' && } {activeTab === 'kanban' && } {activeTab === 'checklist' && } {activeTab === 'holidays' && } diff --git a/shared/finance/pnl.test.ts b/shared/finance/pnl.test.ts new file mode 100644 index 0000000..d91c715 --- /dev/null +++ b/shared/finance/pnl.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { vatOf, grossOf, payDiffOf, deriveP, sumP, monthBounds, isYearMonth, supplyFromGross } from './pnl'; + +/** + * **엑셀과 같은 숫자가 나오는가.** + * + * 정답지는 지금 쓰는 엑셀(차량판매_손익_양식.xlsx)의 실제 줄이다 — + * 26년8월 전진영: 공급가액 18,960,000 · VAT 1,896,000 · 공급대가 20,856,000 · + * 계약금 400,000 · 캐피탈 20,450,000 · 입금 차액 6,000. + * + * 금액 셈이 화면과 서버에서 갈리면 합계와 줄이 어긋난다. 그래서 함수는 한 벌이고, 여기서 못 박는다. + */ +describe('손익 셈 — 엑셀 재현', () => { + it('🔴 전진영 줄이 엑셀과 같다', () => { + const row = { supply_amount: 18_960_000, deposit: 400_000, capital: 20_450_000 }; + expect(vatOf(row.supply_amount)).toBe(1_896_000); + expect(grossOf(row.supply_amount)).toBe(20_856_000); + expect(payDiffOf(row.supply_amount, row.deposit, row.capital)).toBe(6_000); + }); + + it('🔴 캐피탈 전에는 입금 차액이 음수로 남는다 — 아직 안 들어온 돈이다', () => { + // 26년9월 (주)화이트축산: 공급가액 0 · 계약금 400,000 → −400,000 + expect(payDiffOf(0, 400_000, 0)).toBe(-400_000); + }); + + it('🔴 VAT 는 반올림이 아니라 **버림**이다 — ROUNDDOWN(E*10%,0)', () => { + expect(vatOf(1_000_005)).toBe(100_000); // 100,000.5 → 100,000 + expect(vatOf(1_000_009)).toBe(100_000); + expect(vatOf(0)).toBe(0); + }); + + it('🔴 수익은 **VAT 를 뺀 공급가액** 기준이다 — VAT 는 받아서 내는 돈이다', () => { + const d = deriveP({ + supply_amount: 10_000_000, deposit: 0, capital: 0, + cost_outsourcing: 6_000_000, cost_supply: 1_000_000, cost_internal: 500_000, cost_etc: 0, + }); + expect(d.cost_total).toBe(7_500_000); + expect(d.profit).toBe(2_500_000); + expect(d.margin).toBeCloseTo(0.25, 10); + }); + + it('🔴 공급가액이 0이면 수익률은 없다 — 0% 로 쓰면 「손해」로 읽힌다', () => { + expect(deriveP({ supply_amount: 0, deposit: 0, capital: 0 }).margin).toBeNull(); + }); + + it('🔴 합계는 줄과 같은 함수로 낸다 — 엑셀 20행', () => { + const rows = [ + { supply_amount: 18_960_000, deposit: 400_000, capital: 20_450_000 }, + { supply_amount: 0, deposit: 400_000, capital: 0 }, + { supply_amount: 0, deposit: 400_000, capital: 0 }, + { supply_amount: 0, deposit: 400_000, capital: 0 }, + { supply_amount: 0, deposit: 400_000, capital: 0 }, + ]; + const z = sumP(rows); + expect(z.count).toBe(5); + expect(z.supply_amount).toBe(18_960_000); + expect(z.vat).toBe(1_896_000); + expect(z.gross).toBe(20_856_000); + expect(z.capital).toBe(20_450_000); + // 엑셀 J20 = −1,594,000 + expect(z.pay_diff).toBe(-1_594_000); + }); +}); + +describe('계약서 금액에서 공급가액 되짚기', () => { + it('🔴 그냥 1.1 로 나누면 1원이 샌다 — 딱 맞는 값을 고른다', () => { + // 실제 견적의 특장 결제금액. round(/1.1) 은 18,463,636 이고 그 공급대가는 20,309,999 다 + expect(Math.round(20_310_000 / 1.1)).toBe(18_463_636); + expect(grossOf(18_463_636)).toBe(20_309_999); + expect(supplyFromGross(20_310_000)).toBe(18_463_637); + expect(grossOf(supplyFromGross(20_310_000))).toBe(20_310_000); + }); + + it('🔴 엑셀에 적힌 값과도 맞는다', () => { + expect(supplyFromGross(20_856_000)).toBe(18_960_000); + expect(supplyFromGross(0)).toBe(0); + }); + + it('🔴 어떤 금액이든 되짚은 값의 공급대가가 1원 넘게 벌어지지 않는다', () => { + for (const g of [1_100_000, 21_619_000, 33_333_333, 7, 999_999]) { + expect(Math.abs(grossOf(supplyFromGross(g)) - g), String(g)).toBeLessThanOrEqual(1); + } + }); +}); + +describe('달 가르기', () => { + it('🔴 12월 다음은 다음 해 1월이다', () => { + expect(monthBounds('2026-12')).toEqual({ from: '2026-12-01', to: '2027-01-01' }); + expect(monthBounds('2026-09')).toEqual({ from: '2026-09-01', to: '2026-10-01' }); + }); + + it('🔴 주소로 들어온 달 값을 믿지 않는다', () => { + for (const ok of ['2026-01', '2026-12']) expect(isYearMonth(ok)).toBe(true); + for (const bad of ['2026-13', '2026-00', '2026-1', '26-01', '', null, undefined, 42, '2026-09-01']) { + expect(isYearMonth(bad), String(bad)).toBe(false); + } + }); +}); diff --git a/shared/finance/pnl.ts b/shared/finance/pnl.ts new file mode 100644 index 0000000..4c9bfad --- /dev/null +++ b/shared/finance/pnl.ts @@ -0,0 +1,144 @@ +/** + * **차량 판매건별 손익** — 지금 쓰는 엑셀(차량판매_손익_양식)의 셈을 그대로 옮긴다(2026-09-16 지시). + * + * 화면과 서버가 **같은 함수**를 쓴다. 금액 셈이 두 벌이면 합계와 줄이 언젠가 어긋나고, + * 어긋난 쪽을 찾는 데 하루가 간다. + * + * 엑셀의 식: + * VAT = ROUNDDOWN(공급가액 × 10%, 0) ← 반올림이 아니라 **버림**이다 + * 공급대가 = 공급가액 + VAT + * 입금 차액 = 공급대가 − 캐피탈 − 계약금 + * + * ⚠️ VAT·공급대가·입금 차액은 **저장하지 않는다.** 적어 두면 공급가액을 고쳤을 때 둘이 + * 어긋나고, 어느 쪽이 맞는지 알 수 없어진다. 늘 여기서 낸다. + */ + +/** 부가가치세율 — 바뀌면 여기 한 곳 */ +export const VAT_RATE = 0.1; + +/** 엑셀 `ROUNDDOWN(E*10%,0)` — 버림이다. 반올림하면 1원씩 어긋난다 */ +export function vatOf(supply: number): number { + return Math.floor((supply || 0) * VAT_RATE); +} + +/** 공급대가(합계) = 공급가액 + VAT */ +export function grossOf(supply: number): number { + const s = supply || 0; + return s + vatOf(s); +} + +/** + * 입금 차액 = 공급대가 − 캐피탈 − 계약금. + * **음수가 정상이다** — 아직 안 들어온 돈이다(엑셀에서도 캐피탈 전에는 −계약금으로 남는다). + */ +export function payDiffOf(supply: number, deposit: number, capital: number): number { + return grossOf(supply) - (capital || 0) - (deposit || 0); +} + +/** + * 공급대가(VAT 포함)에서 **공급가액**을 되짚는다 — 계약서의 특장 결제금액에서 첫 값을 채울 때 쓴다. + * + * 그냥 `/1.1` 하면 1원이 샌다. VAT 가 **버림**이라 `s + floor(s×10%)` 가 공급대가와 딱 맞는 `s` 가 + * 따로 있기 때문이다(예: 20,310,000 → 18,463,636 은 20,309,999, 18,463,637 이 딱 맞는다). + * 그래서 어림값 둘레를 몇 칸 훑어 **딱 맞는 값**을 고르고, 없으면 가장 가까운 값을 준다. + */ +export function supplyFromGross(gross: number): number { + const g = Math.max(0, Math.round(gross || 0)); + if (g === 0) return 0; + const guess = Math.round(g / 1.1); + let best = guess; + let bestGap = Math.abs(grossOf(guess) - g); + for (let s = guess - 2; s <= guess + 2; s++) { + if (s < 0) continue; + const gap = Math.abs(grossOf(s) - g); + if (gap < bestGap) { best = s; bestGap = gap; } + if (gap === 0) return s; + } + return best; +} + +/** 원가 네 가지 — 외주·사급·내부·기타 */ +export interface CostParts { + cost_outsourcing: number; + cost_supply: number; + cost_internal: number; + cost_etc: number; +} + +export function costTotalOf(c: Partial): number { + return (c.cost_outsourcing || 0) + (c.cost_supply || 0) + (c.cost_internal || 0) + (c.cost_etc || 0); +} + +/** + * 수익 = 공급가액 − 총원가. + * **VAT 를 뺀 공급가액 기준**이다 — VAT 는 우리 돈이 아니라 받아서 내는 돈이다. + */ +export function profitOf(supply: number, costTotal: number): number { + return (supply || 0) - (costTotal || 0); +} + +/** 수익률 — 공급가액이 0이면 비율이 없다(0% 로 쓰면 「손해」로 읽힌다) */ +export function marginOf(supply: number, profit: number): number | null { + return supply > 0 ? profit / supply : null; +} + +/** 한 줄에서 읽어 낼 수 있는 값 전부 — 화면과 합계가 같은 것을 본다 */ +export interface PnlDerived { + vat: number; + gross: number; + pay_diff: number; + cost_total: number; + profit: number; + margin: number | null; +} + +export function deriveP(row: { supply_amount: number; deposit: number; capital: number } & Partial): PnlDerived { + const cost_total = costTotalOf(row); + const profit = profitOf(row.supply_amount, cost_total); + return { + vat: vatOf(row.supply_amount), + gross: grossOf(row.supply_amount), + pay_diff: payDiffOf(row.supply_amount, row.deposit, row.capital), + cost_total, + profit, + margin: marginOf(row.supply_amount, profit), + }; +} + +/** 여러 줄의 합 — 엑셀 20행(합계)과 같다. 합계도 줄과 **같은 함수**로 낸다 */ +export function sumP(rows: ({ supply_amount: number; deposit: number; capital: number } & Partial)[]) { + const z = { + count: rows.length, + supply_amount: 0, vat: 0, gross: 0, deposit: 0, capital: 0, pay_diff: 0, + cost_total: 0, profit: 0, + }; + for (const r of rows) { + const d = deriveP(r); + z.supply_amount += r.supply_amount || 0; + z.vat += d.vat; + z.gross += d.gross; + z.deposit += r.deposit || 0; + z.capital += r.capital || 0; + z.pay_diff += d.pay_diff; + z.cost_total += d.cost_total; + z.profit += d.profit; + } + return { ...z, margin: marginOf(z.supply_amount, z.profit) }; +} + +/** `YYYY-MM` 인가 — 화면·주소에서 온 값을 믿지 않는다 */ +export function isYearMonth(v: unknown): v is string { + return typeof v === 'string' && /^\d{4}-(0[1-9]|1[0-2])$/.test(v); +} + +/** 그 달의 첫날·다음 달 첫날(둘 다 `YYYY-MM-DD`) — 조회 범위 */ +export function monthBounds(ym: string): { from: string; to: string } { + const [y, m] = ym.split('-').map(Number) as [number, number]; + const next = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`; + return { from: `${ym}-01`, to: `${next}-01` }; +} + +/** 날짜(YYYY-MM-DD)에서 달(YYYY-MM) */ +export function monthOf(day: string): string { + return day.slice(0, 7); +} diff --git a/shared/package.json b/shared/package.json index 0c680f9..a84d523 100644 --- a/shared/package.json +++ b/shared/package.json @@ -18,7 +18,8 @@ "./docs/po-lines": "./docs/po-lines.ts", "./process/addon": "./process/addon.ts", "./process/actual": "./process/actual.ts", - "./rbac/presets": "./rbac/presets.ts" + "./rbac/presets": "./rbac/presets.ts", + "./finance/pnl": "./finance/pnl.ts" }, "scripts": { "test": "vitest run", diff --git a/shared/rbac/presets.ts b/shared/rbac/presets.ts index 0e028d5..49d50e1 100644 --- a/shared/rbac/presets.ts +++ b/shared/rbac/presets.ts @@ -43,6 +43,7 @@ export const TAB_MODULE = { 공휴일: 'basedata.holiday', 계정관리: 'account.manage', 기능모듈: 'account.manage', + 손익: 'pnl.view', } as const; /** 관리자 계정이면 프리셋과 무관하게 갖는 것 — 견적 목록·서류 조회처럼 관리 업무의 바탕 */ @@ -74,8 +75,8 @@ export const PRESETS: PresetDef[] = [ { code: 'exec', label: '경영관리', - desc: '보기만 한다 — 견적·성과·주문 진행·부가작업·파일. 배정·단계·기준데이터는 손대지 않는다', - modules: [...BASE, 'stats.all', 'addon.view'], + desc: '보기만 한다 — 견적·성과·주문 진행·부가작업·파일. 다만 **손익은 직접 적는다**. 배정·단계·기준데이터는 손대지 않는다', + modules: [...BASE, 'stats.all', 'addon.view', 'pnl.view', 'pnl.manage'], }, { code: 'master', @@ -85,7 +86,7 @@ export const PRESETS: PresetDef[] = [ ...BASE, 'customer.view', 'stats.all', 'quote.create', 'quote.delete', 'doc.send.email', 'doc.send.sign', 'order.confirm', 'order.control', 'order.remove', 'addon.manage', 'checklist.manage', 'basedata.weights', 'basedata.dims', 'basedata.optiondb', 'basedata.makerprice', 'basedata.holiday', - 'basedata.manage', 'account.manage', 'notify.assign', 'addon.view', + 'basedata.manage', 'account.manage', 'notify.assign', 'addon.view', 'pnl.view', 'pnl.manage', ], }, ]; From 1c7a12edbec3a6e47fe6f08b995b2c0b01dd4b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 15:11:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(pnl):=20=EC=83=88=20=EA=B1=B4=EC=9D=80?= =?UTF-8?q?=20=E3=80=8C=EC=9E=85=EB=A0=A5=20=ED=95=84=EC=9A=94=E3=80=8D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8B=A4=20=EC=A0=81=EC=96=B4=20=EB=82=B4?= =?UTF-8?q?=EB=A0=A4=EB=B3=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아래 월별 표에서 새로 들어온 줄을 하나씩 찾아 열고 적는 일은, 주문이 쌓이면 할 수 없는 일이 된다(지시). 그래서 **새 건은 위에서 끝낸다.** · 「입력 필요」의 줄을 펴면 **그 건의 모든 칸**이 나온다 — 발행일·사업자명·공급가액· 계약금·캐피탈·입금일 둘·원가 넷·메모·비고 · VAT·공급대가·입금 차액·수익은 **치는 대로** 따라온다 — 다 적고 맞는지 볼 수 있다 · 저장하면 적은 발행일의 달로 화면이 옮겨 가고, 그 표에 줄이 내려가 있다 · 아래 월별 표는 이제 **보는 자리**다(고칠 수는 있다) 적는 중에는 그 건만 남긴다 — 밀린 건이 백 줄이면 목록이 화면을 다 먹어 적다가 어디를 보고 있는지 잃는다. 닫힌 목록은 높이를 묶어 아래 표를 밀어내지 않는다. Co-Authored-By: Claude Opus 5 --- backend/src/services/pnl.ts | 7 +- frontend/src/api/pnl.ts | 1 + frontend/src/components/PnlTab.tsx | 233 +++++++++++++++++++++++++---- frontend/src/i18n/en.ts | 6 +- 4 files changed, 215 insertions(+), 32 deletions(-) diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index 3d9934d..3c83140 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -44,9 +44,10 @@ export interface PnlPending { quote_no: string | null; customer: string | null; contracted_on: string | null; - /** 계약서에서 가져온 공급가액(VAT 별도) — 저장 전 미리보기 */ + /** 계약서에서 가져온 값들 — 「입력 필요」 칸에 미리 채워 준다 */ supply_default: number | null; deposit_default: number; + biz_default: string | null; } const n = (v: bigint | number | null | undefined): number => Number(v ?? 0); @@ -182,10 +183,11 @@ export async function pnlPending(): Promise { const kept = q.pnl; let supply: number | null = kept ? n(kept.supply_amount) : null; let deposit = kept ? n(kept.deposit) : 0; + let biz: string | null = kept?.biz_name ?? null; // 이미 적어 둔 줄이 있으면 그 값을 쓴다 — 다시 세지 않는다(굳힌 값이 정본이다) if (!kept && out.length < DEFAULTS_LIMIT) { const d = await contractDefaults(q.id); - supply = d.supply; deposit = d.deposit; + supply = d.supply; deposit = d.deposit; biz = d.biz_name; } out.push({ quote_id: q.id, @@ -194,6 +196,7 @@ export async function pnlPending(): Promise { contracted_on: day(q.contracts[0]?.completed_at ?? null) ?? day(q.created_at), supply_default: supply, deposit_default: deposit, + biz_default: biz ?? q.customer?.name ?? null, }); } return out; diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index a3f0be9..354a186 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -34,6 +34,7 @@ export interface PnlPending { contracted_on: string | null supply_default: number | null deposit_default: number + biz_default: string | null } export interface PnlView { diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx index fc44acd..5b34bec 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -57,6 +57,18 @@ export function PnlTab() { return saved }, [month, load]) + /** + * 「입력 필요」에서 한 건을 **다 적어** 내려보낸다 — 적은 발행일의 달로 화면을 옮겨 + * 방금 내려간 줄이 눈에 보이게 한다(지시: 저장하면 밑으로 내려보내는 방식). + */ + const file = useCallback(async (quoteId: number, patch: PnlPatch) => { + const saved = await savePnl(quoteId, patch) + const ym = saved.invoice_on ? monthOf(saved.invoice_on) : month + setMonth(ym ?? null) + load(ym ?? undefined) + return saved + }, [month, load]) + const rows = view?.rows ?? [] const total = useMemo(() => sumP(rows), [rows]) @@ -72,7 +84,7 @@ export function PnlTab() { {/* ② 입력 필요 — 맨 위에 따로 둔다. 여기서 발행일을 적으면 그 달 표로 들어간다 */} - + {/* ③ 그 달의 표 */}
@@ -160,25 +172,36 @@ function SummaryBar({ total, isMobile }: { total: ReturnType; isMob /* ── 입력 필요 ─────────────────────────────────────────────────────────── */ -function PendingBox({ pending, canEdit, isMobile, onWrite }: { +function PendingBox({ pending, canEdit, isMobile, onFile }: { pending: PnlPending[]; canEdit: boolean; isMobile: boolean - onWrite: (quoteId: number, patch: PnlPatch) => Promise + onFile: (quoteId: number, patch: PnlPatch) => Promise }) { const [open, setOpen] = useState(true) + /** 한 번에 한 건만 편다 — 스무 건이 동시에 펴지면 그 아래 표가 화면 밖으로 밀린다 */ + const [editing, setEditing] = useState(null) return (
0 ? s.cardAlert : s.card}> {open && (pending.length === 0 ? (
{t('발행일을 적어야 할 건이 없습니다.')}
) : ( -
- {pending.map(p => ( - + /* + * 적는 중에는 **그 건만** 남긴다. 밀린 건이 백 줄이면 목록이 화면을 다 먹어 + * 적다가 어디를 보고 있는지 잃는다. 닫힌 목록은 높이를 묶고 구른다. + */ +
+ {(editing === null ? pending : pending.filter(p => p.quote_id === editing)).map(p => ( + setEditing(v => (v === p.quote_id ? null : p.quote_id))} + onFile={onFile} + /> ))}
))} @@ -186,40 +209,183 @@ function PendingBox({ pending, canEdit, isMobile, onWrite }: { ) } -function PendingRow({ item, canEdit, isMobile, onWrite }: { +/** 새 줄에 적을 것 전부 — 표의 칸과 하나씩 짝이 맞는다 */ +interface Draft { + biz_name: string + invoice_on: string + supply_amount: number + deposit: number + capital: number + deposit_paid_on: string + capital_paid_on: string + cost_outsourcing: number + cost_supply: number + cost_internal: number + cost_etc: number + cost_memo: string + memo: string +} + +/** + * 「입력 필요」 한 줄 — 펴면 **그 건의 모든 칸**이 나온다(2026-09-16 지시). + * + * 아래 월별 표는 **보는 자리**다. 주문이 쌓이면 새로 들어온 줄을 표에서 하나씩 찾아 + * 열고 적는 일이 힘들어진다 — 새 건은 여기서 다 적고 저장해 내려보낸다. + * + * 발행일이 없으면 내려갈 달이 정해지지 않으므로 그때만 저장을 막는다. 나머지는 나중에 채워도 된다. + */ +function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { item: PnlPending; canEdit: boolean; isMobile: boolean - onWrite: (quoteId: number, patch: PnlPatch) => Promise + open: boolean; onToggle: () => void + onFile: (quoteId: number, patch: PnlPatch) => Promise }) { - const [date, setDate] = useState('') + const [d, setD] = useState(() => ({ + biz_name: item.biz_default ?? item.customer ?? '', + invoice_on: '', + supply_amount: item.supply_default ?? 0, + deposit: item.deposit_default, + capital: 0, + deposit_paid_on: '', capital_paid_on: '', + cost_outsourcing: 0, cost_supply: 0, cost_internal: 0, cost_etc: 0, + cost_memo: '', memo: '', + })) const [busy, setBusy] = useState(false) const [err, setErr] = useState('') + const set = (k: K, v: Draft[K]) => setD(x => ({ ...x, [k]: v })) + const calc = deriveP(d) + const save = async () => { - if (!date || busy) return + if (!d.invoice_on || busy) return setBusy(true); setErr('') - try { await onWrite(item.quote_id, { invoice_on: date }) } - catch (e) { setErr(e instanceof Error ? e.message : t('저장하지 못했습니다')) } - finally { setBusy(false) } + try { + await onFile(item.quote_id, { + biz_name: d.biz_name.trim() || null, + invoice_on: d.invoice_on, + supply_amount: d.supply_amount, + deposit: d.deposit, + capital: d.capital, + deposit_paid_on: d.deposit_paid_on || null, + capital_paid_on: d.capital_paid_on || null, + cost_outsourcing: d.cost_outsourcing, + cost_supply: d.cost_supply, + cost_internal: d.cost_internal, + cost_etc: d.cost_etc, + cost_memo: d.cost_memo.trim() || null, + memo: d.memo.trim() || null, + }) + } catch (e) { + setErr(e instanceof Error ? e.message : t('저장하지 못했습니다')) + setBusy(false) + } } + return ( -
- {item.quote_no ?? `#${item.quote_id}`} - {item.customer ?? '—'} - {item.contracted_on ?? '—'} - {item.supply_default === null ? '—' : won(item.supply_default)} - - - {err && {err}} + + {open && ( + <> +
+ + set('invoice_on', v)} disabled={!canEdit || busy} + ariaLabel={t('세금계산서 발행일')} style={s.dateInput} clearable /> + + + set('biz_name', e.target.value)} /> + + + set('supply_amount', v)} /> + + {won(calc.vat)} + + {won(calc.gross)} + + set('deposit', v)} /> + + + set('capital', v)} /> + + + {won(calc.pay_diff)} + + + + set('deposit_paid_on', v)} disabled={!canEdit || busy} + ariaLabel={t('계약금 입금일')} style={s.dateInput} clearable /> + + + set('capital_paid_on', v)} disabled={!canEdit || busy} + ariaLabel={t('캐피탈 입금일')} style={s.dateInput} clearable /> + + + set('cost_outsourcing', v)} /> + + + set('cost_supply', v)} /> + + + + set('cost_internal', v)} /> + + + set('cost_etc', v)} /> + + {won(calc.cost_total)} + + {won(calc.profit)} · {pct(calc.margin)} + + + + set('cost_memo', e.target.value)} /> + + + set('memo', e.target.value)} /> + +
+
+ {err && {err}} + {/* 발행일이 없으면 내려갈 달이 없다 — 그때만 막는다 */} + {!d.invoice_on && {t('세금계산서 발행일을 적어야 아래로 내려갑니다')}} + + +
+ + )}
) } /* ── 적는 칸 ──────────────────────────────────────────────────────────── */ +/** + * 금액 칸(입력 중) — 치는 대로 값이 올라간다. 「입력 필요」의 셈(VAT·공급대가·수익)이 + * 타자에 맞춰 따라와야 다 적고 나서 맞는지 확인할 수 있다. + */ +function MoneyField({ value, disabled, label, onChange }: { + value: number; disabled?: boolean; label: string; onChange: (v: number) => void +}) { + return ( + onChange(Number(e.target.value.replace(/[^0-9]/g, '') || 0))} + /> + ) +} + /** 금액 칸 — 세 자리마다 쉼표로 보여 주고 숫자로 돌려준다. 칸을 벗어날 때 저장한다 */ function MoneyCell({ value, disabled, label, onSave }: { value: number; disabled?: boolean; label: string; onSave: (v: number) => void @@ -496,9 +662,16 @@ const s: Record = { sumValWarn: { fontSize: 18, fontWeight: 700, color: 'var(--req)', ...cellNum, textAlign: 'left' }, sumValGood: { fontSize: 18, fontWeight: 700, color: 'var(--dark)', ...cellNum, textAlign: 'left' }, - pendList: { display: 'flex', flexDirection: 'column', maxHeight: 260, overflowY: 'auto' }, - pendRow: { display: 'grid', gridTemplateColumns: '92px 1fr 90px 130px 150px 64px', alignItems: 'center', gap: 8, padding: '6px 0', borderTop: 'var(--hairline)', fontSize: 'var(--fs-label)' }, - pendRowMobile: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6, padding: '8px 0', borderTop: 'var(--hairline)', fontSize: 'var(--fs-label)' }, + // 밀린 건이 백 줄이어도 아래 표가 화면 밖으로 밀리지 않게 높이를 묶는다 + pendList: { display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 320, overflowY: 'auto' }, + // 적는 중에는 그 건만 — 묶을 높이가 없다(폼이 잘리면 안 된다) + 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' }, + 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' }, + pendCta: { fontSize: 'var(--fs-caption)', color: 'var(--dark)', border: 'var(--hairline)', borderRadius: 999, padding: '1px 8px', whiteSpace: 'nowrap' }, + entryGrid: { display: 'grid', gap: 8, padding: '6px 0 8px' }, + entryFoot: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap', paddingTop: 6, borderTop: 'var(--hairline)' }, pendNo: { fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, pendName: { color: 'var(--body)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, pendSub: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', whiteSpace: 'nowrap' }, @@ -542,6 +715,8 @@ const s: Record = { fieldWide: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, gridColumn: '1 / -1' }, fieldLabel: { fontSize: 'var(--fs-caption)', color: 'var(--muted)' }, calc: { fontSize: 'var(--fs-label)', color: 'var(--body)', ...cellNum, textAlign: 'left', padding: '5px 0' }, + calcStrong: { fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, ...cellNum, textAlign: 'left', padding: '5px 0' }, + calcWarn: { fontSize: 'var(--fs-label)', color: 'var(--req)', fontWeight: 700, ...cellNum, textAlign: 'left', padding: '5px 0' }, overlay: { position: 'fixed', inset: 0, background: 'var(--scrim)', zIndex: 1100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--sp-4)' }, modal: { background: '#fff', borderRadius: 12, width: 'min(520px, 96vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 10px 40px rgba(22,24,15,.22)' }, diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index fe46ed6..50f1686 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -16,11 +16,14 @@ export const EN: Record = { '명': '', '건': '', '예정': 'Planned', + '입력': 'Enter', + '접기': 'Collapse', + '여기서 다 적고 저장하면 아래 그 달 표로 내려갑니다': 'Fill it in here and save — the row drops into that month below', + '세금계산서 발행일을 적어야 아래로 내려갑니다': 'An invoice date is needed before the row can drop below', '손익': 'P&L', '손익을 불러오지 못했습니다': 'Could not load the P&L', '입력 필요': 'Needs input', '세금계산서 발행일': 'Invoice date', - '세금계산서 발행일을 적으면 그 달 표로 들어갑니다': 'Entering the invoice date files the row into that month', '발행일을 적어야 할 건이 없습니다.': 'Nothing is waiting for an invoice date.', '이 달에 세금계산서가 발행된 건이 없습니다.': 'No invoices were issued this month.', '기록이 있는 달': 'Months with records', @@ -1265,6 +1268,7 @@ export const EN: Record = { * 쓰는 법: tf('{0} 대화', name) */ export const EN_FMT: Record = { + '계약 {0}': 'Signed {0}', '원가 · {0}': 'Cost · {0}', '{0}일 경과': '{0}d over', '{0}일 남음': '{0}d left', From c55e53c2cc1c79d852324036e339fbbb209d7da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 15:18:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?style(pnl):=20=EC=A0=81=EB=8A=94=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=84=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C=20=E2=80=94?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=C2=B7=EA=B8=80=EC=9E=90=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EC=95=B1=20=ED=86=A0=ED=81=B0=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 손으로 padding 을 줘서 입력칸·날짜칸·버튼이 저마다 1~8px 씩 어긋났다(제보). 칸이 열몇 개인 표에서는 그 어긋남이 줄마다 쌓여 눈에 띈다. · 폼(입력 필요·휴대폰 카드·원가 창) = `--h-control` · `--fs-input` → 36px·14px 한 벌 · 표(PC·태블릿) = `--h-control-sm` · `--fs-label` → 28px·13px 한 벌 · **계산해서 보여만 주는 값도 같은 자리**를 차지한다(테두리만 투명) — 줄이 맞는다 · 값은 전부 오른쪽 끝에 맞춘다 — 적는 칸 옆에서 계산값만 왼쪽이면 어긋나 보인다 · 수익은 「금액 · 비율」이라 한 칸에 안 들어간다 — 줄을 통째로 쓴다 ⚠️ `minHeight` 도 함께 준다. DateField 의 방아쇠가 `minHeight: --h-control` 을 들고 있어 height 만 주면 **그 칸만 8px 더 컸다**(표에서 실제로 그랬다 — 36 vs 28). 실측: 폼 36px/14px 전부 동일 · 표 28px/13px 전부 동일 · 표 줄 높이 69px 균일. Co-Authored-By: Claude Opus 5 --- frontend/src/components/PnlTab.tsx | 100 ++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx index 5b34bec..53f19f2 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -339,7 +339,7 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { set('cost_etc', v)} /> {won(calc.cost_total)} - + {won(calc.profit)} · {pct(calc.margin)} @@ -387,14 +387,14 @@ function MoneyField({ value, disabled, label, onChange }: { } /** 금액 칸 — 세 자리마다 쉼표로 보여 주고 숫자로 돌려준다. 칸을 벗어날 때 저장한다 */ -function MoneyCell({ value, disabled, label, onSave }: { - value: number; disabled?: boolean; label: string; onSave: (v: number) => void +function MoneyCell({ value, disabled, label, dense, onSave }: { + value: number; disabled?: boolean; label: string; dense?: boolean; onSave: (v: number) => void }) { const [draft, setDraft] = useState(null) const shown = draft ?? (value ? value.toLocaleString('ko-KR') : '') return ( setDraft(e.target.value.replace(/[^0-9]/g, ''))} onBlur={() => { if (draft === null) return @@ -411,23 +411,24 @@ function MoneyCell({ value, disabled, label, onSave }: { * 화면 아래쪽 줄에서 잘린다(ui-compact 검사가 이걸 지킨다). * 날짜는 고르는 순간이 곧 확정이라 **고르면 바로 저장**한다. */ -function DateCell({ value, disabled, label, onSave }: { - value: string | null; disabled?: boolean; label: string; onSave: (v: string | null) => void +function DateCell({ value, disabled, label, dense, onSave }: { + value: string | null; disabled?: boolean; label: string; dense?: boolean; onSave: (v: string | null) => void }) { return ( { const next = v || null; if (next !== value) onSave(next) }} /> ) } -function TextCell({ value, disabled, label, onSave, wide }: { - value: string | null; disabled?: boolean; label: string; wide?: boolean; onSave: (v: string | null) => void +function TextCell({ value, disabled, label, onSave, wide, dense }: { + value: string | null; disabled?: boolean; label: string; wide?: boolean; dense?: boolean; onSave: (v: string | null) => void }) { return ( { const next = e.target.value.trim() || null @@ -504,26 +505,26 @@ function TableRow({ row, canEdit, onWrite }: { {row.customer ?? '—'} {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} - put({ biz_name: v })} /> - put({ invoice_on: v })} /> - put({ supply_amount: v })} /> + put({ biz_name: v })} /> + put({ invoice_on: v })} /> + put({ supply_amount: v })} /> {won(d.vat)} {won(d.gross)} - put({ deposit: v })} /> - put({ capital: v })} /> + put({ deposit: v })} /> + put({ capital: v })} /> {won(d.pay_diff)}
{t('계약금')} - put({ deposit_paid_on: v })} />
+ put({ deposit_paid_on: v })} />
{t('캐피탈')} - put({ capital_paid_on: v })} />
+ put({ capital_paid_on: v })} /> - + {costOpen && setCostOpen(false)} onSave={put} />} {won(d.profit)} - put({ memo: v })} /> + put({ memo: v })} /> ) } @@ -635,6 +636,33 @@ const cardBase: React.CSSProperties = { } const cellNum: React.CSSProperties = { fontVariantNumeric: 'tabular-nums', textAlign: 'right', whiteSpace: 'nowrap' } +/** + * **적는 칸은 한 벌이다.** 높이도 글자 크기도 앱 기준 토큰(`--h-control`·`--fs-input`)을 쓴다. + * + * 손으로 padding 을 주면 입력칸·날짜칸·버튼이 저마다 1~4px 씩 어긋나고, 칸이 열몇 개인 표에서는 + * 그 어긋남이 줄마다 쌓여 눈에 띈다(제보). 계산해서 보여만 주는 값도 **같은 높이**를 차지하게 + * 두어야 줄이 맞는다 — 테두리만 투명하게 한다. + */ +const CONTROL: React.CSSProperties = { + boxSizing: 'border-box', width: '100%', minWidth: 0, + // ⚠️ minHeight 도 같이 준다 — DateField 의 방아쇠가 `minHeight: --h-control` 을 들고 있어 + // height 만 주면 그 칸만 8px 더 커진다(실제로 표에서 그랬다) + height: 'var(--h-control)', minHeight: 'var(--h-control)', + fontFamily: 'inherit', fontSize: 'var(--fs-input)', + color: 'var(--body)', padding: '0 10px', + border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff', +} +/** 표 안 — 칸이 열셋이라 한 단계 촘촘하게(`--h-control-sm`·`--fs-label`). 휴대폰에서는 표를 안 쓴다 */ +const CELL: React.CSSProperties = { + ...CONTROL, height: 'var(--h-control-sm)', minHeight: 'var(--h-control-sm)', + fontSize: 'var(--fs-label)', padding: '0 7px', +} +/** 계산해서 보여만 주는 값 — 적는 칸과 **같은 자리**를 차지한다(테두리만 없다) */ +const readOnly = (base: React.CSSProperties): React.CSSProperties => ({ + ...base, border: '1px solid transparent', background: 'none', + display: 'flex', alignItems: 'center', justifyContent: 'flex-end', ...cellNum, +}) + const s: Record = { root: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-3)' }, card: cardBase, @@ -680,11 +708,11 @@ const s: Record = { tableWrap: { overflowX: 'auto' }, table: { borderCollapse: 'collapse', width: '100%', minWidth: 1180 }, th: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'left', padding: '6px 6px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, - td: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', verticalAlign: 'middle' }, - tdNum: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', ...cellNum }, - tdCalc: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--muted)', ...cellNum }, - tdCalcWarn: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--req)', fontWeight: 700, ...cellNum }, - tdCalcGood: { padding: '5px 6px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, ...cellNum }, + td: { padding: '4px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', verticalAlign: 'middle' }, + tdNum: { padding: '4px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', ...cellNum }, + tdCalc: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--muted)', ...cellNum }, + tdCalcWarn: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--req)', fontWeight: 700, ...cellNum }, + tdCalcGood: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, ...cellNum }, tfLabel: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)' }, tfNum: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)', ...cellNum }, tfNumWarn: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--req)', fontSize: 'var(--fs-label)', ...cellNum }, @@ -694,13 +722,24 @@ const s: Record = { mark: { color: 'var(--lime)' }, markFail: { color: 'var(--req)' }, - moneyInput: { width: '100%', minWidth: 88, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, - dateInput: { boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '4px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, - textInput: { width: '100%', minWidth: 90, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, - textInputWide: { width: '100%', minWidth: 140, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 6px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, + // ── 폼(입력 필요 · 휴대폰 카드 · 원가 창) ── + moneyInput: { ...CONTROL, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, + textInput: CONTROL, + textInputWide: CONTROL, + dateInput: { ...CONTROL, minWidth: 0, justifyContent: 'space-between' }, + costBtn: { ...CONTROL, cursor: 'pointer', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + calc: { ...readOnly(CONTROL), color: 'var(--body)' }, + calcStrong: { ...readOnly(CONTROL), color: 'var(--dark)', fontWeight: 700 }, + calcWarn: { ...readOnly(CONTROL), color: 'var(--req)', fontWeight: 700 }, + + // ── 표(PC·태블릿) — 칸이 열셋이라 한 단계 촘촘하게 ── + moneyCell: { ...CELL, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, + textCell: CELL, + textCellWide: { ...CELL, minWidth: 140 }, + dateCell: { ...CELL, minWidth: 0, justifyContent: 'space-between' }, + costCell: { ...CELL, cursor: 'pointer', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, payRow: { display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }, payTag: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', width: 38, whiteSpace: 'nowrap' }, - costBtn: { border: 'var(--hairline)', background: '#fff', borderRadius: 'var(--r-sm)', padding: '5px 8px', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, 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 }, @@ -714,9 +753,6 @@ const s: Record = { field: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }, fieldWide: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, gridColumn: '1 / -1' }, fieldLabel: { fontSize: 'var(--fs-caption)', color: 'var(--muted)' }, - calc: { fontSize: 'var(--fs-label)', color: 'var(--body)', ...cellNum, textAlign: 'left', padding: '5px 0' }, - calcStrong: { fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, ...cellNum, textAlign: 'left', padding: '5px 0' }, - calcWarn: { fontSize: 'var(--fs-label)', color: 'var(--req)', fontWeight: 700, ...cellNum, textAlign: 'left', padding: '5px 0' }, overlay: { position: 'fixed', inset: 0, background: 'var(--scrim)', zIndex: 1100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--sp-4)' }, modal: { background: '#fff', borderRadius: 12, width: 'min(520px, 96vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 10px 40px rgba(22,24,15,.22)' }, From 929aee2a22e1543f26610894baab28c4df74c304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 15:28:46 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(pnl):=20=EC=9B=90=EA=B0=80=EB=8A=94=20?= =?UTF-8?q?=ED=95=9C=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20=C2=B7=20PC=20?= =?UTF-8?q?=ED=91=9C=EB=A5=BC=20=ED=95=9C=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 원가 한 칸(지시) 외주비·사급비·내부비용·기타로 나눠 두고 팝업에서 적게 했는데, 별도 시스템이 **원가를 하나로** 내려 줄 것이라 나눌 이유가 없어졌다. 칸 하나로 줄이고 표에서 바로 적는다 — 팝업도 없앴다. 아직 병합 전인 마이그레이션이라 컬럼을 지우지 않고 **처음부터 한 칸으로** 고쳤다 (운영에는 이 표가 아직 없다). 어디서 온 값인지(`cost_source`)는 남긴다 — 나중에 자동으로 채울 때 손으로 적은 값을 덮지 않기 위해서다. ## 한 화면에 들어가게(지시) PC 에서 가로 스크롤은 잘 안 되고, 밀지 않으면 글자가 잘린 채로 읽힌다. 칸을 열셋에서 **아홉**으로 줄였다 — 값을 지운 것이 아니라 **나오는 값을 나온 자리 아래**로 붙였다. · 고객명 아래 사업자명 (이름이 길어도 안 잘리게 폭을 준다) · 공급가액 아래 VAT · 공급대가 · 계약금·캐피탈 아래 각각의 입금일 (따로 있던 「입금일」 칸이 없어졌다) `table-layout: fixed` + colgroup 으로 폭을 못 박는다. 숫자 칸은 잘리지 않을 만큼 주고 남는 폭은 비고가 먹는다. 견적번호는 줄지 않고 **고객명이 줄어든다**(번호가 잘리면 못 찾는다). 실측 — 1440px: 1359/1359 · 1024px: 943/943, **가로 스크롤 없음**. 줄 높이 68px 균일. 남는 잘림은 서른 자짜리 상호와 긴 비고뿐인데, 둘 다 적는 칸이라 안에서 밀려 다 읽힌다. ## 곁들여 · DB 없이 도는 보안 검사 둘(로컬 자동 로그인 차단 · 마이페이지 접근 규칙)을 **CI 목록에 넣었다** · 손익 시험이 켜는 모듈을 둘로 줄였다 — 스물아홉 줄 × 세 계정이 다른 시험과 DB 를 놓고 다퉜다 Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy-ssm.yml | 2 + .../20260916050000_order_pnl/migration.sql | 11 +- backend/prisma/schema.prisma | 10 +- backend/src/__tests__/pnl.test.ts | 8 +- backend/src/services/pnl.ts | 27 +-- frontend/src/api/pnl.ts | 10 +- frontend/src/components/PnlTab.tsx | 197 ++++++++---------- frontend/src/i18n/en.ts | 7 - shared/finance/pnl.test.ts | 6 +- shared/finance/pnl.ts | 39 ++-- 10 files changed, 123 insertions(+), 194 deletions(-) diff --git a/.github/workflows/deploy-ssm.yml b/.github/workflows/deploy-ssm.yml index 75ec71a..9f357ac 100644 --- a/.github/workflows/deploy-ssm.yml +++ b/.github/workflows/deploy-ssm.yml @@ -83,6 +83,8 @@ jobs: npm run --workspace=backend test -- \ src/__tests__/permissions.test.ts \ src/__tests__/permission-modules.test.ts \ + src/__tests__/dev-auto-login.test.ts \ + src/__tests__/my-page-access.test.ts \ src/__tests__/body-only-required.test.ts \ src/__tests__/quote-template.test.ts \ src/__tests__/doc-archive.test.ts \ diff --git a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql index e7a3c4a..0874f36 100644 --- a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql +++ b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql @@ -21,14 +21,11 @@ CREATE TABLE IF NOT EXISTS "order_pnl" ( -- 입금일 — 계약금과 캐피탈을 따로 적는다 "deposit_paid_on" DATE, "capital_paid_on" DATE, - -- 원가 — **별도 시스템에서 불러올 자리다**(구성 중). 그때까지는 손으로 적는다. - -- 어디서 온 값인지 남겨 두어야, 나중에 자동으로 채울 때 손으로 적은 값을 함부로 덮지 않는다 - "cost_outsourcing" BIGINT NOT NULL DEFAULT 0, - "cost_supply" BIGINT NOT NULL DEFAULT 0, - "cost_internal" BIGINT NOT NULL DEFAULT 0, - "cost_etc" BIGINT NOT NULL DEFAULT 0, + -- 원가 — **한 칸이다.** 별도 시스템이 원가를 하나로 내려 줄 자리고(구성 중), + -- 그때까지는 손으로 적는다. 어디서 온 값인지 남겨 두어야 나중에 자동으로 채울 때 + -- 손으로 적은 값을 함부로 덮지 않는다 + "cost" BIGINT NOT NULL DEFAULT 0, "cost_source" VARCHAR(20) NOT NULL DEFAULT 'manual', - "cost_memo" VARCHAR(500), -- 비고 — 엑셀의 「비고 (사업자등록증 확인 필요)」 칸 "memo" VARCHAR(500), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9a2bb9b..fece329 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -487,14 +487,10 @@ model OrderPnl { deposit_paid_on DateTime? @db.Date capital_paid_on DateTime? @db.Date - /// 원가 — **별도 시스템에서 불러올 자리**(구성 중). 그때까지는 손으로 적는다 - cost_outsourcing BigInt @default(0) - cost_supply BigInt @default(0) - cost_internal BigInt @default(0) - cost_etc BigInt @default(0) + /// 원가 — **한 칸이다.** 별도 시스템이 원가를 하나로 내려 줄 자리(구성 중). 그때까지는 손으로 적는다 + cost BigInt @default(0) /// 'manual' = 손으로 적었다 / 'system' = 원가 시스템이 채웠다. 자동으로 채울 때 손으로 적은 값을 덮지 않기 위해 남긴다 - cost_source String @default("manual") @db.VarChar(20) - cost_memo String? @db.VarChar(500) + cost_source String @default("manual") @db.VarChar(20) /// 비고 — 엑셀의 「비고 (사업자등록증 확인 필요)」 칸 memo String? @db.VarChar(500) diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts index 7c6322f..110a246 100644 --- a/backend/src/__tests__/pnl.test.ts +++ b/backend/src/__tests__/pnl.test.ts @@ -41,17 +41,15 @@ async function contractedQuote(price = 1_000_000): Promise { beforeAll(async () => { if (!prisma) return; - const mods = await prisma.featureModule.findMany({ select: { code: true } }); + // 이 시험이 두드리는 곳은 /pnl 뿐이라 **그 둘만** 켠다(다른 모듈까지 쓰면 DB 를 오래 붙잡는다) + const mods = [{ code: 'pnl.view' }, { code: 'pnl.manage' }]; for (const email of USERS) { await prisma.user.upsert({ where: { email }, update: { active: true, status: 'active' }, create: { email, name: '손익시험', role: 'ADMIN', extra_roles: [], org_code: 'ORG_HQ', active: true, status: 'active', password_hash: 'x' }, }); for (const m of mods) { - const enabled = - m.code === 'pnl.manage' ? email === KEEPER - : m.code === 'pnl.view' ? email !== OUTSIDE - : true; + const enabled = m.code === 'pnl.manage' ? email === KEEPER : email !== OUTSIDE; await prisma.accessControl.upsert({ where: { subject_type_subject_ref_module_code: { subject_type: 'user', subject_ref: email, module_code: m.code } }, update: { enabled }, create: { subject_type: 'user', subject_ref: email, module_code: m.code, enabled }, diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index 3c83140..d805f06 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -28,12 +28,8 @@ export interface PnlRow { capital: number; deposit_paid_on: string | null; capital_paid_on: string | null; - cost_outsourcing: number; - cost_supply: number; - cost_internal: number; - cost_etc: number; + cost: number; cost_source: string; - cost_memo: string | null; memo: string | null; updated_by: string | null; } @@ -58,8 +54,7 @@ type RowWithQuote = { quote_id: number; invoice_on: Date | null; biz_name: string | null; supply_amount: bigint; deposit: bigint; capital: bigint; deposit_paid_on: Date | null; capital_paid_on: Date | null; - cost_outsourcing: bigint; cost_supply: bigint; cost_internal: bigint; cost_etc: bigint; - cost_source: string; cost_memo: string | null; memo: string | null; updated_by: string | null; + cost: bigint; cost_source: string; memo: string | null; updated_by: string | null; quote: { quote_no: string | null; sales_user_id: string | null; customer: { name: string } | null; @@ -81,12 +76,8 @@ function toRow(r: RowWithQuote): PnlRow { capital: n(r.capital), deposit_paid_on: day(r.deposit_paid_on), capital_paid_on: day(r.capital_paid_on), - cost_outsourcing: n(r.cost_outsourcing), - cost_supply: n(r.cost_supply), - cost_internal: n(r.cost_internal), - cost_etc: n(r.cost_etc), + cost: n(r.cost), cost_source: r.cost_source, - cost_memo: r.cost_memo, memo: r.memo, updated_by: r.updated_by, }; @@ -211,11 +202,7 @@ export interface PnlPatch { capital?: number; deposit_paid_on?: string | null; capital_paid_on?: string | null; - cost_outsourcing?: number; - cost_supply?: number; - cost_internal?: number; - cost_etc?: number; - cost_memo?: string | null; + cost?: number; memo?: string | null; } @@ -251,11 +238,7 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro put('capital', money(patch.capital)); put('deposit_paid_on', dateOf(patch.deposit_paid_on)); put('capital_paid_on', dateOf(patch.capital_paid_on)); - put('cost_outsourcing', money(patch.cost_outsourcing)); - put('cost_supply', money(patch.cost_supply)); - put('cost_internal', money(patch.cost_internal)); - put('cost_etc', money(patch.cost_etc)); - put('cost_memo', text(patch.cost_memo, 500)); + put('cost', money(patch.cost)); put('memo', text(patch.memo, 500)); if (!exists) { diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index 354a186..8c8d7ff 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -15,13 +15,10 @@ export interface PnlRow { capital: number deposit_paid_on: string | null capital_paid_on: string | null - cost_outsourcing: number - cost_supply: number - cost_internal: number - cost_etc: number + /** 원가 — **한 칸이다.** 별도 시스템이 하나로 내려 줄 자리(구성 중) */ + cost: number /** 'manual' = 손으로 적었다 / 'system' = 원가 시스템이 채웠다 */ cost_source: string - cost_memo: string | null memo: string | null updated_by: string | null } @@ -48,8 +45,7 @@ export interface PnlView { /** 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장) */ 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 53f19f2..7491264 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -4,7 +4,6 @@ import { fetchPnl, savePnl, type PnlRow, type PnlPending, type PnlPatch } from ' import { deriveP, sumP, monthOf } from '@shared/finance/pnl' import { usePermission } from './PermGate' import { useIsMobile } from '../hooks/useIsMobile' -import { useEscapeClose } from '../lib/escClose' import { BTN } from '../styles/buttons' import { DateField } from './ui/DateField' @@ -153,7 +152,7 @@ function SummaryBar({ total, isMobile }: { total: ReturnType; isMob { label: '캐피탈', value: won(total.capital) }, // 아직 안 들어온 돈 — 음수가 정상이다 { label: '입금 차액', value: won(total.pay_diff), tone: total.pay_diff < 0 ? 'warn' : undefined }, - { label: '원가', value: won(total.cost_total) }, + { label: '원가', value: won(total.cost) }, { label: '수익', value: `${won(total.profit)} · ${pct(total.margin)}`, tone: total.profit < 0 ? 'warn' : 'good' }, ] return ( @@ -218,11 +217,7 @@ interface Draft { capital: number deposit_paid_on: string capital_paid_on: string - cost_outsourcing: number - cost_supply: number - cost_internal: number - cost_etc: number - cost_memo: string + cost: number memo: string } @@ -246,8 +241,7 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { deposit: item.deposit_default, capital: 0, deposit_paid_on: '', capital_paid_on: '', - cost_outsourcing: 0, cost_supply: 0, cost_internal: 0, cost_etc: 0, - cost_memo: '', memo: '', + cost: 0, memo: '', })) const [busy, setBusy] = useState(false) const [err, setErr] = useState('') @@ -266,11 +260,7 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { capital: d.capital, deposit_paid_on: d.deposit_paid_on || null, capital_paid_on: d.capital_paid_on || null, - cost_outsourcing: d.cost_outsourcing, - cost_supply: d.cost_supply, - cost_internal: d.cost_internal, - cost_etc: d.cost_etc, - cost_memo: d.cost_memo.trim() || null, + cost: d.cost, memo: d.memo.trim() || null, }) } catch (e) { @@ -325,28 +315,14 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { set('capital_paid_on', v)} disabled={!canEdit || busy} ariaLabel={t('캐피탈 입금일')} style={s.dateInput} clearable /> - - set('cost_outsourcing', v)} /> + {/* 원가는 **한 칸**이다 — 별도 시스템이 하나로 내려 줄 자리고, 그때까지는 손으로 적는다 */} + + set('cost', v)} /> - - set('cost_supply', v)} /> - - - - set('cost_internal', v)} /> - - - set('cost_etc', v)} /> - - {won(calc.cost_total)} - + {won(calc.profit)} · {pct(calc.margin)} - - set('cost_memo', e.target.value)} /> - set('memo', e.target.value)} /> @@ -453,19 +429,48 @@ function useRowWriter(row: PnlRow, onWrite: (quoteId: number, patch: PnlPatch) = /* ── PC·태블릿 표 ─────────────────────────────────────────────────────── */ +/** + * PC·태블릿 표 — **한 화면에 다 들어가게** 짠다(2026-09-16 지시). + * + * 처음엔 칸을 열셋으로 늘어놓고 옆으로 밀게 했다. PC 에서 가로 스크롤은 잘 안 되고, + * 밀지 않으면 글자가 잘린 채로 읽힌다 — 표를 보는 뜻이 사라진다. 그래서 칸을 **열로 줄였다**. + * + * · 고객명과 사업자명을 한 칸에 위아래로 (사업자명은 이름이 길다 — 폭을 줘야 안 잘린다) + * · 공급가액 아래에 VAT 를 작게 (같은 수에서 나오는 값이라 붙여 두는 편이 읽기 쉽다) + * · 계약금·캐피탈 아래에 각각의 입금일 (따로 두던 「입금일」 칸이 없어졌다) + * + * 폭은 `table-layout: fixed` 로 못 박는다 — 숫자 칸은 잘리지 않을 만큼 주고, 남는 폭은 비고가 먹는다. + */ +const COLS: { w: string; head: string; sub?: string }[] = [ + { w: '13%', head: '고객명', sub: '사업자명' }, + { w: '108px', head: '발행일' }, + // 공급가액에서 나오는 값(VAT·공급대가)은 **그 아래**에 붙인다 — 칸을 둘 더 쓰면 화면을 넘는다 + { w: '116px', head: '공급가액', sub: 'VAT · 합계' }, + { w: '110px', head: '계약금', sub: '입금일' }, + { w: '110px', head: '캐피탈', sub: '입금일' }, + { w: '104px', head: '입금 차액' }, + { w: '96px', head: '원가' }, + { w: '104px', head: '수익' }, + { w: 'auto', head: '비고' }, +] + function RowTable({ rows, canEdit, onWrite }: { rows: PnlRow[]; canEdit: boolean onWrite: (quoteId: number, patch: PnlPatch) => Promise }) { const total = sumP(rows) return ( - // 칸이 열셋이라 태블릿에서는 옆으로 민다 — 줄여서 읽을 수 없는 숫자를 만들지 않는다 + // 아주 좁은 창에서만 밀린다 — 평소에는 스크롤이 생기지 않는다
+ {COLS.map(c => )} - {['고객명', '사업자명', '발행일', '공급가액', 'VAT', '공급대가', '계약금', '캐피탈', '입금 차액', '입금일', '원가', '수익', '비고'] - .map(h => )} + {COLS.map(c => ( + + ))} @@ -473,15 +478,16 @@ function RowTable({ rows, canEdit, onWrite }: { - - - - + + - + @@ -497,31 +503,36 @@ function TableRow({ row, canEdit, onWrite }: { }) { const d = deriveP(row) const { put, mark, failed } = useRowWriter(row, onWrite) - const [costOpen, setCostOpen] = useState(false) const ro = !canEdit return ( + + + - - - - - - - - - + @@ -538,7 +549,6 @@ function RowCard({ row, canEdit, onWrite }: { const d = deriveP(row) const { put, mark, failed } = useRowWriter(row, onWrite) const [open, setOpen] = useState(false) - const [costOpen, setCostOpen] = useState(false) const ro = !canEdit return (
@@ -565,12 +575,14 @@ function RowCard({ row, canEdit, onWrite }: { put({ deposit_paid_on: v })} /> put({ capital_paid_on: v })} /> - + put({ cost: v })} /> + + + {won(d.profit)} put({ memo: v })} />
)} - {costOpen && setCostOpen(false)} onSave={put} />} ) } @@ -584,50 +596,6 @@ function Field({ label, wide, children }: { label: string; wide?: boolean; child ) } -/* ── 원가 ─────────────────────────────────────────────────────────────── */ - -/** - * 원가 — **별도 시스템에서 불러올 자리**(아직 만드는 중). 그때까지는 손으로 적는다. - * 넷으로 나눠 두는 이유는, 나중에 자동으로 채울 때 어느 칸에 무엇이 들어갈지 미리 정해 두기 위함이다. - */ -function CostModal({ row, canEdit, onClose, onSave }: { - row: PnlRow; canEdit: boolean; onClose: () => void; onSave: (patch: PnlPatch) => void -}) { - useEscapeClose(onClose) - const d = deriveP(row) - const parts: { key: keyof PnlPatch; label: string; value: number }[] = [ - { key: 'cost_outsourcing', label: '외주비', value: row.cost_outsourcing }, - { key: 'cost_supply', label: '사급비', value: row.cost_supply }, - { key: 'cost_internal', label: '내부비용', value: row.cost_internal }, - { key: 'cost_etc', label: '기타', value: row.cost_etc }, - ] - return ( -
{ if (ev.target === ev.currentTarget) onClose() }}> -
-
- {tf('원가 · {0}', row.customer ?? String(row.quote_id))} - -
-
- {parts.map(p => ( - - onSave({ [p.key]: v } as PnlPatch)} /> - - ))} - - onSave({ cost_memo: v })} /> - -
- {t('총원가')} {won(d.cost_total)} - {t('수익')} {won(d.profit)} · {pct(d.margin)} -
-
{t('원가는 별도 시스템과 이어질 자리입니다. 지금은 직접 적습니다.')}
-
-
-
- ) -} - /* ── 모양 ─────────────────────────────────────────────────────────────── */ const cardBase: React.CSSProperties = { @@ -706,8 +674,14 @@ const s: Record = { pendAmount: { ...cellNum, color: 'var(--body)', fontSize: 'var(--fs-caption)' }, tableWrap: { overflowX: 'auto' }, - table: { borderCollapse: 'collapse', width: '100%', minWidth: 1180 }, - th: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'left', padding: '6px 6px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, + // 폭을 못 박는다 — 숫자 칸은 잘리지 않을 만큼, 남는 폭은 비고가 먹는다 + table: { borderCollapse: 'collapse', width: '100%', tableLayout: 'fixed', minWidth: 900 }, + th: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'left', padding: '6px 5px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, + thSub: { marginLeft: 5, color: 'var(--muted)', opacity: 0.75 }, + subNum: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', ...cellNum, padding: '2px 7px 0' }, + subNumStrong: { fontSize: 'var(--fs-caption)', color: 'var(--dark)', fontWeight: 700, ...cellNum, padding: '0 7px' }, + tfSub: { display: 'block', fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400 }, + tfSubStrong: { display: 'block', fontSize: 'var(--fs-caption)', color: 'var(--dark)', fontWeight: 700 }, td: { padding: '4px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', verticalAlign: 'middle' }, tdNum: { padding: '4px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', ...cellNum }, tdCalc: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--muted)', ...cellNum }, @@ -717,8 +691,9 @@ const s: Record = { tfNum: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)', ...cellNum }, tfNumWarn: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--req)', fontSize: 'var(--fs-label)', ...cellNum }, - name: { display: 'block', color: 'var(--dark)', fontWeight: 700, whiteSpace: 'nowrap' }, - no: { display: 'block', color: 'var(--muted)', fontSize: 'var(--fs-caption)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + nameRow: { display: 'flex', alignItems: 'baseline', gap: 5, minWidth: 0, padding: '0 2px 2px' }, + name: { color: 'var(--dark)', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }, + no: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', flexShrink: 0, fontWeight: 400 }, mark: { color: 'var(--lime)' }, markFail: { color: 'var(--req)' }, @@ -735,7 +710,7 @@ const s: Record = { // ── 표(PC·태블릿) — 칸이 열셋이라 한 단계 촘촘하게 ── moneyCell: { ...CELL, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, textCell: CELL, - textCellWide: { ...CELL, minWidth: 140 }, + textCellWide: CELL, dateCell: { ...CELL, minWidth: 0, justifyContent: 'space-between' }, costCell: { ...CELL, cursor: 'pointer', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, payRow: { display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }, diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 50f1686..acbe73c 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -38,13 +38,7 @@ export const EN: Record = { '계약금 입금일': 'Deposit paid on', '캐피탈 입금일': 'Financing paid on', '원가': 'Cost', - '총원가': 'Total cost', - '원가 메모': 'Cost note', '수익': 'Profit', - '외주비': 'Outsourcing', - '사급비': 'Supplied parts', - '내부비용': 'Internal', - '원가는 별도 시스템과 이어질 자리입니다. 지금은 직접 적습니다.': 'Costs will be fed from a separate system. For now they are entered by hand.', '손익 조회 — 판매건별 매출·원가·수익 (보기 전용)': 'View P&L — revenue, cost and profit per sale (read-only)', '손익 입력 — 세금계산서 발행일·입금·원가를 적는다': 'Enter P&L — invoice date, payments and costs', '배정할 건이 없습니다.': 'Nothing to assign.', @@ -1269,7 +1263,6 @@ export const EN: Record = { */ export const EN_FMT: Record = { '계약 {0}': 'Signed {0}', - '원가 · {0}': 'Cost · {0}', '{0}일 경과': '{0}d over', '{0}일 남음': '{0}d left', '{0} 일정': 'Schedule for {0}', diff --git a/shared/finance/pnl.test.ts b/shared/finance/pnl.test.ts index d91c715..b837bbd 100644 --- a/shared/finance/pnl.test.ts +++ b/shared/finance/pnl.test.ts @@ -30,11 +30,7 @@ describe('손익 셈 — 엑셀 재현', () => { }); it('🔴 수익은 **VAT 를 뺀 공급가액** 기준이다 — VAT 는 받아서 내는 돈이다', () => { - const d = deriveP({ - supply_amount: 10_000_000, deposit: 0, capital: 0, - cost_outsourcing: 6_000_000, cost_supply: 1_000_000, cost_internal: 500_000, cost_etc: 0, - }); - expect(d.cost_total).toBe(7_500_000); + const d = deriveP({ supply_amount: 10_000_000, deposit: 0, capital: 0, cost: 7_500_000 }); expect(d.profit).toBe(2_500_000); expect(d.margin).toBeCloseTo(0.25, 10); }); diff --git a/shared/finance/pnl.ts b/shared/finance/pnl.ts index 4c9bfad..3422aaa 100644 --- a/shared/finance/pnl.ts +++ b/shared/finance/pnl.ts @@ -57,24 +57,12 @@ export function supplyFromGross(gross: number): number { return best; } -/** 원가 네 가지 — 외주·사급·내부·기타 */ -export interface CostParts { - cost_outsourcing: number; - cost_supply: number; - cost_internal: number; - cost_etc: number; -} - -export function costTotalOf(c: Partial): number { - return (c.cost_outsourcing || 0) + (c.cost_supply || 0) + (c.cost_internal || 0) + (c.cost_etc || 0); -} - /** - * 수익 = 공급가액 − 총원가. + * 수익 = 공급가액 − 원가. * **VAT 를 뺀 공급가액 기준**이다 — VAT 는 우리 돈이 아니라 받아서 내는 돈이다. */ -export function profitOf(supply: number, costTotal: number): number { - return (supply || 0) - (costTotal || 0); +export function profitOf(supply: number, cost: number): number { + return (supply || 0) - (cost || 0); } /** 수익률 — 공급가액이 0이면 비율이 없다(0% 로 쓰면 「손해」로 읽힌다) */ @@ -87,30 +75,35 @@ export interface PnlDerived { vat: number; gross: number; pay_diff: number; - cost_total: number; profit: number; margin: number | null; } -export function deriveP(row: { supply_amount: number; deposit: number; capital: number } & Partial): PnlDerived { - const cost_total = costTotalOf(row); - const profit = profitOf(row.supply_amount, cost_total); +/** 한 줄이 읽어야 할 값 — 원가는 **한 칸**이고 나머지는 여기서 낸다 */ +export interface PnlBase { + supply_amount: number; + deposit: number; + capital: number; + cost?: number; +} + +export function deriveP(row: PnlBase): PnlDerived { + const profit = profitOf(row.supply_amount, row.cost ?? 0); return { vat: vatOf(row.supply_amount), gross: grossOf(row.supply_amount), pay_diff: payDiffOf(row.supply_amount, row.deposit, row.capital), - cost_total, profit, margin: marginOf(row.supply_amount, profit), }; } /** 여러 줄의 합 — 엑셀 20행(합계)과 같다. 합계도 줄과 **같은 함수**로 낸다 */ -export function sumP(rows: ({ supply_amount: number; deposit: number; capital: number } & Partial)[]) { +export function sumP(rows: PnlBase[]) { const z = { count: rows.length, supply_amount: 0, vat: 0, gross: 0, deposit: 0, capital: 0, pay_diff: 0, - cost_total: 0, profit: 0, + cost: 0, profit: 0, }; for (const r of rows) { const d = deriveP(r); @@ -120,7 +113,7 @@ export function sumP(rows: ({ supply_amount: number; deposit: number; capital: n z.deposit += r.deposit || 0; z.capital += r.capital || 0; z.pay_diff += d.pay_diff; - z.cost_total += d.cost_total; + z.cost += r.cost || 0; z.profit += d.profit; } return { ...z, margin: marginOf(z.supply_amount, z.profit) }; From cdd092a91eacda52dfc68ec6ec2e75144c89bf7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 16:21:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(pnl):=20=EC=A0=80=EC=9E=A5=20=EB=92=A4?= =?UTF-8?q?=20=EC=95=88=20=EC=97=B4=EB=A6=AC=EB=8D=98=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=EC=95=BD=20=EA=B8=88=EC=95=A1=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=C2=B7=20=ED=91=9C=20=EC=A0=95=EB=A0=AC=20=C2=B7=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C(=EC=82=AC=EC=9C=A0=20=ED=95=84=EC=88=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① 한 번 저장하면 「입력 필요」가 다시 안 열렸다(제보) 저장하면 그 건은 목록에서 빠지는데 「지금 펴 둔 줄」 표시는 그 번호를 들고 있었다. 그러면 「편 줄만 보여 준다」는 규칙에 걸려 **목록이 통째로 비고**, 누를 줄조차 없어진다. 저장이 끝나면 표시를 비우고, **펴 둔 줄이 목록에 없으면 접은 것으로 본다** — 두 겹으로 막는다. ## ② 계약에서 오는 값은 못 고친다(지시) 고객명 · 공급가액 · VAT · 공급대가 · 계약금은 계약서에서 그대로 가져와 **줄을 만들 때 굳힌다.** 사업자명 칸은 없앴다(고객명으로 통일). 화면에서 빼는 것만으로는 막은 것이 아니라 **서버가 받지 않는다** — 보내도 안 바뀐다(검사로 못 박음). 고쳐야 하는 예외가 생기면 그때 다시 본다. 적는 칸은 이제 여섯이다 — 세금계산서 발행일 · 캐피탈 · 입금일 둘 · 원가 · 비고. ## ③ 표 정렬(지시) 값을 위아래로 쌓았더니 칸마다 높이가 달라지고 숫자가 세로로 몰렸다. **쌓지 않는다** — 칸을 하나씩 옆으로 늘어놓고, 글자로만 보여 주는 숫자도 적는 칸과 **같은 높이**를 차지하게 했다. 비고도 넓혔다. 다 넣으면 폭이 남으므로 좁은 화면에서는 **표만** 옆으로 민다(페이지가 아니라). 못 적는 칸은 흐리게 — 적을 수 있는 것처럼 보이면 눌러 보다가 헛수고한다. ## ④ 「입력 필요」 기준 = 특장사 수락(지시) 계약만 끝난 건은 아직 만들 차가 없어 세금계산서를 끊을 일이 없다. **수락한 뒤부터** 선다 (로컬 실데이터로 106건 → 3건). **오래 기다린 것이 맨 위**로 오게 서버에서 못 박았다. ## ⑤ 삭제 — 지우지 않는다(지시) 사유를 반드시 적게 하고(서버가 강제), 줄은 **회색으로 남긴다.** 그 줄 위에 반투명 레이어를 덮어 사유를 적는다 — 줄 전체를 정확히 덮는다(실측: 같은 top·left·너비·높이). 합계에서는 빠지고, 달 제목 옆에 「삭제 N건」으로 센다. 잘못 지웠으면 되돌린다. Co-Authored-By: Claude Opus 5 --- .../20260916050000_order_pnl/migration.sql | 11 +- backend/prisma/schema.prisma | 27 +- backend/src/__tests__/pnl.test.ts | 96 ++++- backend/src/routes/pnl.ts | 48 ++- backend/src/services/pnl.ts | 104 +++-- frontend/src/api/pnl.ts | 30 +- frontend/src/components/PnlTab.tsx | 398 ++++++++++++------ frontend/src/i18n/en.ts | 6 +- 8 files changed, 531 insertions(+), 189 deletions(-) diff --git a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql index 0874f36..b41e27b 100644 --- a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql +++ b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql @@ -10,11 +10,9 @@ CREATE TABLE IF NOT EXISTS "order_pnl" ( "quote_id" INTEGER NOT NULL UNIQUE, -- 세금계산서 발행일 — **이 날짜가 몇 월 표에 들어갈지를 정한다.** 비어 있으면 「입력 필요」에 남는다 "invoice_on" DATE, - -- 사업자명 — 고객명과 다를 수 있다(개인 이름으로 계약하고 상호로 발행). 처음엔 고객명으로 채운다 - "biz_name" VARCHAR(120), - -- 공급가액(VAT 별도) — 계약서상 특장가격에서 처음 값을 채우고, 그 뒤로는 적힌 값이 정본이다 + -- 공급가액(VAT 별도)·계약금 — **계약서에서 그대로 가져와 굳힌다.** 화면에서 고치지 않는다(2026-09-16 지시). + -- 굳혀 두는 이유: 단가표를 나중에 고쳐도 이미 세금계산서가 나간 줄의 금액은 그대로여야 한다 "supply_amount" BIGINT NOT NULL DEFAULT 0, - -- 계약금 — 시스템에 등록된 특장 계약금으로 채우되 고칠 수 있다 "deposit" BIGINT NOT NULL DEFAULT 0, -- 캐피탈 — 경영관리가 직접 적는다 "capital" BIGINT NOT NULL DEFAULT 0, @@ -28,6 +26,11 @@ CREATE TABLE IF NOT EXISTS "order_pnl" ( "cost_source" VARCHAR(20) NOT NULL DEFAULT 'manual', -- 비고 — 엑셀의 「비고 (사업자등록증 확인 필요)」 칸 "memo" VARCHAR(500), + -- 삭제 — **줄을 지우지 않는다.** 표에 회색으로 남기고 사유를 위에 적는다(2026-09-16 지시). + -- 합계에서는 빠진다. 되돌릴 수 있다(되돌리면 세 칸을 비운다) + "voided_at" TIMESTAMP(3), + "voided_by" VARCHAR(120), + "void_reason" VARCHAR(300), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_by" VARCHAR(120), diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fece329..5d3ff95 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -473,16 +473,12 @@ model OrderPnl { quote_id Int @unique /// 세금계산서 발행일 — **이 날짜가 몇 월 표에 들어갈지를 정한다.** 비어 있으면 「입력 필요」에 남는다 - invoice_on DateTime? @db.Date - /// 사업자명 — 고객명과 다를 수 있다(개인 이름으로 계약하고 상호로 발행) - biz_name String? @db.VarChar(120) - - /// 공급가액(VAT 별도). 처음엔 계약서상 특장가격에서 채우고, 그 뒤로는 적힌 값이 정본이다 - supply_amount BigInt @default(0) - /// 계약금 — 시스템에 등록된 특장 계약금으로 채우되 고칠 수 있다 - deposit BigInt @default(0) + invoice_on DateTime? @db.Date + /// 공급가액(VAT 별도)·계약금 — **계약서에서 가져와 굳힌다.** 화면에서 고치지 않는다(2026-09-16 지시) + supply_amount BigInt @default(0) + deposit BigInt @default(0) /// 캐피탈 — 경영관리가 직접 적는다 - capital BigInt @default(0) + capital BigInt @default(0) deposit_paid_on DateTime? @db.Date capital_paid_on DateTime? @db.Date @@ -493,10 +489,15 @@ model OrderPnl { cost_source String @default("manual") @db.VarChar(20) /// 비고 — 엑셀의 「비고 (사업자등록증 확인 필요)」 칸 - memo String? @db.VarChar(500) - created_at DateTime @default(now()) - updated_at DateTime @default(now()) @updatedAt - updated_by String? @db.VarChar(120) + memo String? @db.VarChar(500) + + /// 삭제 — **줄을 지우지 않는다.** 표에 회색으로 남기고 사유를 위에 적는다(2026-09-16 지시). 합계에서는 빠진다 + voided_at DateTime? + voided_by String? @db.VarChar(120) + void_reason String? @db.VarChar(300) + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.VarChar(120) quote Quote @relation(fields: [quote_id], references: [id]) diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts index 110a246..49a3f09 100644 --- a/backend/src/__tests__/pnl.test.ts +++ b/backend/src/__tests__/pnl.test.ts @@ -4,8 +4,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; * **차량 판매건별 손익** — 권한과 「달 가르기」. * * ① 돈에 관한 표라 **보는 것부터** 권한을 건다(`pnl.view`), 적는 것은 `pnl.manage` - * ② 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다 - * ③ 처음 만들 때 계약서 금액을 **굳힌다** — 나중에 단가를 고쳐도 이미 나간 줄은 그대로다 + * ② 「입력 필요」에 서는 시점은 **특장사 수락**이다 — 계약만 끝난 건은 아직 만들 차가 없다 + * ③ 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다 + * ④ 계약서 금액(공급가액·계약금)은 **굳는다** — 화면에서도 서버에서도 못 고친다 + * ⑤ 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다 */ process.env['ALLOW_TEST_PERMISSION_BYPASS'] = 'false'; @@ -27,6 +29,7 @@ const outside = authCookie(OUTSIDE, 'ADMIN', 'ORG_HQ'); let customerId = 0; const quotes: number[] = []; +/** 계약만 끝난 건 — 특장사 수락 전이라 「입력 필요」에 서지 않는다 */ async function contractedQuote(price = 1_000_000): Promise { const q = await prisma!.quote.create({ data: { @@ -39,6 +42,16 @@ async function contractedQuote(price = 1_000_000): Promise { return q.id; } +/** 특장사가 **수락한** 건 — 여기서부터 「입력 필요」다 */ +async function acceptedQuote(acceptedAt = new Date()): 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 }, + }); + return id; +} + beforeAll(async () => { if (!prisma) return; // 이 시험이 두드리는 곳은 /pnl 뿐이라 **그 둘만** 켠다(다른 모듈까지 쓰면 DB 를 오래 붙잡는다) @@ -62,6 +75,7 @@ beforeAll(async () => { 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.quote.deleteMany({ where: { id: { in: quotes } } }); await prisma.customer.deleteMany({ where: { id: customerId } }); await prisma.accessControl.deleteMany({ where: { subject_ref: { in: USERS } } }); @@ -76,27 +90,45 @@ describe.runIf(live)('손익 — 권한', () => { it('🔴 보기 권한만 있으면 읽되 적지는 못한다', async () => { expect((await request(app).get('/api/v1/pnl').set('Cookie', looker)).status).toBe(200); - const id = await contractedQuote(); + const id = await acceptedQuote(); 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); + }, 30_000); + + it('🔴 오래 기다린 것이 맨 위다 — 밀린 건이 아래로 묻히면 안 된다', async () => { + const old = await acceptedQuote(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 contractedQuote(); + const id = await acceptedQuote(); const before = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper); expect(before.status).toBe(200); expect(before.body.data.pending.some((p: { quote_id: number }) => p.quote_id === id), '새 계약이 입력 필요에 없다').toBe(true); - const put = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-10', supply_amount: 18_960_000, deposit: 400_000, capital: 20_450_000 }); + const put = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-10', capital: 20_450_000 }); expect(put.status, JSON.stringify(put.body)).toBe(200); const sep = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper); const row = sep.body.data.rows.find((r: { quote_id: number }) => r.quote_id === id); expect(row, '9월 표에 없다').toBeTruthy(); - expect(row.supply_amount).toBe(18_960_000); + expect(row.capital).toBe(20_450_000); expect(sep.body.data.pending.some((p: { quote_id: number }) => p.quote_id === id), '입력 필요에 그대로 남았다').toBe(false); // 발행일을 다른 달로 바꾸면 그 달로 옮겨간다 @@ -108,7 +140,7 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다', }, 60_000); it('🔴 발행일을 지우면 「입력 필요」로 돌아온다', async () => { - const id = await contractedQuote(); + const id = await acceptedQuote(); 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); @@ -123,15 +155,59 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다', }, 30_000); it('🔴 적은 값은 그대로 남는다 — 비운 칸은 건드리지 않는다(부분 저장)', async () => { - const id = await contractedQuote(); - await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-12', supply_amount: 5_000_000, memo: '확인필요' }); + const id = await acceptedQuote(); + 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: '확인완료' }); - expect(r.body.data.supply_amount).toBe(5_000_000); + expect(r.body.data.capital).toBe(5_000_000); expect(r.body.data.memo).toBe('확인완료'); expect(r.body.data.invoice_on).toBe('2026-09-12'); }, 30_000); + it('🔴 계약서 금액은 굳는다 — 보내도 안 바뀐다(화면에서 빼는 것만으로는 막은 것이 아니다)', 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); + }, 30_000); + + it('🔴 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다', async () => { + const id = await acceptedQuote(); + await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-11-03' }); + + // 사유 없이는 못 지운다 + const bare = await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', keeper).send({ reason: ' ' }); + expect(bare.status, '사유 없이 지워졌다').toBe(400); + + const dead = await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', keeper).send({ reason: '계약 해지 — 차량 반납' }); + expect(dead.status, JSON.stringify(dead.body)).toBe(200); + expect(dead.body.data.void_reason).toBe('계약 해지 — 차량 반납'); + + // 표에는 그대로 있다(지우지 않는다) + const nov = await request(app).get('/api/v1/pnl?month=2026-11').set('Cookie', keeper); + const row = nov.body.data.rows.find((r: { quote_id: number }) => r.quote_id === id); + expect(row, '삭제한 줄이 표에서 사라졌다').toBeTruthy(); + expect(row.voided_at).toBeTruthy(); + expect(row.voided_by).toBeTruthy(); + + // 되돌리면 사유 기록이 비워진다 + const back = await request(app).post(`/api/v1/pnl/${id}/unvoid`).set('Cookie', keeper).send({}); + expect(back.body.data.voided_at).toBeNull(); + expect(back.body.data.void_reason).toBeNull(); + }, 30_000); + + it('🔴 보기 권한만으로는 지우지 못한다', async () => { + const id = await acceptedQuote(); + 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 r = await request(app).put('/api/v1/pnl/99999999').set('Cookie', keeper).send({ invoice_on: '2026-09-13' }); expect(r.status).toBe(404); diff --git a/backend/src/routes/pnl.ts b/backend/src/routes/pnl.ts index d2bcdc3..c9e7b15 100644 --- a/backend/src/routes/pnl.ts +++ b/backend/src/routes/pnl.ts @@ -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, type PnlPatch } from '../services/pnl.js'; +import { pnlOfMonth, pnlPending, pnlMonths, savePnl, voidPnl, unvoidPnl, type PnlPatch } from '../services/pnl.js'; export const pnlRouter = Router(); @@ -56,3 +56,49 @@ pnlRouter.put('/:quoteId', ...manage, async (req: Request, res: Response): Promi fail(res, 'PUT /:quoteId', e); } }); + +/** 줄 번호를 읽는다 — 주소창 값을 믿지 않는다 */ +function quoteIdOf(req: Request, res: Response): number | null { + const id = Number(req.params['quoteId']); + if (Number.isInteger(id)) return id; + res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 견적 id' } }); + return null; +} + +// ── POST /pnl/:quoteId/void — 삭제(줄은 남는다, 사유 필수) ──────────────── +pnlRouter.post('/:quoteId/void', ...manage, async (req: Request, res: Response): Promise => { + const quoteId = quoteIdOf(req, res); + if (quoteId === null) return; + const reason = String((req.body as { reason?: unknown })?.reason ?? '').trim(); + // 사유 없이는 못 지운다 — 몇 달 뒤에 왜 뺐는지 물으면 답할 수 있어야 한다 + if (!reason) { + res.status(400).json({ error: { code: 'REASON_REQUIRED', message: '삭제 사유를 적어 주세요.' } }); + return; + } + try { + const row = await voidPnl(quoteId, reason, req.auth?.email ?? 'unknown'); + if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: '손익 줄을 찾을 수 없습니다' } }); return; } + res.json({ data: row }); + } catch (e) { + if (e && typeof e === 'object' && (e as { code?: string }).code === 'P2025') { + res.status(404).json({ error: { code: 'NOT_FOUND', message: '손익 줄을 찾을 수 없습니다' } }); return; + } + fail(res, 'POST /:quoteId/void', e); + } +}); + +// ── POST /pnl/:quoteId/unvoid — 되돌리기 ────────────────────────────────── +pnlRouter.post('/:quoteId/unvoid', ...manage, async (req: Request, res: Response): Promise => { + const quoteId = quoteIdOf(req, res); + if (quoteId === null) return; + try { + const row = await unvoidPnl(quoteId, req.auth?.email ?? 'unknown'); + if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: '손익 줄을 찾을 수 없습니다' } }); return; } + res.json({ data: row }); + } catch (e) { + if (e && typeof e === 'object' && (e as { code?: string }).code === 'P2025') { + res.status(404).json({ error: { code: 'NOT_FOUND', message: '손익 줄을 찾을 수 없습니다' } }); return; + } + fail(res, 'POST /:quoteId/unvoid', e); + } +}); diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index d805f06..3cc5978 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -19,7 +19,6 @@ export interface PnlRow { quote_id: number; quote_no: string | null; customer: string | null; - biz_name: string | null; sales_user_id: string | null; maker_org: string | null; invoice_on: string | null; @@ -32,6 +31,10 @@ export interface PnlRow { cost_source: string; memo: string | null; updated_by: string | null; + /** 삭제된 줄 — 표에 회색으로 남고 합계에서 빠진다 */ + voided_at: string | null; + voided_by: string | null; + void_reason: string | null; } /** 아직 세금계산서 발행일을 안 적은 건 — 화면 맨 위 「입력 필요」 */ @@ -39,11 +42,11 @@ export interface PnlPending { quote_id: number; quote_no: string | null; customer: string | null; - contracted_on: string | null; - /** 계약서에서 가져온 값들 — 「입력 필요」 칸에 미리 채워 준다 */ + /** 특장사가 **수락한** 날 — 이때부터 「입력 필요」에 선다 */ + accepted_on: string | null; + /** 계약서에서 가져온 값들 — 「입력 필요」 칸에 그대로 보여 준다(고칠 수 없다) */ supply_default: number | null; deposit_default: number; - biz_default: string | null; } const n = (v: bigint | number | null | undefined): number => Number(v ?? 0); @@ -51,10 +54,11 @@ 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; + quote_id: number; invoice_on: Date | null; supply_amount: bigint; deposit: bigint; 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; quote: { quote_no: string | null; sales_user_id: string | null; customer: { name: string } | null; @@ -67,7 +71,6 @@ function toRow(r: RowWithQuote): PnlRow { quote_id: r.quote_id, quote_no: r.quote.quote_no, customer: r.quote.customer?.name ?? null, - biz_name: r.biz_name, sales_user_id: r.quote.sales_user_id, maker_org: r.quote.order?.maker_org?.name ?? null, invoice_on: day(r.invoice_on), @@ -80,6 +83,9 @@ function toRow(r: RowWithQuote): PnlRow { cost_source: r.cost_source, memo: r.memo, updated_by: r.updated_by, + voided_at: r.voided_at ? r.voided_at.toISOString() : null, + voided_by: r.voided_by, + void_reason: r.void_reason, }; } @@ -107,15 +113,15 @@ export async function pnlOfMonth(ym: string): Promise { * `body_payment` 는 VAT 포함이라 1.1 로 나눠 공급가액을 낸다. 계약금은 특장 계약금 그대로다. * 계산에 실패하면(옛 견적·단가 누락) **null 을 돌려준다** — 0 으로 채우면 「0원짜리 판매」가 된다. */ -export async function contractDefaults(quoteId: number): Promise<{ supply: number | null; deposit: number; biz_name: string | null }> { - if (!prisma) return { supply: null, deposit: 0, biz_name: null }; +export async function contractDefaults(quoteId: number): Promise<{ supply: number | null; deposit: number }> { + if (!prisma) return { supply: null, deposit: 0 }; /* * ⚠️ **여기서 던지면 손익 화면 전체가 안 뜬다.** 「입력 필요」는 수십 줄을 한 번에 세는데, * 그 중 한 건이 옛 견적·단가 누락으로 못 세는 일이 실제로 있다. 한 줄을 못 세면 그 줄만 * 비워 두고 손으로 적게 한다 — 나머지 줄까지 같이 잃지 않는다. */ const q = await prisma.quote.findUnique({ where: { id: quoteId }, include: { customer: true } }).catch(() => null); - if (!q) return { supply: null, deposit: 0, biz_name: null }; + if (!q) return { supply: null, deposit: 0 }; const inp = (q.inputs ?? {}) as Record; const customer: CustomerInput = { name: q.customer?.name, @@ -127,8 +133,6 @@ export async function contractDefaults(quoteId: number): Promise<{ supply: numbe has_biz_plate: inp['has_biz_plate'] as boolean | undefined, tax_exempt_type: inp['tax_exempt_type'] as string | undefined, }; - /** 계약서에 적힌 상호 — 없으면 고객명으로 시작한다(경영관리가 고친다) */ - const bizName = (inp['contract_party'] as string | undefined)?.trim() || q.customer?.name || null; try { // 보조금·세율은 **견적을 만든 해** 기준이다 — 올해 표로 다시 세면 작년 계약 금액이 바뀐다 const calcYear = q.created_at.getFullYear(); @@ -138,10 +142,10 @@ export async function contractDefaults(quoteId: number): Promise<{ supply: numbe const total = calcQuote(params); // 특장 결제금액(VAT 포함) → 공급가액(VAT 별도). 그냥 나누면 VAT 버림 때문에 1원이 샌다 const supply = total.body_payment > 0 ? supplyFromGross(total.body_payment) : null; - return { supply, deposit: Math.max(0, Math.round(total.body_deposit || 0)), biz_name: bizName }; + return { supply, deposit: Math.max(0, Math.round(total.body_deposit || 0)) }; } catch { - // 옛 견적·단가 누락으로 다시 셀 수 없는 건이 있다. 그럴 땐 손으로 적게 둔다 - return { supply: null, deposit: 0, biz_name: bizName }; + // 옛 견적·단가 누락으로 다시 셀 수 없는 건이 있다 — 0 으로 두고 줄에는 「—」로 보인다 + return { supply: null, deposit: 0 }; } } @@ -149,24 +153,30 @@ export async function contractDefaults(quoteId: number): Promise<{ supply: numbe const DEFAULTS_LIMIT = 60; /** - * 아직 발행일을 안 적은 건 — **계약 완료 이상**이면서 손익 줄이 없거나 발행일이 빈 것. - * 오래된 것부터 위에 둔다(먼저 처리할 순서). + * 아직 발행일을 안 적은 건 — **특장사가 수락한 뒤부터** 여기 선다(2026-09-16 지시). + * + * 계약만 끝난 건은 아직 만들지도 않은 차라 세금계산서를 끊을 일이 없다. + * 특장사가 수락하면 그때부터 실제로 만들기 시작하므로, 그 시점을 기준으로 삼는다. + * + * **오래된 것이 맨 위**다 — 먼저 처리할 순서이고, 밀린 건이 아래로 묻히면 안 된다. */ export async function pnlPending(): Promise { if (!prisma) return []; const quotes = await prisma.quote.findMany({ where: { ...VISIBLE, - status: { in: ['contracted', 'assigned', 'ordered', 'completed'] }, + // 수락한 주문만 — 취소(배정 취소·주문 삭제)된 건은 수락 기록이 지워지거나 canceled 로 남는다 + order: { is: { accepted_at: { not: null }, canceled_at: null } }, OR: [{ pnl: { is: null } }, { pnl: { invoice_on: null } }], }, select: { id: true, quote_no: true, created_at: true, customer: { select: { name: true } }, - contracts: { orderBy: { created_at: 'desc' }, take: 1, select: { completed_at: true } }, - pnl: { select: { biz_name: true, supply_amount: true, deposit: true } }, + order: { select: { accepted_at: true } }, + pnl: { select: { supply_amount: true, deposit: true } }, }, - orderBy: { created_at: 'asc' }, + // 수락이 오래된 것부터 — 화면에서 다시 정렬하지 않게 여기서 못 박는다 + orderBy: { order: { accepted_at: 'asc' } }, }); const out: PnlPending[] = []; @@ -174,31 +184,33 @@ export async function pnlPending(): Promise { const kept = q.pnl; let supply: number | null = kept ? n(kept.supply_amount) : null; let deposit = kept ? n(kept.deposit) : 0; - let biz: string | null = kept?.biz_name ?? null; // 이미 적어 둔 줄이 있으면 그 값을 쓴다 — 다시 세지 않는다(굳힌 값이 정본이다) if (!kept && out.length < DEFAULTS_LIMIT) { const d = await contractDefaults(q.id); - supply = d.supply; deposit = d.deposit; biz = d.biz_name; + supply = d.supply; deposit = d.deposit; } out.push({ quote_id: q.id, quote_no: q.quote_no, customer: q.customer?.name ?? null, - contracted_on: day(q.contracts[0]?.completed_at ?? null) ?? day(q.created_at), + accepted_on: day(q.order?.accepted_at ?? null) ?? day(q.created_at), supply_default: supply, deposit_default: deposit, - biz_default: biz ?? q.customer?.name ?? null, }); } return out; } /** 화면이 보낸 값 — 안 보낸 칸은 그대로 둔다(부분 저장) */ +/** + * 화면이 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장). + * + * ⚠️ **공급가액·계약금은 여기 없다.** 계약서에서 가져와 줄을 만들 때 굳히고, 그 뒤로는 고치지 않는다 + * (2026-09-16 지시 — 고쳐야 하는 예외가 생기면 그때 다시 본다). 화면에서 빼는 것만으로는 + * 막은 것이 아니라 **서버가 받지 않는다.** + */ export interface PnlPatch { invoice_on?: string | null; - biz_name?: string | null; - supply_amount?: number; - deposit?: number; capital?: number; deposit_paid_on?: string | null; capital_paid_on?: string | null; @@ -232,9 +244,6 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro const data: Record = { updated_by: by }; 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('capital', money(patch.capital)); put('deposit_paid_on', dateOf(patch.deposit_paid_on)); put('capital_paid_on', dateOf(patch.capital_paid_on)); @@ -242,16 +251,43 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro put('memo', text(patch.memo, 500)); if (!exists) { + // 계약서 금액은 **줄을 만들 때 한 번** 굳힌다 — 그 뒤로는 어떤 요청도 이 둘을 못 바꾼다 const d = await contractDefaults(quoteId); - if (data['supply_amount'] === undefined && d.supply !== null) data['supply_amount'] = BigInt(d.supply); - if (data['deposit'] === undefined) data['deposit'] = BigInt(d.deposit); - if (data['biz_name'] === undefined && d.biz_name) data['biz_name'] = d.biz_name; + data['supply_amount'] = BigInt(d.supply ?? 0); + 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 }); } - const saved = await prisma.orderPnl.findUnique({ + return readPnl(quoteId); +} + +/** + * **삭제** — 줄을 지우지 않는다. 사유를 적어 회색으로 남기고 합계에서만 뺀다(2026-09-16 지시). + * 사유 없이는 삭제할 수 없다 — 몇 달 뒤에 왜 뺐는지 물으면 답할 수 있어야 한다. + */ +export async function voidPnl(quoteId: number, reason: string, by: string): Promise { + if (!prisma) return null; + await prisma.orderPnl.update({ + where: { quote_id: quoteId }, + data: { voided_at: new Date(), voided_by: by, void_reason: reason.slice(0, 300), updated_by: by }, + }); + return readPnl(quoteId); +} + +/** 되돌리기 — 잘못 지운 것을 살린다(사유 기록은 지운다) */ +export async function unvoidPnl(quoteId: number, by: string): Promise { + if (!prisma) return null; + await prisma.orderPnl.update({ + where: { quote_id: quoteId }, + data: { voided_at: null, voided_by: null, void_reason: null, updated_by: by }, + }); + return readPnl(quoteId); +} + +async function readPnl(quoteId: number): Promise { + const saved = await prisma!.orderPnl.findUnique({ where: { quote_id: quoteId }, include: { quote: { select: QUOTE_PICK } }, }); diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index 8c8d7ff..86d2fad 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -6,7 +6,6 @@ export interface PnlRow { quote_id: number quote_no: string | null customer: string | null - biz_name: string | null sales_user_id: string | null maker_org: string | null invoice_on: string | null @@ -21,6 +20,10 @@ export interface PnlRow { cost_source: string memo: string | null updated_by: string | null + /** 삭제된 줄 — 표에 회색으로 남고 합계에서 빠진다 */ + voided_at: string | null + voided_by: string | null + void_reason: string | null } /** 아직 세금계산서 발행일을 안 적은 건 — 화면 맨 위 「입력 필요」 */ @@ -28,10 +31,10 @@ export interface PnlPending { quote_id: number quote_no: string | null customer: string | null - contracted_on: string | null + /** 특장사가 **수락한** 날 — 이때부터 「입력 필요」에 선다 */ + accepted_on: string | null supply_default: number | null deposit_default: number - biz_default: string | null } export interface PnlView { @@ -42,10 +45,12 @@ export interface PnlView { months: string[] } -/** 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장) */ +/** + * 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장). + * ⚠️ **공급가액·계약금은 없다** — 계약서에서 가져와 굳힌 값이라 고치지 않는다(서버도 받지 않는다). + */ export type PnlPatch = Partial> async function jsonOf(res: Response): Promise { @@ -61,6 +66,19 @@ export function fetchPnl(month?: string): Promise { return fetch(`/api/v1/pnl${qs}`, { credentials: 'include' }).then(jsonOf) } +/** 삭제 — 줄은 남고 회색이 된다. 사유는 반드시 적는다(서버가 강제) */ +export function voidPnl(quoteId: number, reason: string): Promise { + return fetch(`/api/v1/pnl/${quoteId}/void`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }), + }).then(jsonOf) +} + +/** 되돌리기 */ +export function unvoidPnl(quoteId: number): Promise { + return fetch(`/api/v1/pnl/${quoteId}/unvoid`, { method: 'POST', credentials: 'include' }).then(jsonOf) +} + export function savePnl(quoteId: number, patch: PnlPatch): Promise { return fetch(`/api/v1/pnl/${quoteId}`, { method: 'PUT', diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx index 7491264..92f56db 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { t, tf } from '../i18n' -import { fetchPnl, savePnl, type PnlRow, type PnlPending, type PnlPatch } from '../api/pnl' +import { fetchPnl, savePnl, voidPnl, unvoidPnl, type PnlRow, type PnlPending, type PnlPatch } from '../api/pnl' import { deriveP, sumP, monthOf } from '@shared/finance/pnl' import { usePermission } from './PermGate' import { useIsMobile } from '../hooks/useIsMobile' +import { useEscapeClose } from '../lib/escClose' import { BTN } from '../styles/buttons' import { DateField } from './ui/DateField' @@ -68,8 +69,16 @@ export function PnlTab() { return saved }, [month, load]) + /** 줄 하나가 바뀌면 그 줄만 갈아 끼운다(삭제·되돌리기도 같은 길) */ + const replace = useCallback((saved: PnlRow) => { + setView(v => v && ({ ...v, rows: v.rows.map(r => (r.quote_id === saved.quote_id ? saved : r)) })) + }, []) + const rows = view?.rows ?? [] - const total = useMemo(() => sumP(rows), [rows]) + // **삭제된 줄은 합계에서 뺀다.** 표에는 회색으로 남지만 그 달 숫자는 아니다 + const alive = useMemo(() => rows.filter(r => !r.voided_at), [rows]) + const total = useMemo(() => sumP(alive), [alive]) + const voided = rows.length - alive.length return (
@@ -89,16 +98,17 @@ export function PnlTab() {
{month ? tf('{0}년 {1}월', Number(month.slice(0, 4)), Number(month.slice(5, 7))) : t('손익')} - {tf('{0}건', rows.length)} + {tf('{0}건', alive.length)} + {voided > 0 && {tf('삭제 {0}건', voided)}}
{rows.length === 0 ? (
{t('이 달에 세금계산서가 발행된 건이 없습니다.')}
) : isMobile ? (
- {rows.map(r => )} + {rows.map(r => )}
) : ( - + )}
@@ -178,6 +188,13 @@ function PendingBox({ pending, canEdit, isMobile, onFile }: { const [open, setOpen] = useState(true) /** 한 번에 한 건만 편다 — 스무 건이 동시에 펴지면 그 아래 표가 화면 밖으로 밀린다 */ const [editing, setEditing] = useState(null) + /* + * ⚠️ **편 줄이 목록에서 사라진 뒤에는 접은 것으로 본다**(제보: 한 번 저장하면 다시 안 열렸다). + * 저장하면 그 건은 「입력 필요」에서 빠지는데 `editing` 은 그 번호를 들고 있었다. 그러면 + * 「그 건만 보여 주는」 규칙에 걸려 **목록이 통째로 비고**, 누를 줄조차 없어진다. + * 지우는 것을 잊지 않게 여기서도 한 번 더 본다 — 저장 쪽에서 비우는 것과 이중으로 막는다. + */ + const live = editing !== null && pending.some(p => p.quote_id === editing) ? editing : null return (
0 ? s.cardAlert : s.card}> @@ -287,19 +298,13 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { set('invoice_on', v)} disabled={!canEdit || busy} ariaLabel={t('세금계산서 발행일')} style={s.dateInput} clearable /> - - set('biz_name', e.target.value)} /> - - - set('supply_amount', v)} /> - + {/* 계약서에서 오는 값 — 보여만 준다(고쳐야 하는 예외가 생기면 그때 다시 본다) */} + {item.customer ?? '—'} + {won(fixed.supply_amount)} {won(calc.vat)} {won(calc.gross)} - - set('deposit', v)} /> - + {won(fixed.deposit)} set('capital', v)} /> @@ -370,7 +375,8 @@ function MoneyCell({ value, disabled, label, dense, onSave }: { const shown = draft ?? (value ? value.toLocaleString('ko-KR') : '') return ( setDraft(e.target.value.replace(/[^0-9]/g, ''))} onBlur={() => { if (draft === null) return @@ -392,7 +398,8 @@ function DateCell({ value, disabled, label, dense, onSave }: { }) { return ( { const next = v || null; if (next !== value) onSave(next) }} /> ) @@ -403,7 +410,7 @@ function TextCell({ value, disabled, label, onSave, wide, dense }: { }) { return ( { @@ -430,66 +437,65 @@ function useRowWriter(row: PnlRow, onWrite: (quoteId: number, patch: PnlPatch) = /* ── PC·태블릿 표 ─────────────────────────────────────────────────────── */ /** - * PC·태블릿 표 — **한 화면에 다 들어가게** 짠다(2026-09-16 지시). + * PC·태블릿 표 — **한 줄에 한 칸씩, 줄마다 높이가 같게**(2026-09-16 지시). * - * 처음엔 칸을 열셋으로 늘어놓고 옆으로 밀게 했다. PC 에서 가로 스크롤은 잘 안 되고, - * 밀지 않으면 글자가 잘린 채로 읽힌다 — 표를 보는 뜻이 사라진다. 그래서 칸을 **열로 줄였다**. + * 처음엔 폭을 줄이려고 값을 위아래로 쌓았다(고객명 아래 사업자명, 공급가액 아래 VAT·공급대가). + * 그러자 칸마다 높이가 달라지고 숫자가 세로로 몰려 읽기 어려워졌다(제보) — 표를 보는 뜻이 사라진다. * - * · 고객명과 사업자명을 한 칸에 위아래로 (사업자명은 이름이 길다 — 폭을 줘야 안 잘린다) - * · 공급가액 아래에 VAT 를 작게 (같은 수에서 나오는 값이라 붙여 두는 편이 읽기 쉽다) - * · 계약금·캐피탈 아래에 각각의 입금일 (따로 두던 「입금일」 칸이 없어졌다) + * 그래서 **쌓지 않는다.** 칸은 하나씩 옆으로 늘어놓고, 다 넣으면 폭이 남으니 + * 화면이 좁을 때는 **표만** 옆으로 민다(페이지가 아니라 표에 스크롤이 붙는다). * - * 폭은 `table-layout: fixed` 로 못 박는다 — 숫자 칸은 잘리지 않을 만큼 주고, 남는 폭은 비고가 먹는다. + * 계약서에서 오는 값(고객명·공급가액·VAT·공급대가·계약금)은 **글자**이고, 적는 칸은 **입력**이다. + * 둘이 섞이므로 글자 쪽도 입력과 **같은 높이**를 차지하게 해 줄을 맞춘다(`tdCalc`). */ -const COLS: { w: string; head: string; sub?: string }[] = [ - { w: '13%', head: '고객명', sub: '사업자명' }, - { w: '108px', head: '발행일' }, - // 공급가액에서 나오는 값(VAT·공급대가)은 **그 아래**에 붙인다 — 칸을 둘 더 쓰면 화면을 넘는다 - { w: '116px', head: '공급가액', sub: 'VAT · 합계' }, - { w: '110px', head: '계약금', sub: '입금일' }, - { w: '110px', head: '캐피탈', sub: '입금일' }, - { w: '104px', head: '입금 차액' }, - { w: '96px', head: '원가' }, - { w: '104px', head: '수익' }, +const COLS: { w: number | string; head: string; num?: boolean }[] = [ + { w: 128, head: '고객명' }, + { w: 112, head: '발행일' }, + { w: 108, head: '공급가액', num: true }, + { w: 100, head: 'VAT', num: true }, + { w: 112, head: '공급대가', num: true }, + { w: 92, head: '계약금', num: true }, + { w: 108, head: '캐피탈', num: true }, + { w: 108, head: '입금 차액', num: true }, + { w: 112, head: '계약금 입금일' }, + { w: 112, head: '캐피탈 입금일' }, + { w: 108, head: '원가', num: true }, + { w: 108, head: '수익', num: true }, { w: 'auto', head: '비고' }, + { w: 40, head: '' }, ] +/** 비고를 뺀 폭의 합 + 비고 최소폭 — 이보다 좁아지면 표만 옆으로 민다 */ +const TABLE_MIN = COLS.reduce((n, c) => n + (typeof c.w === 'number' ? c.w : 0), 0) + 180 -function RowTable({ rows, canEdit, onWrite }: { +function RowTable({ rows, canEdit, onWrite, onVoid }: { rows: PnlRow[]; canEdit: boolean onWrite: (quoteId: number, patch: PnlPatch) => Promise + onVoid: (saved: PnlRow) => void }) { - const total = sumP(rows) + const total = sumP(rows.filter(r => !r.voided_at)) return ( - // 아주 좁은 창에서만 밀린다 — 평소에는 스크롤이 생기지 않는다
-
{t(h)} + {t(c.head)}{c.sub && {t(c.sub)}} +
{t('합계')}{won(total.supply_amount)}{won(total.vat)}{won(total.gross)}{t('합계')} + {won(total.supply_amount)} + {won(total.vat)} + {won(total.gross)} + {won(total.deposit)} {won(total.capital)} {won(total.pay_diff)} - {won(total.cost_total)}{won(total.cost)} {won(total.profit)}
- {row.customer ?? '—'} - {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} +
+ {row.customer ?? '—'} + {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} +
+ put({ biz_name: v })} /> +
+ put({ invoice_on: v })} /> + + put({ supply_amount: v })} /> + {/* VAT 와 공급대가는 위 숫자에서 나온다 — 칸을 따로 쓰지 않고 바로 아래 붙인다 */} +
{won(d.vat)}
+
{won(d.gross)}
+
+ put({ deposit: v })} /> + put({ deposit_paid_on: v })} /> put({ biz_name: v })} /> put({ invoice_on: v })} /> put({ supply_amount: v })} />{won(d.vat)}{won(d.gross)} put({ deposit: v })} /> put({ capital: v })} />{won(d.pay_diff)} -
{t('계약금')} - put({ deposit_paid_on: v })} />
-
{t('캐피탈')} - put({ capital_paid_on: v })} />
+ put({ capital: v })} /> + put({ capital_paid_on: v })} />
- - {costOpen && setCostOpen(false)} onSave={put} />} + {won(d.pay_diff)} + put({ cost: v })} /> {won(d.profit)} put({ memo: v })} />
- {COLS.map(c => )} +
+ {COLS.map(c => )} - - {COLS.map(c => ( - - ))} - + {COLS.map(c => )} - {rows.map(r => )} + {rows.map(r => )} - + + + + -
- {t(c.head)}{c.sub && {t(c.sub)}} -
{t(c.head)}
{t('합계')} - {won(total.supply_amount)} - {won(total.vat)} - {won(total.gross)} - {won(total.supply_amount)}{won(total.vat)}{won(total.gross)} {won(total.deposit)} {won(total.capital)} {won(total.pay_diff)} {won(total.cost)} {won(total.profit)} +
@@ -497,61 +503,162 @@ function RowTable({ rows, canEdit, onWrite }: { ) } -function TableRow({ row, canEdit, onWrite }: { +function TableRow({ row, canEdit, onWrite, onVoid }: { row: PnlRow; canEdit: boolean onWrite: (quoteId: number, patch: PnlPatch) => Promise + onVoid: (saved: PnlRow) => void }) { const d = deriveP(row) const { put, mark, failed } = useRowWriter(row, onWrite) - const ro = !canEdit + const [ask, setAsk] = useState(false) + const dead = !!row.voided_at + const ro = !canEdit || dead return ( - - -
- {row.customer ?? '—'} - {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} + <> + {/* 삭제된 줄은 **회색**이고, 그 위에 반투명 레이어가 덮여 사유가 적힌다(아래 VoidLayer) */} + + +
+ {row.customer ?? '—'} + {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} +
+ + + put({ invoice_on: v })} /> + + {won(row.supply_amount)} + {won(d.vat)} + {won(d.gross)} + {won(row.deposit)} + + put({ capital: v })} /> + + {won(d.pay_diff)} + + put({ deposit_paid_on: v })} /> + + + put({ capital_paid_on: v })} /> + + + put({ cost: v })} /> + + {won(d.profit)} + put({ memo: v })} /> + + {canEdit && !dead && ( + + )} + {/* + 회색 줄 **위에** 덮는다 — 줄을 지우지 않고 왜 뺐는지 그 자리에 적는다(지시). + 줄(``)을 기준 삼아 그 **한 줄을 통째로** 덮는다. 칸 안에 넣지만 칸에 갇히지 않는다 — + 기준이 줄이라 왼쪽 끝부터 오른쪽 끝까지 닿는다. + */} + {dead && ( +
+ {t('삭제됨')} + {row.void_reason ?? '—'} + {row.voided_by ?? ''} + {canEdit && ( + + )} +
+ )} + + + {ask && ( + setAsk(false)} + onDone={saved => { setAsk(false); onVoid(saved) }} + /> + )} + + ) +} + +/** + * 삭제 확인 — **사유 없이는 못 지운다.** 몇 달 뒤에 왜 뺐는지 물으면 답할 수 있어야 한다. + * 줄은 지워지지 않는다는 것을 창에서도 말해 준다(지운 줄 알고 놀라지 않게). + */ +function VoidCard({ row, onClose, onDone }: { + row: PnlRow; onClose: () => void; onDone: (saved: PnlRow) => void +}) { + useEscapeClose(onClose) + const [reason, setReason] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + const go = async () => { + if (!reason.trim() || busy) return + setBusy(true); setErr('') + try { onDone(await voidPnl(row.quote_id, reason.trim())) } + catch (e) { setErr(e instanceof Error ? e.message : t('저장하지 못했습니다')); setBusy(false) } + } + return ( +
{ if (ev.target === ev.currentTarget) onClose() }}> +
+
+ {tf('{0} 손익 삭제', row.customer ?? String(row.quote_id))} +
- put({ biz_name: v })} /> - - - put({ invoice_on: v })} /> - - - put({ supply_amount: v })} /> - {/* VAT 와 공급대가는 위 숫자에서 나온다 — 칸을 따로 쓰지 않고 바로 아래 붙인다 */} -
{won(d.vat)}
-
{won(d.gross)}
- - - put({ deposit: v })} /> - put({ deposit_paid_on: v })} /> - - - put({ capital: v })} /> - put({ capital_paid_on: v })} /> - - {won(d.pay_diff)} - - put({ cost: v })} /> - - {won(d.profit)} - put({ memo: v })} /> +
+
{t('줄은 지워지지 않습니다. 회색으로 남고 합계에서만 빠지며, 사유가 그 줄 위에 적힙니다.')}
+ + setReason(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void go() }} + /> + {err && {err}} +
+ + +
+
+
+
+ ) +} + +/** 표 안에서는 같은 창을 줄 하나에 담아 띄운다(표 구조를 깨지 않게) */ +function VoidModal(p: { row: PnlRow; onClose: () => void; onDone: (saved: PnlRow) => void }) { + return ( + + ) } /* ── 휴대폰 카드 ──────────────────────────────────────────────────────── */ -function RowCard({ row, canEdit, onWrite }: { +function RowCard({ row, canEdit, onWrite, onVoid }: { row: PnlRow; canEdit: boolean onWrite: (quoteId: number, patch: PnlPatch) => Promise + onVoid: (saved: PnlRow) => void }) { const d = deriveP(row) const { put, mark, failed } = useRowWriter(row, onWrite) const [open, setOpen] = useState(false) - const ro = !canEdit + const [ask, setAsk] = useState(false) + const dead = !!row.voided_at + const ro = !canEdit || dead return ( -
+
+ {/* 삭제된 줄 — 카드 맨 위에 사유를 얹는다(표의 반투명 레이어와 같은 뜻) */} + {dead && ( +
+ {t('삭제됨')} + {row.void_reason ?? '—'} + {canEdit && ( + + )} +
+ )}
{open && (
- put({ biz_name: v })} /> put({ invoice_on: v })} /> - put({ supply_amount: v })} /> + {/* 계약서에서 오는 값 — 보여만 준다 */} + {won(row.supply_amount)} {won(d.vat)} - {won(d.gross)} - put({ deposit: v })} /> + {won(d.gross)} + {won(row.deposit)} put({ capital: v })} /> put({ deposit_paid_on: v })} /> put({ capital_paid_on: v })} /> @@ -581,8 +688,14 @@ function RowCard({ row, canEdit, onWrite }: { {won(d.profit)} put({ memo: v })} /> + {canEdit && !dead && ( +
+ +
+ )}
)} + {ask && setAsk(false)} onDone={saved => { setAsk(false); onVoid(saved) }} />}
) } @@ -603,6 +716,14 @@ const cardBase: React.CSSProperties = { padding: 'var(--sp-4)', display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', minWidth: 0, } const cellNum: React.CSSProperties = { fontVariantNumeric: 'tabular-nums', textAlign: 'right', whiteSpace: 'nowrap' } +/** + * 표에서 **글자로만 보여 주는 숫자** — 적는 칸(28px)과 같은 높이를 차지해야 줄이 맞는다. + * 안쪽 여백도 입력칸(7px)과 맞춘다 — 안 맞추면 숫자 오른쪽 끝이 칸마다 어긋난다. + */ +const tdText: React.CSSProperties = { + padding: '5px 7px', borderBottom: 'var(--hairline)', + fontSize: 'var(--fs-label)', height: 'var(--h-control-sm)', ...cellNum, +} /** * **적는 칸은 한 벌이다.** 높이도 글자 크기도 앱 기준 토큰(`--h-control`·`--fs-input`)을 쓴다. @@ -625,6 +746,9 @@ const CELL: React.CSSProperties = { ...CONTROL, height: 'var(--h-control-sm)', minHeight: 'var(--h-control-sm)', fontSize: 'var(--fs-label)', padding: '0 7px', } +/** 못 적는 칸 — 테두리는 그대로 두고 흐리게. 적을 수 있는 칸처럼 보이면 눌러 보다가 헛수고한다 */ +const OFF: React.CSSProperties = { background: 'var(--soft, #F7F7F4)', color: 'var(--muted)', cursor: 'default' } + /** 계산해서 보여만 주는 값 — 적는 칸과 **같은 자리**를 차지한다(테두리만 없다) */ const readOnly = (base: React.CSSProperties): React.CSSProperties => ({ ...base, border: '1px solid transparent', background: 'none', @@ -676,22 +800,20 @@ const s: Record = { tableWrap: { overflowX: 'auto' }, // 폭을 못 박는다 — 숫자 칸은 잘리지 않을 만큼, 남는 폭은 비고가 먹는다 table: { borderCollapse: 'collapse', width: '100%', tableLayout: 'fixed', minWidth: 900 }, - th: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'left', padding: '6px 5px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, - thSub: { marginLeft: 5, color: 'var(--muted)', opacity: 0.75 }, - subNum: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', ...cellNum, padding: '2px 7px 0' }, - subNumStrong: { fontSize: 'var(--fs-caption)', color: 'var(--dark)', fontWeight: 700, ...cellNum, padding: '0 7px' }, + th: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'left', padding: '6px 7px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, + thNum: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400, textAlign: 'right', padding: '6px 7px', borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap' }, tfSub: { display: 'block', fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 400 }, tfSubStrong: { display: 'block', fontSize: 'var(--fs-caption)', color: 'var(--dark)', fontWeight: 700 }, - td: { padding: '4px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', verticalAlign: 'middle' }, - tdNum: { padding: '4px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', ...cellNum }, - tdCalc: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--muted)', ...cellNum }, - tdCalcWarn: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--req)', fontWeight: 700, ...cellNum }, - tdCalcGood: { padding: '4px 12px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, ...cellNum }, + td: { padding: '5px 5px', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', verticalAlign: 'middle', height: 'var(--h-control-sm)' }, + tdCalc: { ...tdText, color: 'var(--muted)' }, + tdCalcStrong: { ...tdText, color: 'var(--dark)', fontWeight: 700 }, + tdCalcWarn: { ...tdText, color: 'var(--req)', fontWeight: 700 }, + tdCalcGood: { ...tdText, color: 'var(--dark)', fontWeight: 700 }, tfLabel: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)' }, tfNum: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)', ...cellNum }, tfNumWarn: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--req)', fontSize: 'var(--fs-label)', ...cellNum }, - nameRow: { display: 'flex', alignItems: 'baseline', gap: 5, minWidth: 0, padding: '0 2px 2px' }, + nameRow: { display: 'flex', alignItems: 'baseline', gap: 5, minWidth: 0, padding: '0 2px' }, name: { color: 'var(--dark)', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }, no: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', flexShrink: 0, fontWeight: 400 }, mark: { color: 'var(--lime)' }, @@ -718,6 +840,10 @@ 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)' }, + 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%' }, rowCardSum: { display: 'flex', gap: 6, flexWrap: 'wrap' }, cardAmount: { marginLeft: 'auto', ...cellNum, fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)' }, @@ -737,6 +863,38 @@ const s: Record = { costSum: { gridColumn: '1 / -1', display: 'flex', justifyContent: 'space-between', gap: 8, paddingTop: 8, borderTop: '1px solid var(--dark)', fontSize: 'var(--fs-label)', color: 'var(--dark)', flexWrap: 'wrap' }, note: { gridColumn: '1 / -1', fontSize: 'var(--fs-caption)', color: 'var(--muted)', lineHeight: 1.6 }, + // ── 삭제된 줄 ── + // 회색으로 남긴다 — 지운 것이 아니라 「뺀 것」이고, 무엇이 있었는지는 그대로 보여야 한다 + // 줄을 기준으로 삼아야 레이어가 줄 전체를 덮는다 + trDead: { color: 'var(--muted)', position: 'relative' }, + tdAct: { padding: '5px 2px', borderBottom: 'var(--hairline)', textAlign: 'center' }, + delBtn: { + border: 'none', background: 'none', color: 'var(--muted)', cursor: 'pointer', + fontFamily: 'inherit', fontSize: 13, width: 28, height: 'var(--h-control-sm)', borderRadius: 'var(--r-sm)', + }, + voidLayer: { + position: 'absolute', inset: 0, zIndex: 1, + display: 'flex', alignItems: 'center', gap: 8, padding: '0 8px', + // 반투명 — 아래 회색 줄이 비쳐 보이되 사유는 또렷하게 읽힌다 + background: 'rgba(255,255,255,.88)', + borderTop: '1px solid var(--req)', borderBottom: '1px solid var(--req)', + fontSize: 'var(--fs-label)', color: 'var(--dark)', textAlign: 'left', + }, + voidTag: { + flexShrink: 0, fontSize: 'var(--fs-caption)', fontWeight: 700, color: '#fff', + background: 'var(--req)', borderRadius: 4, padding: '1px 7px', whiteSpace: 'nowrap', + }, + voidReason: { flex: '0 1 auto', minWidth: 0, maxWidth: 460, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + voidWho: { flexShrink: 1, minWidth: 0, fontSize: 'var(--fs-caption)', color: 'var(--muted)', whiteSpace: 'nowrap', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis' }, + undoBtn: { + flexShrink: 0, border: 'var(--hairline)', background: '#fff', borderRadius: 'var(--r-sm)', + padding: '0 9px', height: 'var(--h-control-sm)', cursor: 'pointer', + fontFamily: 'inherit', fontSize: 'var(--fs-caption)', color: 'var(--dark)', whiteSpace: 'nowrap', + }, + voidCount: { fontSize: 'var(--fs-caption)', color: 'var(--req)', whiteSpace: 'nowrap' }, + tdModalHost: { padding: 0, border: 'none', height: 0 }, + voidBody: { padding: 'var(--sp-4)', display: 'flex', flexDirection: 'column', gap: 8 }, + muted: { fontSize: 'var(--fs-caption)', color: 'var(--muted)' }, empty: { fontSize: 'var(--fs-label)', color: 'var(--muted)', padding: '10px 0' }, err: { fontSize: 'var(--fs-label)', color: 'var(--warn)' }, diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index acbe73c..509d410 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -16,6 +16,8 @@ export const EN: Record = { '명': '', '건': '', '예정': 'Planned', + '삭제됨': 'Voided', + '줄은 지워지지 않습니다. 회색으로 남고 합계에서만 빠지며, 사유가 그 줄 위에 적힙니다.': 'The row is not deleted. It stays greyed out, drops out of the totals, and the reason is shown over it.', '입력': 'Enter', '접기': 'Collapse', '여기서 다 적고 저장하면 아래 그 달 표로 내려갑니다': 'Fill it in here and save — the row drops into that month below', @@ -1262,7 +1264,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}', From 3da4a60180d813ffb1dcb43e0747a3896219763c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 16:51:19 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(pnl):=20=EA=B3=A0=EA=B0=9D=EB=AA=85=20?= =?UTF-8?q?=EC=9E=98=EB=A6=BC=20=ED=95=B4=EA=B2=B0=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EC=97=85=EC=9E=90=EB=AA=85=20=EC=B9=B8=20=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EC=95=BD=20=EC=97=B4=20=EC=B9=B8(=EC=88=98?= =?UTF-8?q?=EC=9D=B5=C2=B7=EC=88=98=EC=9D=B5=EB=A5=A0=20=EA=B0=95=EC=A1=B0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 고객명이 잘렸다(제보) 옆에 붙인 견적번호가 자리를 먹고 있었다. **번호를 뺐다** — 번호로 찾는 일은 견적 목록에서 한다. 저장 표시(✓)만 이름 뒤에 남는다. ## 사업자명은 따로 적는다(지시) 계약서에서 끌어오려다 **적는 건과 안 적는 건이 섞여 있어** 되돌렸다. 표에 칸을 따로 두고 손으로 적는다(비워 둬도 된다). 미리 채우지 않는다 — 채워 두면 안 적기로 한 건까지 적힌 것처럼 보인다. ## 요약 열 칸 · 수익과 수익률 강조(지시) 한 칸에 「금액 · %」로 붙여 놨더니 긴 글자가 되어 눈에 안 들어왔다. 갈라서 각자 한 칸씩 준다. **숫자만** 브랜드 라임(`--lime-ink`)으로 키운다 — 컨피규레이터 **실구매가와 같은 색·같은 크기(22px)**. 칸은 칠하지 않는다. 칠하면 덩어리가 되어 옆 칸들과 무게가 뒤집힌다(가격바가 배경을 걷은 것과 같은 이유). ⚠️ 이 색은 흰 바탕 대비가 낮아 **큰 글씨 전용**이다(가격바 주석). 그래서 요약의 그 둘만 키우고, 표 안의 수익은 본문 크기라 **검정 굵게**로 둔다. 칸 수를 다섯으로 못 박았던 것도 풀었다 — 좁은 화면에서 칸이 131px 이 되어 22px 짜리 수익 금액(143px)이 옆 칸을 침범했다. 이제 폭에 맞춰 칸 수가 준다. ## 곁들여 로컬 확인용 더미 생성 스크립트(`backend/src/scripts/pnl-dummy.ts`) — 「입력 필요」에 설 건 (특장사 수락 완료)을 몇 개 만든다. 운영에서는 시작과 동시에 멈추고, 시드·배포에 들어가지 않는다. Co-Authored-By: Claude Opus 5 --- .../20260916050000_order_pnl/migration.sql | 2 + backend/prisma/schema.prisma | 11 ++-- backend/src/scripts/pnl-dummy.ts | 52 ++++++++++++++++ backend/src/services/pnl.ts | 7 ++- frontend/src/api/pnl.ts | 4 +- frontend/src/components/PnlTab.tsx | 62 +++++++++++++++---- frontend/src/i18n/en.ts | 1 + 7 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 backend/src/scripts/pnl-dummy.ts diff --git a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql index b41e27b..0306aa5 100644 --- a/backend/prisma/migrations/20260916050000_order_pnl/migration.sql +++ b/backend/prisma/migrations/20260916050000_order_pnl/migration.sql @@ -10,6 +10,8 @@ CREATE TABLE IF NOT EXISTS "order_pnl" ( "quote_id" INTEGER NOT NULL UNIQUE, -- 세금계산서 발행일 — **이 날짜가 몇 월 표에 들어갈지를 정한다.** 비어 있으면 「입력 필요」에 남는다 "invoice_on" DATE, + -- 사업자명 — 고객명과 따로 적는다. **적는 건도 있고 안 적는 건도 있어** 비워 둘 수 있다 + "biz_name" VARCHAR(120), -- 공급가액(VAT 별도)·계약금 — **계약서에서 그대로 가져와 굳힌다.** 화면에서 고치지 않는다(2026-09-16 지시). -- 굳혀 두는 이유: 단가표를 나중에 고쳐도 이미 세금계산서가 나간 줄의 금액은 그대로여야 한다 "supply_amount" BIGINT NOT NULL DEFAULT 0, diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5d3ff95..fcdf4d0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -473,12 +473,15 @@ model OrderPnl { quote_id Int @unique /// 세금계산서 발행일 — **이 날짜가 몇 월 표에 들어갈지를 정한다.** 비어 있으면 「입력 필요」에 남는다 - invoice_on DateTime? @db.Date + invoice_on DateTime? @db.Date + + /// 사업자명 — 고객명과 따로 적는다. 적는 건도 있고 안 적는 건도 있어 비워 둘 수 있다 + biz_name String? @db.VarChar(120) /// 공급가액(VAT 별도)·계약금 — **계약서에서 가져와 굳힌다.** 화면에서 고치지 않는다(2026-09-16 지시) - supply_amount BigInt @default(0) - deposit BigInt @default(0) + supply_amount BigInt @default(0) + deposit BigInt @default(0) /// 캐피탈 — 경영관리가 직접 적는다 - capital BigInt @default(0) + capital BigInt @default(0) deposit_paid_on DateTime? @db.Date capital_paid_on DateTime? @db.Date diff --git a/backend/src/scripts/pnl-dummy.ts b/backend/src/scripts/pnl-dummy.ts new file mode 100644 index 0000000..43f0ddb --- /dev/null +++ b/backend/src/scripts/pnl-dummy.ts @@ -0,0 +1,52 @@ +/** + * **로컬 확인용 더미** — 「입력 필요」에 설 건(특장사 수락 완료)을 만든다. + * + * ⚠️ 로컬에서 손으로만 돌린다(배포·시드에 들어가지 않는다). 운영 DB 에는 절대 돌리지 말 것. + * `npx tsx src/scripts/pnl-dummy.ts [개수]` + */ +// ⚠️ config 를 가장 먼저 — prisma 가 빈 환경으로 먼저 만들어지면 「DB 연결 없음」으로 죽는다 +import '../config.js'; +import { prisma } from '../lib/prisma.js'; + +const NAMES = ['더미물류', '더미상사', '주식회사 더미로지스', '더미냉동', '더미유통']; + +async function main() { + if (!prisma) throw new Error('DB 연결 필요'); + if ((process.env['NODE_ENV'] ?? 'development') === 'production') throw new Error('운영에서는 돌리지 않는다'); + const n = Math.min(Math.max(Number(process.argv[2] ?? 1), 1), 10); + + // 값이 제대로 잡히게 **실제 견적 하나를 본떠** 만든다(옵션 선택이 있어야 특장가격이 나온다) + const seed = await prisma.quote.findFirst({ + where: { status: { in: ['ordered', 'completed', 'assigned'] }, selections: { not: {} } }, + orderBy: { id: 'desc' }, + select: { model_code: true, selections: true, inputs: true, final_price: true, sales_user_id: true, org_id: true }, + }); + if (!seed) throw new Error('본뜰 견적을 못 찾았다 — 옵션이 있는 견적이 하나는 있어야 한다'); + + for (let i = 0; i < n; i++) { + const name = `${NAMES[i % NAMES.length]}_더미${Date.now() % 10000}`; + const customer = await prisma.customer.create({ data: { name, phone: `010-0000-${String(1000 + i)}` } }); + const quote = await prisma.quote.create({ + data: { + model_code: seed.model_code, selections: seed.selections as object, inputs: seed.inputs as object, + status: 'ordered', customer_id: customer.id, final_price: seed.final_price, + sales_user_id: seed.sales_user_id, org_id: seed.org_id, + assign_requested_at: new Date(), + }, + select: { id: true }, + }); + // 수락일을 하루씩 벌려 둔다 — 「오래된 것이 맨 위」를 눈으로 확인할 수 있게 + const acceptedAt = new Date(Date.now() - (i + 1) * 86_400_000); + 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(`더미 ${n}건을 「입력 필요」에 세웠다.`); +} + +main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); }); diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index 3cc5978..385286c 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -19,6 +19,8 @@ export interface PnlRow { quote_id: number; quote_no: string | null; customer: string | null; + /** 사업자명 — 고객명과 따로 적는다(안 적을 수도 있다) */ + biz_name: string | null; sales_user_id: string | null; maker_org: string | null; invoice_on: string | null; @@ -54,7 +56,7 @@ const day = (d: Date | null | undefined): string | null => (d ? d.toISOString(). /** DB 행 + 견적을 화면 모양으로 */ type RowWithQuote = { - quote_id: number; invoice_on: Date | null; + quote_id: number; invoice_on: Date | null; biz_name: string | null; supply_amount: bigint; deposit: bigint; capital: bigint; deposit_paid_on: Date | null; capital_paid_on: Date | null; cost: bigint; cost_source: string; memo: string | null; updated_by: string | null; @@ -73,6 +75,7 @@ function toRow(r: RowWithQuote): PnlRow { customer: r.quote.customer?.name ?? null, sales_user_id: r.quote.sales_user_id, maker_org: r.quote.order?.maker_org?.name ?? null, + biz_name: r.biz_name, invoice_on: day(r.invoice_on), supply_amount: n(r.supply_amount), deposit: n(r.deposit), @@ -211,6 +214,7 @@ export async function pnlPending(): Promise { */ export interface PnlPatch { invoice_on?: string | null; + biz_name?: string | null; capital?: number; deposit_paid_on?: string | null; capital_paid_on?: string | null; @@ -244,6 +248,7 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro const data: Record = { updated_by: by }; 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('capital', money(patch.capital)); put('deposit_paid_on', dateOf(patch.deposit_paid_on)); put('capital_paid_on', dateOf(patch.capital_paid_on)); diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index 86d2fad..fc0b0c2 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -6,6 +6,8 @@ export interface PnlRow { quote_id: number quote_no: string | null customer: string | null + /** 사업자명 — 고객명과 따로 적는다(안 적을 수도 있다) */ + biz_name: string | null sales_user_id: string | null maker_org: string | null invoice_on: string | null @@ -50,7 +52,7 @@ export interface PnlView { * ⚠️ **공급가액·계약금은 없다** — 계약서에서 가져와 굳힌 값이라 고치지 않는다(서버도 받지 않는다). */ 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 92f56db..8826def 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -89,7 +89,7 @@ export function PnlTab() { {err &&
{err}
} {/* ① 요약 — 그 달 전체 */} - + {/* ② 입력 필요 — 맨 위에 따로 둔다. 여기서 발행일을 적으면 그 달 표로 들어간다 */} @@ -152,8 +152,19 @@ function MonthBar({ month, months, busy, onPick }: { const won = (n: number) => `₩${Math.round(n).toLocaleString('ko-KR')}` const pct = (v: number | null) => (v === null ? '—' : `${(v * 100).toFixed(1)}%`) -function SummaryBar({ total, isMobile }: { total: ReturnType; isMobile: boolean }) { - const tiles: { label: string; value: string; tone?: 'warn' | 'good' }[] = [ +/** + * 그 달 요약 — **열 칸**(2026-09-16 지시). + * + * 수익과 수익률을 한 칸에 「금액 · %」로 붙여 놨더니 긴 글자가 되어 눈에 안 들어왔다. + * 갈라서 각자 한 칸씩 주고, **숫자만** 브랜드 라임으로 키운다 — 이 표를 보는 이유가 그 둘이다. + * 색은 컨피규레이터의 **실구매가와 같은 값**(`--lime-ink`)이다. 칸을 칠하지 않는다 — + * 칸을 칠하면 덩어리가 되어 옆 칸들과 무게가 뒤집힌다(가격바가 배경을 걷은 것과 같은 이유). + * ⚠️ 이 색은 흰 바탕에서 대비가 낮아 **큰 글씨 전용**이다. 그래서 이 둘만 22px 로 키운다. + * 손실이면 빨강으로 뒤집는다(라임으로 물든 손실만큼 위험한 것이 없다). + */ +function SummaryBar({ total }: { total: ReturnType }) { + const loss = total.profit < 0 + const tiles: { label: string; value: string; tone?: 'warn' | 'profit' | 'loss' }[] = [ { label: '발행 건수', value: tf('{0}건', total.count) }, { label: '공급가액', value: won(total.supply_amount) }, { label: 'VAT', value: won(total.vat) }, @@ -163,15 +174,20 @@ function SummaryBar({ total, isMobile }: { total: ReturnType; isMob // 아직 안 들어온 돈 — 음수가 정상이다 { label: '입금 차액', value: won(total.pay_diff), tone: total.pay_diff < 0 ? 'warn' : undefined }, { label: '원가', value: won(total.cost) }, - { label: '수익', value: `${won(total.profit)} · ${pct(total.margin)}`, tone: total.profit < 0 ? 'warn' : 'good' }, + { label: '수익', value: won(total.profit), tone: loss ? 'loss' : 'profit' }, + { label: '수익률', value: pct(total.margin), tone: loss ? 'loss' : 'profit' }, ] return (
-
+ {/* + 칸 수를 못 박지 않는다 — 다섯으로 고정했더니 좁은 화면에서 칸이 131px 이 되어 + 22px 짜리 수익 금액(143px)이 옆 칸을 침범했다(제보). 폭에 맞춰 칸 수가 준다. + */} +
{tiles.map(x => (
{t(x.label)}
-
{x.value}
+
{x.value}
))}
@@ -229,6 +245,8 @@ function PendingBox({ pending, canEdit, isMobile, onFile }: { /** 새 줄에 적을 것 전부 — 표의 칸과 하나씩 짝이 맞는다 */ interface Draft { invoice_on: string + /** 사업자명 — 적을 수도, 안 적을 수도 있다 */ + biz_name: string capital: number deposit_paid_on: string capital_paid_on: string @@ -250,7 +268,7 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { onFile: (quoteId: number, patch: PnlPatch) => Promise }) { const [d, setD] = useState(() => ({ - invoice_on: '', capital: 0, + invoice_on: '', biz_name: '', capital: 0, deposit_paid_on: '', capital_paid_on: '', cost: 0, memo: '', })) @@ -267,6 +285,7 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { try { await onFile(item.quote_id, { invoice_on: d.invoice_on, + biz_name: d.biz_name.trim() || null, capital: d.capital, deposit_paid_on: d.deposit_paid_on || null, capital_paid_on: d.capital_paid_on || null, @@ -300,6 +319,11 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { {/* 계약서에서 오는 값 — 보여만 준다(고쳐야 하는 예외가 생기면 그때 다시 본다) */} {item.customer ?? '—'} + {/* 사업자명은 따로 적는다 — 적는 건도 있고 안 적는 건도 있다 */} + + set('biz_name', e.target.value)} /> + {won(fixed.supply_amount)} {won(calc.vat)} @@ -449,7 +473,9 @@ function useRowWriter(row: PnlRow, onWrite: (quoteId: number, patch: PnlPatch) = * 둘이 섞이므로 글자 쪽도 입력과 **같은 높이**를 차지하게 해 줄을 맞춘다(`tdCalc`). */ const COLS: { w: number | string; head: string; num?: boolean }[] = [ - { w: 128, head: '고객명' }, + // 견적번호는 빼 두었다 — 옆에 붙이면 고객명이 잘린다(제보). 번호로 찾을 일은 견적 목록에서 한다 + { w: 132, head: '고객명' }, + { w: 132, head: '사업자명' }, { w: 112, head: '발행일' }, { w: 108, head: '공급가액', num: true }, { w: 100, head: 'VAT', num: true }, @@ -485,7 +511,7 @@ function RowTable({ rows, canEdit, onWrite, onVoid }: { - {t('합계')} + {t('합계')} {won(total.supply_amount)} {won(total.vat)} {won(total.gross)} @@ -517,12 +543,16 @@ function TableRow({ row, canEdit, onWrite, onVoid }: { <> {/* 삭제된 줄은 **회색**이고, 그 위에 반투명 레이어가 덮여 사유가 적힌다(아래 VoidLayer) */} + {/* 계약서에서 오는 고객명 — 글자다. 저장 표시(✓)만 뒤에 붙는다 */}
{row.customer ?? '—'} - {row.quote_no ?? `#${row.quote_id}`}{mark && {mark}} + {mark && {mark}}
+ + put({ biz_name: v })} /> + put({ invoice_on: v })} /> @@ -673,6 +703,7 @@ function RowCard({ row, canEdit, onWrite, onVoid }: { {open && (
put({ invoice_on: v })} /> + put({ biz_name: v })} /> {/* 계약서에서 오는 값 — 보여만 준다 */} {won(row.supply_amount)} {won(d.vat)} @@ -775,12 +806,14 @@ const s: Record = { // 줄이 바뀌어도 폭을 다 먹지 않게 — 고르개가 화면 한 줄을 차지하면 달 이동 단추가 멀어진다 monthSelect: { flex: '0 0 auto', maxWidth: 200, fontFamily: 'inherit', fontSize: 'var(--fs-input)', padding: '5px 8px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff' }, - sumGrid: { display: 'grid', gap: 'var(--sp-3)' }, + sumGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(152px, 1fr))', gap: 'var(--sp-3)' }, sumCell: { minWidth: 0 }, sumLabel: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', whiteSpace: 'nowrap' }, sumVal: { fontSize: 18, fontWeight: 700, color: 'var(--dark)', ...cellNum, textAlign: 'left' }, sumValWarn: { fontSize: 18, fontWeight: 700, color: 'var(--req)', ...cellNum, textAlign: 'left' }, - sumValGood: { fontSize: 18, fontWeight: 700, color: 'var(--dark)', ...cellNum, textAlign: 'left' }, + // 결론 숫자 — 컨피규레이터의 실구매가와 같은 색·같은 크기. 칸은 칠하지 않는다 + sumValProfit: { fontSize: 22, fontWeight: 700, color: 'var(--lime-ink)', letterSpacing: '-0.01em', ...cellNum, textAlign: 'left' }, + sumValLoss: { fontSize: 22, fontWeight: 700, color: 'var(--req)', letterSpacing: '-0.01em', ...cellNum, textAlign: 'left' }, // 밀린 건이 백 줄이어도 아래 표가 화면 밖으로 밀리지 않게 높이를 묶는다 pendList: { display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 320, overflowY: 'auto' }, @@ -808,6 +841,7 @@ const s: Record = { tdCalc: { ...tdText, color: 'var(--muted)' }, tdCalcStrong: { ...tdText, color: 'var(--dark)', fontWeight: 700 }, tdCalcWarn: { ...tdText, color: 'var(--req)', fontWeight: 700 }, + // ⚠️ 표의 수익은 **검정**이다 — 라임은 흰 바탕 대비가 낮아 본문 크기에서 흐려 보인다(가격바 주석) tdCalcGood: { ...tdText, color: 'var(--dark)', fontWeight: 700 }, tfLabel: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)' }, tfNum: { padding: '8px 6px', borderTop: '1px solid var(--dark)', fontWeight: 700, color: 'var(--dark)', fontSize: 'var(--fs-label)', ...cellNum }, @@ -816,7 +850,7 @@ const s: Record = { nameRow: { display: 'flex', alignItems: 'baseline', gap: 5, minWidth: 0, padding: '0 2px' }, name: { color: 'var(--dark)', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }, no: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', flexShrink: 0, fontWeight: 400 }, - mark: { color: 'var(--lime)' }, + mark: { color: 'var(--lime-ink)', flexShrink: 0, fontWeight: 700 }, markFail: { color: 'var(--req)' }, // ── 폼(입력 필요 · 휴대폰 카드 · 원가 창) ── @@ -826,6 +860,8 @@ const s: Record = { dateInput: { ...CONTROL, minWidth: 0, justifyContent: 'space-between' }, costBtn: { ...CONTROL, cursor: 'pointer', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, calc: { ...readOnly(CONTROL), color: 'var(--body)' }, + /** 글자로 보여 주는 값(고객명 등) — 숫자가 아니라 왼쪽에 붙인다 */ + calcText: { ...readOnly(CONTROL), color: 'var(--dark)', fontWeight: 700, justifyContent: 'flex-start', textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block', lineHeight: 'var(--h-control)' }, calcStrong: { ...readOnly(CONTROL), color: 'var(--dark)', fontWeight: 700 }, calcWarn: { ...readOnly(CONTROL), color: 'var(--req)', fontWeight: 700 }, diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 509d410..bb0649a 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -16,6 +16,7 @@ export const EN: Record = { '명': '', '건': '', '예정': 'Planned', + '수익률': 'Margin', '삭제됨': 'Voided', '줄은 지워지지 않습니다. 회색으로 남고 합계에서만 빠지며, 사유가 그 줄 위에 적힙니다.': 'The row is not deleted. It stays greyed out, drops out of the totals, and the reason is shown over it.', '입력': 'Enter', From 8d3c75c29db6730efaf94f2d6d7085a6983b5a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 17:04:56 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor(rbac):=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=A0=84=EC=88=98=EC=A1=B0=EC=82=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=C2=B7=EC=84=A4=EB=AA=85=EC=9D=84=20=EB=AC=BC=EA=B0=88?= =?UTF-8?q?=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 무엇이 꼬여 있었나 이름은 DB 시드에, 설명은 화면 상수에, 코드는 라우트에 — **세 곳으로 갈라져** 서로 어긋났다. 그래서 이런 것들이 쌓였다. · **아무 데서도 검사하지 않는 모듈 넷**이 목록에 남아 켜고 끌 수 있었다 — 견적 삭제(삭제 기능 자체가 없어졌다) · 앱 알림(기기에서 허용하는 것으로 바뀌었다) · 제작 배정 알림 메일(역할 프리셋이 대신한다) · 기준데이터 옛 우산(다섯으로 쪼갰다) · **정렬 번호가 셋씩 겹쳐**(10·11·12) 목록 순서가 뜰 때마다 달라졌다 · 이름이 하는 일과 어긋났다 — 「고객 목록」인데 서류함까지, 「주문 상태 변경」인데 제작 단계 진행 ## 한 벌로 모았다 `shared/rbac/modules.ts` 하나가 **코드·이름·설명·묶음·물러남**을 갖는다. 순서는 배열 순서다(번호를 매기지 않는다 — 매기면 겹친다). DB 의 `feature_module` 은 권한 행이 가리키는 **자리**일 뿐이고, 목록은 라우트가 카탈로그로 실어 보낸다. 이제 화면 글자를 고치는 데 마이그레이션이 필요 없다. ## 이름·설명 물갈이 스물넷 전부 **실제로 하는 일**로 다시 적고 한 줄 설명을 달았다. 묶음(견적·서류·고객·주문·제작· 성과·손익·기준데이터·운영)으로 갈라 목록에 구분 줄을 넣었다 — 스물넷이 한 덩어리면 못 찾는다. ## 물러난 모듈 **행은 지우지 않는다**(이미 켜 둔 계정의 기록이다). `active=false` 로 내리고 목록에서 빼고, 프리셋·권한 시드에서도 뺐다. **새로 켤 수 없다**(서버가 400) — 끄는 것은 언제나 된다. 옛 우산은 이미 켜 둔 계정에서 다섯을 대신하던 동작을 그대로 지킨다. ## 지키는 검사(permission-catalog.test.ts) · 살아 있는 모듈은 **어디선가 실제로 검사한다** — 켜도 아무 일 없는 토글을 못 만든다 · 카탈로그와 시드가 한 글자도 어긋나지 않는다(이름·화면·순서·활성) · 정렬 번호가 겹치지 않는다 · 설명이 이름을 되풀이하지 않는다 · 물러난 모듈이 프리셋·권한 시드에 없다 · 이름·설명·묶음이 **영문 사전에 있다** — 값으로 부르는 자리라 i18n 검사가 못 보던 구멍이다 Co-Authored-By: Claude Opus 5 --- .../migration.sql | 45 ++++++ .../src/__tests__/permission-catalog.test.ts | 140 ++++++++++++++++++ backend/src/__tests__/role-presets.test.ts | 11 +- backend/src/routes/access-control.ts | 10 ++ backend/src/routes/feature-modules.ts | 24 ++- db/seed/access_control.csv | 12 -- db/seed/feature_module.csv | 54 +++---- frontend/src/i18n/en.ts | 53 +++++++ frontend/src/pages/AdminPage.tsx | 99 ++++++------- shared/package.json | 1 + shared/rbac/modules.ts | 125 ++++++++++++++++ shared/rbac/presets.ts | 9 +- shared/types/index.ts | 10 +- 13 files changed, 489 insertions(+), 104 deletions(-) create mode 100644 backend/prisma/migrations/20260916070000_module_catalog/migration.sql create mode 100644 backend/src/__tests__/permission-catalog.test.ts create mode 100644 shared/rbac/modules.ts diff --git a/backend/prisma/migrations/20260916070000_module_catalog/migration.sql b/backend/prisma/migrations/20260916070000_module_catalog/migration.sql new file mode 100644 index 0000000..0c99d44 --- /dev/null +++ b/backend/prisma/migrations/20260916070000_module_catalog/migration.sql @@ -0,0 +1,45 @@ +-- 기능모듈 전수조사(2026-09-16) — 이름·순서·활성을 카탈로그(shared/rbac/modules)와 맞춘다. +-- +-- ⚠️ **행을 지우지 않는다.** 쓰지 않게 된 모듈(견적 삭제·앱 알림·배정 알림 메일·옛 기준데이터 우산)도 +-- `active=false` 로 내리기만 한다 — 이미 켜 둔 계정의 권한 기록을 잃지 않기 위해서다. +-- 목록에는 안 뜨고(라우트가 카탈로그의 retired 를 거른다) 새로 켤 수도 없다. +-- +-- ⚠️ 이름·순서는 이제 **화면이 카탈로그에서 읽는다.** 여기 값은 DB 를 카탈로그에 맞춰 두는 것뿐이고, +-- 다음에 이름을 고칠 때는 카탈로그만 고치면 된다(마이그레이션이 더 필요하지 않다). +INSERT INTO "feature_module" ("code", "name", "surface", "sort_order", "active") +VALUES + ('quote.create', '견적 만들기', '영업', 1, true), + ('quote.edit', '견적 고치기', '영업,관리자', 2, true), + ('quote.confirm', '견적 확정', '영업,관리자', 3, true), + ('doc.send.email', '견적서·계약서 메일 발송', '영업,관리자', 4, true), + ('doc.send.sign', '전자서명 요청', '영업,관리자', 5, true), + ('doc.view', '구조변경 서류 보기', '관리자,특장사', 6, true), + ('customer.view', '고객·서류함 보기', '관리자', 7, true), + ('order.view', '주문 진행 보기', '영업,관리자,특장사', 8, true), + ('order.confirm', '제작 배정', '관리자', 9, true), + ('order.control', '제작 단계 진행', '관리자,특장사', 10, true), + ('order.remove', '주문 치우기', '관리자', 11, true), + ('addon.view', '부가작업 보기', '관리자', 12, true), + ('addon.manage', '부가작업 진행', '관리자', 13, true), + ('checklist.manage', '체크리스트 서식 관리', '관리자', 14, true), + ('stats.own', '내 실적 보기', '영업,관리자', 15, true), + ('stats.all', '전체 실적 보기', '관리자', 16, true), + ('pnl.view', '손익 보기', '관리자', 17, true), + ('pnl.manage', '손익 입력', '관리자', 18, true), + ('basedata.optiondb', '옵션DB 관리', '관리자', 19, true), + ('basedata.weights', '무게상수 관리', '관리자', 20, true), + ('basedata.dims', '치수 프리셋 관리', '관리자', 21, true), + ('basedata.makerprice', '특장사 단가 관리', '관리자', 22, true), + ('basedata.holiday', '공휴일 관리', '관리자', 23, true), + ('account.manage', '계정·권한 관리', '관리자', 24, true), + ('quote.delete', '견적 삭제', '관리자', 25, false), + ('notify.assign', '제작 배정 알림 메일', '관리자', 26, false), + ('notify.push', '앱 알림', '영업,관리자,특장사', 27, false), + ('basedata.manage', '기준데이터 관리(옛 우산)', '관리자', 28, false) +ON CONFLICT ("code") DO UPDATE + SET "name" = EXCLUDED."name", + "surface" = EXCLUDED."surface", + "sort_order" = EXCLUDED."sort_order", + "active" = EXCLUDED."active"; + +-- 물러난 모듈에 남아 있는 권한 행은 **그대로 둔다**(기록이다). 아무 데서도 검사하지 않으므로 효력이 없다. diff --git a/backend/src/__tests__/permission-catalog.test.ts b/backend/src/__tests__/permission-catalog.test.ts new file mode 100644 index 0000000..7d19662 --- /dev/null +++ b/backend/src/__tests__/permission-catalog.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { MODULES, MODULE_BY_CODE, LIVE_MODULES, isRetired } from '@buildup-ev/shared/rbac/modules'; +import { PRESETS } from '@buildup-ev/shared/rbac/presets'; + +/** + * **기능모듈 카탈로그가 한 벌인가.** + * + * 2026-09-16 전수조사에서 나온 것들을 여기서 못 박는다. + * · 아무 데서도 검사하지 않는 모듈이 목록에 남아 켜고 끌 수 있었다(견적 삭제·앱 알림·배정 알림 메일) + * · 같은 뜻의 모듈이 둘이었다(옛 우산 `basedata.manage` 와 새로 쪼갠 다섯) + * · 정렬 번호가 셋씩 겹쳐 목록 순서가 뜰 때마다 달라졌다 + * · 이름·설명이 코드·시드·화면 세 곳에 흩어져 서로 어긋났다 + */ +const ROOT = path.resolve(__dirname, '../../..'); +const read = (p: string) => readFileSync(path.join(ROOT, p), 'utf8'); + +/** 따옴표를 아는 아주 작은 CSV 파서 — surface 값에 쉼표가 들어 있다 */ +function rows(rel: string): Record[] { + const lines = read(rel).trim().split('\n'); + const split = (line: string): string[] => { + const out: string[] = []; let cur = '', q = false; + for (const ch of line) { + if (ch === '"') q = !q; + else if (ch === ',' && !q) { out.push(cur); cur = ''; } + else cur += ch; + } + out.push(cur); + return out.map(c => c.trim()); + }; + const head = split(lines[0]!); + return lines.slice(1).map(l => Object.fromEntries(head.map((h, i) => [h, split(l)[i] ?? '']))); +} + +const SEED = rows('db/seed/feature_module.csv'); +const GRANTS = rows('db/seed/access_control.csv'); + +/** 서버가 실제로 요구하는 모듈 — 라우트·서비스·미들웨어에서 그대로 긁는다 */ +function enforced(): Set { + const out = new Set(); + for (const sub of ['routes', 'services', 'lib']) { + const dir = path.join(ROOT, 'backend/src', sub); + for (const f of readdirSync(dir).filter(n => n.endsWith('.ts'))) { + const src = readFileSync(path.join(dir, f), 'utf8'); + for (const m of src.matchAll(/(?:requirePermission|hasPermission)\(\s*(?:req,\s*)?'([a-z0-9._]+)'/g)) out.add(m[1]!); + // 표 이름으로 고르는 자리(option-db) — 값으로 적힌 코드도 센다 + for (const m of src.matchAll(/'(basedata\.[a-z]+)'/g)) out.add(m[1]!); + } + } + return out; +} + +describe('기능모듈 카탈로그', () => { + it('카탈로그를 실제로 읽었다', () => { + expect(MODULES.length).toBeGreaterThan(20); + expect(LIVE_MODULES.length).toBeGreaterThan(20); + expect(SEED.length).toBe(MODULES.length); + }); + + it('🔴 코드가 겹치지 않는다', () => { + const dup = MODULES.map(m => m.code).filter((c, i, a) => a.indexOf(c) !== i); + expect(dup, '같은 코드가 두 번 적혔다').toEqual([]); + }); + + it('🔴 이름과 설명이 비어 있지 않고, 설명이 이름을 되풀이하지 않는다', () => { + const bad = LIVE_MODULES.filter(m => !m.name.trim() || !m.desc.trim() || m.desc.trim() === m.name.trim()); + expect(bad.map(m => m.code), '이름만 되풀이하는 설명은 설명이 아니다').toEqual([]); + }); + + it('🔴 시드는 카탈로그를 그대로 비춘다 — 이름·화면·순서·활성', () => { + const mismatch: string[] = []; + MODULES.forEach((m, i) => { + const row = SEED.find(r => r['code'] === m.code); + if (!row) { mismatch.push(`${m.code}: 시드에 없다`); return; } + if (row['name'] !== m.name) mismatch.push(`${m.code}: 이름 ${row['name']} ≠ ${m.name}`); + if (row['surface'] !== m.surfaces.join(',')) mismatch.push(`${m.code}: 화면 ${row['surface']} ≠ ${m.surfaces.join(',')}`); + if (Number(row['sort_order']) !== i + 1) mismatch.push(`${m.code}: 순서 ${row['sort_order']} ≠ ${i + 1}`); + const active = m.retired ? 'N' : 'Y'; + if (row['active'] !== active) mismatch.push(`${m.code}: 활성 ${row['active']} ≠ ${active}`); + }); + expect(mismatch, `카탈로그와 시드가 어긋났다:\n ${mismatch.join('\n ')}`).toEqual([]); + }); + + it('🔴 정렬 번호가 겹치지 않는다 — 겹치면 목록 순서가 뜰 때마다 달라진다', () => { + const seen = new Set(); + const dup = SEED.map(r => r['sort_order']!).filter(o => seen.has(o) || (seen.add(o), false)); + expect(dup, '같은 번호를 쓰는 모듈이 있다').toEqual([]); + }); + + it('🔴 살아 있는 모듈은 **어디선가 실제로 검사한다** — 켜도 아무 일 없는 토글을 두지 않는다', () => { + const server = enforced(); + const ui = new Set(); + const page = read('frontend/src/pages/AdminPage.tsx') + read('frontend/src/components/PermGate.tsx'); + for (const dir of ['frontend/src/components', 'frontend/src/pages']) { + for (const f of readdirSync(path.join(ROOT, dir))) { + if (!f.endsWith('.tsx') && !f.endsWith('.ts')) continue; + for (const m of readFileSync(path.join(ROOT, dir, f), 'utf8').matchAll(/usePermission\(\s*'([a-z0-9._]+)'/g)) ui.add(m[1]!); + } + } + for (const m of page.matchAll(/usePermission\(\s*'([a-z0-9._]+)'/g)) ui.add(m[1]!); + const dead = LIVE_MODULES.filter(m => !server.has(m.code) && !ui.has(m.code)); + expect( + dead.map(m => `${m.code}(${m.name})`), + `아무 데서도 검사하지 않는 모듈 — 물러나게 하거나(retired) 검사를 붙여라:\n ${dead.map(m => m.code).join('\n ')}`, + ).toEqual([]); + }); + + it('🔴 물러난 모듈은 프리셋·권한 시드에 없다 — 새로 켜지지 않는다', () => { + const inPreset = PRESETS.flatMap(p => p.modules).filter(isRetired); + expect([...new Set(inPreset)], '물러난 모듈이 자리 기본값에 남아 있다').toEqual([]); + const inGrants = GRANTS.map(g => g['module_code']!).filter(isRetired); + expect([...new Set(inGrants)], '물러난 모듈에 권한을 준다').toEqual([]); + }); + + it('🔴 프리셋이 없는 모듈을 가리키지 않는다', () => { + const ghosts = PRESETS.flatMap(p => p.modules).filter(c => !MODULE_BY_CODE[c]); + expect([...new Set(ghosts)], '카탈로그에 없는 모듈을 자리에 넣었다').toEqual([]); + }); + + it('🔴 이름·설명·묶음이 모두 영문 사전에 있다', () => { + /* + * 화면은 `t(mod.name)` 처럼 **값으로** 부른다 — i18n 검사의 정규식은 리터럴만 보므로 + * 여기 문구는 그쪽에서 안 걸린다. 빠지면 영어로 켠 사람에게 한국어가 그대로 나간다. + */ + const en = read('frontend/src/i18n/en.ts'); + const has = (k: string) => en.includes(`\n '${k.replace(/'/g, "\\'")}':`); + const missing: string[] = []; + for (const m of MODULES) { + for (const k of [m.name, m.desc, m.group]) if (!has(k)) missing.push(`${m.code}: ${k}`); + } + expect(missing, `영문 사전에 없는 기능모듈 문구:\n ${missing.join('\n ')}`).toEqual([]); + }); + + it('🔴 목록은 물러난 모듈을 내보내지 않는다', () => { + const route = read('backend/src/routes/feature-modules.ts'); + expect(route, 'retired 를 거르지 않는다').toMatch(/!m\.retired/); + expect(LIVE_MODULES.some(m => isRetired(m.code))).toBe(false); + }); +}); diff --git a/backend/src/__tests__/role-presets.test.ts b/backend/src/__tests__/role-presets.test.ts index 61613e8..6fb2518 100644 --- a/backend/src/__tests__/role-presets.test.ts +++ b/backend/src/__tests__/role-presets.test.ts @@ -199,8 +199,15 @@ describe.runIf(live)('프리셋 — 실제 API', () => { it('🔴 옛 우산(basedata.manage)만 켜 둔 계정은 쪼갠 화면을 그대로 쓴다', async () => { expect((await setPreset(null)).status).toBe(200); - await request(app).post('/api/v1/access-control').set('Cookie', master) - .send({ subject_type: 'user', subject_ref: ADMIN, module_code: 'basedata.manage', enabled: true }); + /* + * ⚠️ **행을 직접 넣는다.** 우산은 2026-09-16 에 물러났고, 그 뒤로 화면·API 로는 새로 켤 수 없다. + * 지켜야 하는 것은 「옛날에 켜 둔 계정이 지금도 다섯 화면을 쓴다」이므로, 옛날 행을 흉내 낸다. + */ + await prisma!.accessControl.upsert({ + where: { subject_type_subject_ref_module_code: { subject_type: 'user', subject_ref: ADMIN, module_code: 'basedata.manage' } }, + update: { enabled: true }, + create: { subject_type: 'user', subject_ref: ADMIN, module_code: 'basedata.manage', enabled: true }, + }); expect((await request(app).get('/api/v1/holidays/admin?year=2026').set('Cookie', admin)).status).toBe(200); expect((await request(app).get('/api/v1/quotes/maker-prices/ORG_BRAIN').set('Cookie', admin)).status).toBe(200); await prisma!.accessControl.deleteMany({ where: { subject_ref: ADMIN, module_code: 'basedata.manage' } }); diff --git a/backend/src/routes/access-control.ts b/backend/src/routes/access-control.ts index a832af3..c25008d 100644 --- a/backend/src/routes/access-control.ts +++ b/backend/src/routes/access-control.ts @@ -5,6 +5,7 @@ import { prisma } from '../lib/prisma.js'; import { LOCK_MODULE, mergePermissions } from '../lib/permissions.js'; import { rolesOf, type Role } from '@buildup-ev/shared/types'; import { isPresetCode } from '@buildup-ev/shared/rbac/presets'; +import { isRetired } from '@buildup-ev/shared/rbac/modules'; export const accessControlRouter = Router(); @@ -30,6 +31,15 @@ accessControlRouter.post('/', rbac('ADMIN'), requirePermission('account.manage') if (!['role', 'user', 'preset'].includes(subject_type)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '알 수 없는 대상입니다' } }); return; } + /* + * **물러난 모듈은 새로 켜지 못한다**(2026-09-16 전수조사). + * 켜도 아무 일이 없는 토글을 켜 놓고 「왜 안 되지」를 찾게 된다. + * 이미 켜 둔 기록은 건드리지 않는다 — **끄는 것은 언제나 된다.** + * (아예 없는 코드는 여기서 막지 않는다 — 외래키가 막고, DB 에만 있는 모듈도 끌 수는 있어야 한다) + */ + if (isRetired(module_code) && enabled === true) { + res.status(400).json({ error: { code: 'MODULE_RETIRED', message: '더 이상 쓰지 않는 기능모듈입니다.' } }); return; + } /* * **역할 프리셋 구성**(2026-09-16) — 기능모듈 화면에서 자리마다 켜고 끈다. * 마스터 자리는 손대지 않는다: 전부 켜져 있어야 시스템 주인이 스스로를 잠그지 않는다. diff --git a/backend/src/routes/feature-modules.ts b/backend/src/routes/feature-modules.ts index 8516dad..982dc4a 100644 --- a/backend/src/routes/feature-modules.ts +++ b/backend/src/routes/feature-modules.ts @@ -1,17 +1,31 @@ import { Router } from 'express'; import { rbac } from '../middleware/rbac.js'; import { prisma } from '../lib/prisma.js'; +import { MODULE_BY_CODE } from '@buildup-ev/shared/rbac/modules'; export const featureModulesRouter = Router(); +/** + * 기능모듈 목록 — **이름·설명·묶음은 카탈로그(shared/rbac/modules)에서** 온다. + * + * DB 의 `feature_module` 은 권한 행이 가리키는 **자리**일 뿐이다. 이름을 DB 에 두면 + * 화면 글자를 고치려고 마이그레이션을 써야 하고, 실제로 이름과 설명이 세 곳으로 갈라져 + * 서로 어긋났다(2026-09-16 전수조사). + * + * 물러난 모듈(`retired`)은 내려보내지 않는다 — 켜고 끌 수 없는 것이 목록에 있으면 + * 「이걸 켜면 뭐가 되나」를 계속 묻게 된다. + */ featureModulesRouter.get('/', rbac('ADMIN'), async (_req, res): Promise => { if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; } - const modules = await prisma.featureModule.findMany({ - where: { active: true }, - orderBy: { sort_order: 'asc' }, - }); - res.json({ data: modules }); + const rows = await prisma.featureModule.findMany({ select: { code: true } }); + const data = rows + .map(r => MODULE_BY_CODE[r.code]) + .filter((m): m is NonNullable => !!m && !m.retired) + // 카탈로그에 적힌 차례대로 — 번호를 따로 매기지 않는다(매기면 겹친다) + .sort((a, b) => Object.keys(MODULE_BY_CODE).indexOf(a.code) - Object.keys(MODULE_BY_CODE).indexOf(b.code)) + .map(m => ({ code: m.code, name: m.name, desc: m.desc, group: m.group, surface: m.surfaces.join(',') })); + res.json({ data }); }); diff --git a/db/seed/access_control.csv b/db/seed/access_control.csv index 96faa7e..e2effe4 100644 --- a/db/seed/access_control.csv +++ b/db/seed/access_control.csv @@ -8,7 +8,6 @@ role,SALES,doc.send.sign,Y, role,SALES,stats.own,Y, role,ADMIN,quote.confirm,Y, role,ADMIN,quote.edit,Y, -role,ADMIN,quote.delete,N,계정별로 켠다 role,ADMIN,order.confirm,Y, role,ADMIN,order.view,Y, role,ADMIN,order.control,N,계정별로 켠다 @@ -16,24 +15,20 @@ role,ADMIN,order.remove,Y, role,ADMIN,doc.view,Y, role,ADMIN,doc.send.email,Y, role,ADMIN,doc.send.sign,Y, -role,ADMIN,notify.push,Y, role,ADMIN,addon.manage,Y, role,ADMIN,stats.own,Y, role,ADMIN,stats.all,Y, -role,ADMIN,basedata.manage,N,계정별로 켠다 role,ADMIN,account.manage,N,계정별로 켠다 role,ADMIN,checklist.manage,N,계정별로 켠다 role,MAKER,order.view,Y, role,MAKER,order.control,Y, role,MAKER,doc.view,Y, -role,MAKER,notify.push,Y, role,ADMIN,customer.view,Y,프리셋 도입 전과 같게 — 관리자 기본 preset,sales_mgr,addon.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,customer.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,doc.send.email,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,doc.send.sign,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,doc.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,sales_mgr,notify.push,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,order.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,order.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,sales_mgr,quote.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 @@ -47,7 +42,6 @@ preset,pm,basedata.optiondb,Y,프리셋 기본값 — 기능모듈 화면에서 preset,pm,basedata.weights,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,pm,checklist.manage,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,pm,doc.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,pm,notify.push,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,pm,order.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,pm,order.control,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,pm,order.remove,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 @@ -60,7 +54,6 @@ preset,prod_mgr,addon.manage,Y,프리셋 기본값 — 기능모듈 화면에서 preset,prod_mgr,basedata.makerprice,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,prod_mgr,checklist.manage,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,prod_mgr,doc.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,prod_mgr,notify.push,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,prod_mgr,order.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,prod_mgr,order.control,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,prod_mgr,order.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 @@ -70,7 +63,6 @@ preset,prod_mgr,stats.all,Y,프리셋 기본값 — 기능모듈 화면에서 preset,prod_mgr,stats.own,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,exec,addon.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,exec,doc.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,exec,notify.push,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,exec,order.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,exec,quote.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,exec,quote.edit,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 @@ -82,7 +74,6 @@ preset,master,addon.view,Y,프리셋 기본값 — 기능모듈 화면에서 고 preset,master,basedata.dims,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,basedata.holiday,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,basedata.makerprice,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,master,basedata.manage,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,basedata.optiondb,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,basedata.weights,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,checklist.manage,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 @@ -90,15 +81,12 @@ preset,master,customer.view,Y,프리셋 기본값 — 기능모듈 화면에서 preset,master,doc.send.email,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,doc.send.sign,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,doc.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,master,notify.assign,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,master,notify.push,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,order.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,order.control,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,order.remove,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,order.view,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,quote.confirm,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,quote.create,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 -preset,master,quote.delete,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,quote.edit,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,stats.all,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 preset,master,stats.own,Y,프리셋 기본값 — 기능모듈 화면에서 고친다 diff --git a/db/seed/feature_module.csv b/db/seed/feature_module.csv index e4decba..8612c71 100644 --- a/db/seed/feature_module.csv +++ b/db/seed/feature_module.csv @@ -1,29 +1,29 @@ code,name,surface,sort_order,active -quote.create,견적 생성,영업,1,Y -quote.confirm,견적서 확정·생성,"영업,관리자",2,Y -quote.edit,견적 수정·복제,"영업,관리자",3,Y -quote.delete,견적 삭제,관리자,4,Y -doc.send.email,메일 발송,"영업,관리자",5,Y -doc.send.sign,전자서명 발송,"영업,관리자",6,Y -order.confirm,주문 전환·배정,관리자,7,Y -order.view,주문 진행 조회,"영업,관리자,특장사",8,Y -order.control,주문 상태 변경,"관리자,특장사",9,Y -doc.view,구조변경 서류 조회,"관리자,특장사",10,Y -notify.assign,제작 배정 알림 메일(구 — 역할 프리셋이 대신한다),관리자,10,N -notify.push,앱 알림,공통,11,N -order.remove,주문 치우기(관리자),관리자,11,Y -stats.own,내 실적 조회,"영업,관리자",11,Y -stats.all,전체 실적 조회,관리자,12,Y -basedata.manage,옵션DB·무게상수 관리,관리자,13,Y -account.manage,계정 관리,관리자,14,Y -checklist.manage,체크리스트 서식 관리,관리자,15,Y -addon.manage,부가작업 관리,관리자,12,Y -customer.view,고객 목록,관리자,16,Y -basedata.weights,무게상수 관리,관리자,17,Y -basedata.dims,치수 프리셋 관리,관리자,18,Y +quote.create,견적 만들기,영업,1,Y +quote.edit,견적 고치기,"영업,관리자",2,Y +quote.confirm,견적 확정,"영업,관리자",3,Y +doc.send.email,견적서·계약서 메일 발송,"영업,관리자",4,Y +doc.send.sign,전자서명 요청,"영업,관리자",5,Y +doc.view,구조변경 서류 보기,"관리자,특장사",6,Y +customer.view,고객·서류함 보기,관리자,7,Y +order.view,주문 진행 보기,"영업,관리자,특장사",8,Y +order.confirm,제작 배정,관리자,9,Y +order.control,제작 단계 진행,"관리자,특장사",10,Y +order.remove,주문 치우기,관리자,11,Y +addon.view,부가작업 보기,관리자,12,Y +addon.manage,부가작업 진행,관리자,13,Y +checklist.manage,체크리스트 서식 관리,관리자,14,Y +stats.own,내 실적 보기,"영업,관리자",15,Y +stats.all,전체 실적 보기,관리자,16,Y +pnl.view,손익 보기,관리자,17,Y +pnl.manage,손익 입력,관리자,18,Y basedata.optiondb,옵션DB 관리,관리자,19,Y -basedata.makerprice,특장사 단가 관리,관리자,20,Y -basedata.holiday,공휴일 관리,관리자,21,Y -addon.view,부가작업 조회,관리자,22,Y -pnl.view,손익 조회,관리자,23,Y -pnl.manage,손익 관리,관리자,24,Y +basedata.weights,무게상수 관리,관리자,20,Y +basedata.dims,치수 프리셋 관리,관리자,21,Y +basedata.makerprice,특장사 단가 관리,관리자,22,Y +basedata.holiday,공휴일 관리,관리자,23,Y +account.manage,계정·권한 관리,관리자,24,Y +quote.delete,견적 삭제,관리자,25,N +notify.assign,제작 배정 알림 메일,관리자,26,N +notify.push,앱 알림,"영업,관리자,특장사",27,N +basedata.manage,기준데이터 관리(옛 우산),관리자,28,N diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index bb0649a..6ac31ee 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -16,6 +16,59 @@ export const EN: Record = { '명': '', '건': '', '예정': 'Planned', + '주문·제작': 'Orders & build', + '성과·손익': 'Performance & P&L', + '기준데이터': 'Reference data', + '운영': 'Operations', + '견적 만들기': 'Create quotes', + '견적 고치기': 'Edit quotes', + '견적 확정': 'Finalise quotes', + '전자서명 요청': 'Request e-signature', + '구조변경 서류 보기': 'View conversion documents', + '고객·서류함 보기': 'View customers & document folders', + '주문 진행 보기': 'View order progress', + '제작 단계 진행': 'Run build steps', + '주문 치우기': 'Hide orders', + '부가작업 보기': 'View add-on work', + '체크리스트 서식 관리': 'Manage checklist templates', + '내 실적 보기': 'View my performance', + '전체 실적 보기': 'View all performance', + '손익 보기': 'View P&L', + '손익 입력': 'Enter P&L', + '옵션DB 관리': 'Manage option database', + '무게상수 관리': 'Manage weight constants', + '치수 프리셋 관리': 'Manage dimension presets', + '특장사 단가 관리': 'Manage upfitter rates', + '공휴일 관리': 'Manage public holidays', + '계정·권한 관리': 'Manage accounts & permissions', + '기준데이터 관리(옛 우산)': 'Reference data (legacy umbrella)', + '제작 배정 알림 메일': 'Assignment alert email', + '앱 알림': 'App notifications', + '컨피규레이터에서 새 견적을 만든다': 'Create a new quote in the configurator', + '저장된 견적의 옵션·고객정보를 고치고 복제한다': 'Edit and duplicate a saved quote’s options and customer details', + '임시저장을 확정해 견적번호를 매기고 견적서를 낸다': 'Finalise a draft, assign a quote number and issue the quote', + '고객에게 PDF 를 메일로 보낸다': 'Email the PDFs to the customer', + '계약서에 서명을 요청하고 서명본을 받는다': 'Request a signature on the contract and receive the signed copy', + '하중계산서·제원대비표·작업지시서를 열어 보고 내려받는다': 'Open and download the load calculation, spec comparison and work order', + '남의 고객까지 본다 (영업은 이 권한 없이도 자기 고객을 본다)': 'See other reps’ customers (sales see their own without this)', + '주문 현황판과 주문 상세를 본다': 'See the order board and order details', + '계약된 건을 특장사에 배정하고, 배정을 거두거나 돌려보낸다': 'Assign signed orders to an upfitter, withdraw or reject a request', + '단계를 완료·되돌리고 납기와 차량 도착 예정일을 고친다': 'Complete or undo steps and change the due and vehicle arrival dates', + '잘못 만든 주문을 목록에서 감춘다 (기록은 지워지지 않는다)': 'Hide a mistaken order from the list (the record is kept)', + '특장사 출고 뒤 고객 인도까지의 진행을 본다': 'See progress from factory release to customer handover', + '부가작업 단계를 완료하고 고객 인도 목표일·인도일을 적는다': 'Complete add-on steps and record handover target and actual dates', + '단계마다 확인할 항목을 만들고 고친다': 'Create and edit the items checked at each step', + '내가 맡은 건의 깔때기·금액·소요일': 'Funnel, amounts and lead times for my own deals', + '모든 영업의 실적과 계정별 비교': 'All reps’ performance and per-account comparison', + '판매건별 매출·원가·수익 (보기 전용)': 'Revenue, cost and profit per sale (read-only)', + '세금계산서 발행일·입금·원가를 적고 줄을 삭제한다': 'Record invoice dates, payments and costs, and void rows', + '고객 견적에 쓰는 옵션 단가·보조금·세율': 'Option prices, subsidies and tax rates used in customer quotes', + '하중계산에 쓰는 상수': 'Constants used in load calculation', + '사양별 튜닝 후 치수': 'Post-conversion dimensions by specification', + '우리가 특장사에 지급하는 값 (고객 견적가와 다른 축이다)': 'What we pay the upfitter (a different axis from the customer price)', + '납기를 영업일로 셀 때 쓰는 기준': 'The basis for counting due dates in business days', + '계정을 만들고 역할·자리(프리셋)·기능모듈을 켠다': 'Create accounts and switch on roles, presets and feature modules', + '쓰지 않는다': 'Not in use', '수익률': 'Margin', '삭제됨': 'Voided', '줄은 지워지지 않습니다. 회색으로 남고 합계에서만 빠지며, 사유가 그 줄 위에 적힙니다.': 'The row is not deleted. It stays greyed out, drops out of the totals, and the reason is shown over it.', diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 02f148b..8a3f047 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -61,60 +61,40 @@ const ROLES: Role[] = ['SALES', 'ADMIN', 'MAKER'] const ROLE_KO: Record = { SALES: '영업', ADMIN: '관리자', MAKER: '특장사' } const ROLE_SURFACE: Record = { SALES: '영업', ADMIN: '관리자', MAKER: '특장사' } +/** + * 그 역할 화면에 있는 모듈. **순서는 서버가 준 차례 그대로**다 — + * 카탈로그(shared/rbac/modules)에 적힌 순서이고, 번호를 따로 매기지 않는다(매기면 겹친다). + */ function getModulesForRole(modules: FeatureModule[], role: Role): FeatureModule[] { const surface = ROLE_SURFACE[role] - return modules - .filter(m => m.surface.split(',').map(s => s.trim()).includes(surface)) - .sort((a, b) => a.sort_order - b.sort_order) + return modules.filter(m => m.surface.split(',').map(s => s.trim()).includes(surface)) } /** 겸직 계정 — 가진 역할들의 모듈을 합쳐서 본다(같은 모듈이 두 번 나오지 않게 코드로 묶는다). */ function getModulesForRoles(modules: FeatureModule[], roles: Role[]): FeatureModule[] { const seen = new Map() - for (const r of roles) for (const m of getModulesForRole(modules, r)) seen.set(m.code, m) - return [...seen.values()].sort((a, b) => a.sort_order - b.sort_order) + // 목록 차례를 지키려면 **원본 순서**로 돌면서 걸러야 한다(역할별로 돌면 순서가 섞인다) + const allowed = new Set(roles.flatMap(r => getModulesForRole(modules, r)).map(m => m.code)) + for (const m of modules) if (allowed.has(m.code)) seen.set(m.code, m) + return [...seen.values()] +} + +/** + * 묶음이 바뀌는 자리에 **구분 줄**을 끼운다 — 모듈이 스물넷이라 한 덩어리로 늘어놓으면 + * 무엇이 어디쯤 있는지 알 수 없다. 묶음은 카탈로그(shared/rbac/modules)가 정한다. + */ +function GroupRow({ mods, i, span }: { mods: FeatureModule[]; i: number; span: number }) { + const cur = mods[i]?.group + if (!cur || (i > 0 && mods[i - 1]?.group === cur)) return null + return {t(cur)} } + const QUOTE_STATUS_LABELS: Record = { draft: '임시저장', confirmed: '견적 완료', contracted: '계약 완료', assigned: '배정 완료', ordered: '주문 진행', completed: '완료', expired: '만료', } -/** - * 기능모듈 설명 — **DB 의 feature_module 코드와 짝이 맞아야 한다.** - * 예전에는 존재하지 않는 코드(kanban.*·admin.*·document.generate)를 적어 두고 - * 정작 실재하는 여러 모듈에는 설명이 없었다 — 켜고 끄는 사람이 무엇을 켜는지 알 수 없다. - */ -const MODULE_DESC: Record = { - 'quote.create': '견적 작성 및 저장', - 'quote.edit': '견적 수정·복제', - 'quote.delete': '견적 삭제', - 'quote.confirm': '견적서 확정·생성', - 'order.confirm': '주문 전환 및 특장사 배정', - 'order.view': '주문 진행 조회', - 'order.control': '주문 단계 완료·증빙 등록 (끄면 조회만)', - 'doc.view': '구조변경 서류 조회', - 'doc.send.email': '견적서·계약서 메일 발송', - 'doc.send.sign': '전자서명 발송', - 'account.manage': '계정 발급 및 권한 관리', - 'basedata.manage': '옵션DB·무게상수 관리', - 'addon.manage': '부가작업 진행 · 고객 인도 목표일 (특장사 출고 뒤 우리 쪽 작업)', - 'addon.view': '부가작업 조회 (보기 전용 — 진행은 「부가작업 진행」)', - 'stats.own': '내 실적 조회', - 'stats.all': '전체 실적 조회', - 'customer.view': '고객 목록·서류함 조회', - 'checklist.manage': '체크리스트 서식 만들기·고치기 (단계별 확인 항목)', - 'basedata.weights': '무게상수 관리 (하중 계산에 쓰는 상수)', - 'basedata.dims': '치수 프리셋 관리 (사양별 튜닝 후 치수)', - 'basedata.optiondb': '옵션DB 관리 (고객 견적 단가·보조금·세율)', - 'basedata.makerprice': '특장사 단가 관리 (우리가 특장사에 지급하는 값)', - 'basedata.holiday': '공휴일 관리 (납기 영업일 계산 기준)', - 'order.remove': '주문 치우기 — 목록에서 감춘다(행은 남는다)', - 'notify.push': '앱 알림 받기 (알림함·휴대폰 팝업)', - 'notify.assign': '구 「제작 배정 알림 메일」 — 이제 역할 프리셋이 정한다(쓰지 않음)', - 'pnl.view': '손익 조회 — 판매건별 매출·원가·수익 (보기 전용)', - 'pnl.manage': '손익 입력 — 세금계산서 발행일·입금·원가를 적는다', -} type TabKey = 'home' | 'quotes' | 'customers' | 'perf' | 'pnl' | 'kanban' | 'files' | 'toggles' | 'accounts' | 'weights' | 'dims' | 'optiondb' | 'makerprice' | 'checklist' | 'holidays' function fmtPrice(n: number) { return n ? `₩${n.toLocaleString()}` : '—' } @@ -794,7 +774,7 @@ function AccountsTab() { {user.is_master ? t('마스터 — 전체 모듈') : `계정 모듈 override — ${presetOf(user) ? `${PRESET_BY_CODE[presetOf(user)!]!.label} 프리셋` : myRoles.map(r => ROLE_KO[r]).join(' + ')} 기준`}
- {(user.is_master ? modules : getModulesForRoles(modules, myRoles)).map(mod => { + {(user.is_master ? modules : getModulesForRoles(modules, myRoles)).map((mod, i, arr) => { /* * 겸직이면 역할 중 하나라도 켜 두었으면 켜진 것 — 서버 판정(mergePermissions)과 같다. * **프리셋이 있으면 프리셋이 역할 기본값을 덮는다**(2026-09-16) — 화면도 서버와 같은 순서로 본다. @@ -808,9 +788,14 @@ function AccountsTab() { const userOverride = ac.find(a => a.subject_type === 'user' && a.subject_ref === user.email && a.module_code === mod.code) const effective = userOverride !== undefined ? userOverride.enabled : roleEnabled const hasOverride = userOverride !== undefined + const newGroup = i === 0 || arr[i - 1]?.group !== mod.group return ( -
+ + {/* 묶음이 바뀌면 줄을 하나 끼운다 — 스물넷이 한 덩어리면 무엇이 어디쯤인지 알 수 없다 */} + {newGroup &&
{t(mod.group)}
} +
{t(mod.name)}
+ {mod.desc &&
{t(mod.desc)}
}
{mod.code}
{hasOverride @@ -825,6 +810,7 @@ function AccountsTab() { {effective ? 'ON' : 'OFF'}
+ ) })}
@@ -2248,16 +2234,16 @@ export function AdminPage() { - {getModulesForRole(modules, 'ADMIN').map(mod => { + {getModulesForRole(modules, 'ADMIN').map((mod, i, arr) => { const enabled = isEnabled(ac, 'preset', preset.code, mod.code) const key = `preset:${preset.code}:${mod.code}` return ( - + + +
{t(mod.name)}
- {MODULE_DESC[mod.code] && MODULE_DESC[mod.code] !== mod.name && ( -
{t(MODULE_DESC[mod.code]!)}
- )} + {mod.desc &&
{t(mod.desc)}
}
{mod.code}
@@ -2270,6 +2256,7 @@ export function AdminPage() { +
) })} @@ -2290,18 +2277,18 @@ export function AdminPage() { - {roleMods.map(mod => { + {roleMods.map((mod, i, arr) => { const enabled = isEnabled(ac, 'role', role, mod.code) const key = `${role}:${mod.code}` return ( - + + +
{t(mod.name)}
{/* 설명은 **글로 보여 준다**(2026-09-16) — 툴팁은 휴대폰에서 뜨지 않아 무엇을 켜는지 알 수 없었다. 이름과 같은 말이면 두 번 적지 않는다 */} - {MODULE_DESC[mod.code] && MODULE_DESC[mod.code] !== mod.name && ( -
{t(MODULE_DESC[mod.code]!)}
- )} + {mod.desc &&
{t(mod.desc)}
}
{mod.code}
@@ -2314,6 +2301,7 @@ export function AdminPage() { +
) })} @@ -2384,6 +2372,11 @@ const styles: Record = { tdModule: { padding: '10px 12px', borderBottom: '0.5px solid var(--line)' }, tdToggle: { textAlign: 'center' as const, padding: '10px 12px', borderBottom: '0.5px solid var(--line)' }, modName: { fontSize: 13, color: 'var(--dark)' }, + /** 기능모듈 표의 묶음 줄 — 견적·서류·주문·제작 … 으로 스물넷을 갈라 놓는다 */ + modGroupRow: { + padding: '12px 8px 3px', fontSize: 'var(--fs-caption)', color: 'var(--muted)', + fontWeight: 700, letterSpacing: '.02em', borderBottom: 'var(--hairline)', + }, modCode: { fontSize: 11, color: 'var(--muted)', marginTop: 2 }, // 높이·폭은 표 안 버튼과 같은 값으로 — 한 화면에서 버튼 크기가 갈리지 않게 toggleOn: { ...BTN.smPrimary, background: 'var(--lime)', color: 'var(--dark)', border: 'none', fontWeight: 700 }, @@ -2729,6 +2722,8 @@ const acc: Record = { moduleGrid: { display: 'flex', flexWrap: 'wrap' as const, gap: 8 }, moduleItem: { background: '#fff', border: '0.5px solid var(--line)', borderRadius: 8, padding: '8px 12px', minWidth: 140, display: 'flex', flexDirection: 'column', gap: 3 }, modName: { fontSize: 12, fontWeight: 600, color: 'var(--dark)' }, + modGroup: { gridColumn: '1 / -1', padding: '10px 2px 2px', fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontWeight: 700 }, + modDesc: { gridColumn: '1 / -1', fontSize: 'var(--fs-caption)', color: 'var(--muted)', lineHeight: 1.5 }, modCode: { fontSize: 10, color: 'var(--muted)' }, modMeta: { marginBottom: 4 }, overrideTag: { fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 6, background: 'var(--warnbg)', color: 'var(--warn)' }, diff --git a/shared/package.json b/shared/package.json index a84d523..9370c75 100644 --- a/shared/package.json +++ b/shared/package.json @@ -19,6 +19,7 @@ "./process/addon": "./process/addon.ts", "./process/actual": "./process/actual.ts", "./rbac/presets": "./rbac/presets.ts", + "./rbac/modules": "./rbac/modules.ts", "./finance/pnl": "./finance/pnl.ts" }, "scripts": { diff --git a/shared/rbac/modules.ts b/shared/rbac/modules.ts new file mode 100644 index 0000000..0921ce1 --- /dev/null +++ b/shared/rbac/modules.ts @@ -0,0 +1,125 @@ +/** + * **기능모듈 한 벌** — 코드·이름·설명·묶음이 **여기 한 곳**에 있다(2026-09-16 전수조사). + * + * 전에는 세 곳에 흩어져 있었다 — 코드는 라우트에, 이름은 DB 시드에, 설명은 화면 상수에. + * 그래서 이런 것들이 쌓였다. + * · 아무 데서도 검사하지 않는 모듈이 목록에 남아 켜고 끌 수 있었다(견적 삭제·앱 알림·배정 알림 메일) + * · 같은 뜻의 모듈이 둘이었다(옛 우산 `basedata.manage` 와 새로 쪼갠 다섯) + * · 정렬 번호가 셋씩 겹쳐 목록 순서가 뜰 때마다 달라졌다 + * · 이름이 실제로 하는 일과 어긋났다(「고객 목록」인데 서류함까지, 「주문 상태 변경」인데 제작 단계 진행) + * + * ⚠️ **새 모듈을 만들면 여기에 한 줄을 더한다.** 목록 순서는 이 배열 순서다(번호를 따로 매기지 않는다 — + * 번호를 매기면 겹친다). 시드(`db/seed/feature_module.csv`)는 이 표를 비추기만 하고, + * `permission-catalog.test.ts` 가 둘이 어긋나지 않게 지킨다. + */ + +/** 어느 화면의 기능인가 — 목록에서 눈으로 가르는 용도다(권한 판정에는 쓰지 않는다) */ +export type ModuleSurface = '영업' | '관리자' | '특장사'; + +/** 목록에서 줄을 나누는 묶음 */ +export type ModuleGroup = '견적' | '서류' | '고객' | '주문·제작' | '성과·손익' | '기준데이터' | '운영'; + +export interface ModuleDef { + code: string; + /** 짧은 이름 — **실제로 하는 일**로 적는다 */ + name: string; + /** 한 줄 설명 — 켜면 무엇을 할 수 있는지. 이름을 늘여 쓰지 말 것 */ + desc: string; + surfaces: ModuleSurface[]; + group: ModuleGroup; + /** + * 물러난 모듈 — **목록에 뜨지 않는다.** 왜 물러났는지를 값으로 적는다. + * 행은 지우지 않는다(이미 켜 둔 계정의 권한 기록을 잃지 않기 위해). 새로 켤 수는 없다. + */ + retired?: string; +} + +export const MODULES: ModuleDef[] = [ + // ── 견적 ──────────────────────────────────────────────────────────────── + { code: 'quote.create', group: '견적', surfaces: ['영업'], + name: '견적 만들기', desc: '컨피규레이터에서 새 견적을 만든다' }, + { code: 'quote.edit', group: '견적', surfaces: ['영업', '관리자'], + name: '견적 고치기', desc: '저장된 견적의 옵션·고객정보를 고치고 복제한다' }, + { code: 'quote.confirm', group: '견적', surfaces: ['영업', '관리자'], + name: '견적 확정', desc: '임시저장을 확정해 견적번호를 매기고 견적서를 낸다' }, + + // ── 서류 ──────────────────────────────────────────────────────────────── + { code: 'doc.send.email', group: '서류', surfaces: ['영업', '관리자'], + name: '견적서·계약서 메일 발송', desc: '고객에게 PDF 를 메일로 보낸다' }, + { code: 'doc.send.sign', group: '서류', surfaces: ['영업', '관리자'], + name: '전자서명 요청', desc: '계약서에 서명을 요청하고 서명본을 받는다' }, + { code: 'doc.view', group: '서류', surfaces: ['관리자', '특장사'], + name: '구조변경 서류 보기', desc: '하중계산서·제원대비표·작업지시서를 열어 보고 내려받는다' }, + + // ── 고객 ──────────────────────────────────────────────────────────────── + { code: 'customer.view', group: '고객', surfaces: ['관리자'], + name: '고객·서류함 보기', desc: '남의 고객까지 본다 (영업은 이 권한 없이도 자기 고객을 본다)' }, + + // ── 주문·제작 ─────────────────────────────────────────────────────────── + { code: 'order.view', group: '주문·제작', surfaces: ['영업', '관리자', '특장사'], + name: '주문 진행 보기', desc: '주문 현황판과 주문 상세를 본다' }, + { code: 'order.confirm', group: '주문·제작', surfaces: ['관리자'], + name: '제작 배정', desc: '계약된 건을 특장사에 배정하고, 배정을 거두거나 돌려보낸다' }, + { code: 'order.control', group: '주문·제작', surfaces: ['관리자', '특장사'], + name: '제작 단계 진행', desc: '단계를 완료·되돌리고 납기와 차량 도착 예정일을 고친다' }, + { code: 'order.remove', group: '주문·제작', surfaces: ['관리자'], + name: '주문 치우기', desc: '잘못 만든 주문을 목록에서 감춘다 (기록은 지워지지 않는다)' }, + { code: 'addon.view', group: '주문·제작', surfaces: ['관리자'], + name: '부가작업 보기', desc: '특장사 출고 뒤 고객 인도까지의 진행을 본다' }, + { code: 'addon.manage', group: '주문·제작', surfaces: ['관리자'], + name: '부가작업 진행', desc: '부가작업 단계를 완료하고 고객 인도 목표일·인도일을 적는다' }, + { code: 'checklist.manage', group: '주문·제작', surfaces: ['관리자'], + name: '체크리스트 서식 관리', desc: '단계마다 확인할 항목을 만들고 고친다' }, + + // ── 성과·손익 ─────────────────────────────────────────────────────────── + { code: 'stats.own', group: '성과·손익', surfaces: ['영업', '관리자'], + name: '내 실적 보기', desc: '내가 맡은 건의 깔때기·금액·소요일' }, + { code: 'stats.all', group: '성과·손익', surfaces: ['관리자'], + name: '전체 실적 보기', desc: '모든 영업의 실적과 계정별 비교' }, + { code: 'pnl.view', group: '성과·손익', surfaces: ['관리자'], + name: '손익 보기', desc: '판매건별 매출·원가·수익 (보기 전용)' }, + { code: 'pnl.manage', group: '성과·손익', surfaces: ['관리자'], + name: '손익 입력', desc: '세금계산서 발행일·입금·원가를 적고 줄을 삭제한다' }, + + // ── 기준데이터 ────────────────────────────────────────────────────────── + { code: 'basedata.optiondb', group: '기준데이터', surfaces: ['관리자'], + name: '옵션DB 관리', desc: '고객 견적에 쓰는 옵션 단가·보조금·세율' }, + { code: 'basedata.weights', group: '기준데이터', surfaces: ['관리자'], + name: '무게상수 관리', desc: '하중계산에 쓰는 상수' }, + { code: 'basedata.dims', group: '기준데이터', surfaces: ['관리자'], + name: '치수 프리셋 관리', desc: '사양별 튜닝 후 치수' }, + { code: 'basedata.makerprice', group: '기준데이터', surfaces: ['관리자'], + name: '특장사 단가 관리', desc: '우리가 특장사에 지급하는 값 (고객 견적가와 다른 축이다)' }, + { code: 'basedata.holiday', group: '기준데이터', surfaces: ['관리자'], + name: '공휴일 관리', desc: '납기를 영업일로 셀 때 쓰는 기준' }, + + // ── 운영 ──────────────────────────────────────────────────────────────── + { code: 'account.manage', group: '운영', surfaces: ['관리자'], + name: '계정·권한 관리', desc: '계정을 만들고 역할·자리(프리셋)·기능모듈을 켠다' }, + + // ── 물러난 모듈 ───────────────────────────────────────────────────────── + // 행은 남긴다(이미 켜 둔 계정의 기록을 잃지 않게). 목록에는 안 뜨고 새로 켤 수도 없다. + { code: 'quote.delete', group: '견적', surfaces: ['관리자'], + name: '견적 삭제', desc: '쓰지 않는다', + retired: '삭제 기능을 제품에서 없앴다(2026-08) — 지우지 않고 숨긴다' }, + { code: 'notify.assign', group: '운영', surfaces: ['관리자'], + name: '제작 배정 알림 메일', desc: '쓰지 않는다', + retired: '알림 받을 자리를 역할 프리셋이 정한다(2026-09-16)' }, + { code: 'notify.push', group: '운영', surfaces: ['영업', '관리자', '특장사'], + name: '앱 알림', desc: '쓰지 않는다', + retired: '알림함에는 늘 쌓이고 푸시는 기기에서 허용한다 — 계정 설정으로 가르지 않는다(2026-09-14)' }, + { code: 'basedata.manage', group: '기준데이터', surfaces: ['관리자'], + name: '기준데이터 관리(옛 우산)', desc: '쓰지 않는다', + retired: '기준데이터를 다섯으로 쪼갰다(2026-09-16) — 이미 켜 둔 계정에서만 다섯을 대신한다' }, +]; + +export const MODULE_BY_CODE: Record = Object.fromEntries(MODULES.map(m => [m.code, m])); + +/** 목록에 뜨는 것 — 물러난 모듈은 뺀다. 순서는 이 배열 순서다 */ +export const LIVE_MODULES: ModuleDef[] = MODULES.filter(m => !m.retired); + +/** 물러난 모듈인가 — 프리셋·시드가 새로 켜지 못하게 막을 때 쓴다 */ +export const isRetired = (code: string): boolean => !!MODULE_BY_CODE[code]?.retired; + +/** 묶음 순서 — 화면이 이 순서로 줄을 나눈다 */ +export const MODULE_GROUPS: ModuleGroup[] = ['견적', '서류', '고객', '주문·제작', '성과·손익', '기준데이터', '운영']; diff --git a/shared/rbac/presets.ts b/shared/rbac/presets.ts index 49d50e1..48c6044 100644 --- a/shared/rbac/presets.ts +++ b/shared/rbac/presets.ts @@ -47,7 +47,8 @@ export const TAB_MODULE = { } as const; /** 관리자 계정이면 프리셋과 무관하게 갖는 것 — 견적 목록·서류 조회처럼 관리 업무의 바탕 */ -const BASE = ['quote.confirm', 'quote.edit', 'order.view', 'doc.view', 'stats.own', 'notify.push']; +// ⚠️ 물러난 모듈(shared/rbac/modules 의 retired)은 넣지 않는다 — 아무 데서도 검사하지 않는다 +const BASE = ['quote.confirm', 'quote.edit', 'order.view', 'doc.view', 'stats.own']; export const PRESETS: PresetDef[] = [ { @@ -83,10 +84,10 @@ export const PRESETS: PresetDef[] = [ label: '마스터', desc: '전부 — 공휴일·계정 관리·기능모듈까지', modules: [ - ...BASE, 'customer.view', 'stats.all', 'quote.create', 'quote.delete', 'doc.send.email', 'doc.send.sign', - 'order.confirm', 'order.control', 'order.remove', 'addon.manage', 'checklist.manage', + ...BASE, 'customer.view', 'stats.all', 'quote.create', 'doc.send.email', 'doc.send.sign', + 'order.confirm', 'order.control', 'order.remove', 'addon.view', 'addon.manage', 'checklist.manage', 'basedata.weights', 'basedata.dims', 'basedata.optiondb', 'basedata.makerprice', 'basedata.holiday', - 'basedata.manage', 'account.manage', 'notify.assign', 'addon.view', 'pnl.view', 'pnl.manage', + 'account.manage', 'pnl.view', 'pnl.manage', ], }, ]; diff --git a/shared/types/index.ts b/shared/types/index.ts index 4426cf7..5863fa3 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -199,13 +199,19 @@ export function rolesOf(u: { role: Role; extra_roles?: Role[] | null; is_master? } /** F3. 기능 모듈 카탈로그 */ +/** + * 기능모듈 한 칸 — **이름·설명·묶음은 카탈로그(shared/rbac/modules)에서** 온다. + * DB 의 `feature_module` 은 권한 행이 가리키는 자리일 뿐이라, 화면에 내려오는 것은 카탈로그 쪽이다. + */ export interface FeatureModule { code: string; name: string; + /** 한 줄 설명 — 켜면 무엇을 할 수 있는지 */ + desc: string; + /** 목록에서 줄을 나누는 묶음(견적·서류·주문·제작 …) */ + group: string; /** 콤마 구분 (예: '영업,관리자') */ surface: string; - sort_order: number; - active: boolean; } /** F4. 권한 토글 (역할 기본값 + 계정 override) */