From 3baa569fca61bc9dd34f211bee6ad2fb8ec02e3d Mon Sep 17 00:00:00 2001 From: OziinG Date: Tue, 13 Oct 2026 03:17:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(=EA=B2=AC=EC=A0=81):=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=9E=90=20=EA=B5=AC=EB=B6=84=EC=97=90=20=EB=A9=B4=EC=84=B8?= =?UTF-8?q?=EC=82=AC=EC=97=85=EC=9E=90=20=EC=B6=94=EA=B0=80=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=82=AC=EC=97=85=EC=9E=90=20=ED=98=9C?= =?UTF-8?q?=ED=83=9D,=20=EB=B6=80=EA=B0=80=EC=84=B8=20=ED=99=98=EA=B8=89?= =?UTF-8?q?=EB=A7=8C=200=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BizType/BusinessType 에 vat_exempt(면세사업자) 추가 - 환급 불가 목록(NO_VAT_REFUND_BIZ_TYPES)에 포함 — 서버·화면·PDF 가 같은 판정을 따른다 - 택배 보조금(개인사업자 혜택) 판정을 isIndividualBenefit() 로 모아 면세사업자 포함 - 드롭다운·견적서 PDF·고객정보 조회·영문 라벨 추가 - 회귀 테스트: 면세사업자 실구매가 = 같은 조건 개인사업자 + 부가세액 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014j937nqwhjragHewwR6TMc --- .../vat-refund-single-source.test.ts | 7 +++ backend/src/routes/quotes.ts | 2 +- backend/src/services/quote-calc.ts | 5 +- backend/src/services/quote-pdf.ts | 4 +- frontend/src/api/quotes.ts | 2 +- frontend/src/components/CustomerViewModal.tsx | 2 +- frontend/src/components/SubsidyInputs.tsx | 1 + frontend/src/i18n/en.ts | 1 + frontend/src/lib/liveQuote.ts | 4 +- frontend/src/lib/quoteCustomer.ts | 4 +- shared/pricing/core.ts | 6 +-- shared/pricing/types.ts | 7 ++- shared/pricing/vat-refund.test.ts | 49 ++++++++++++++++++- shared/pricing/vat-refund.ts | 13 ++++- shared/types/index.ts | 7 ++- 15 files changed, 94 insertions(+), 20 deletions(-) diff --git a/backend/src/__tests__/vat-refund-single-source.test.ts b/backend/src/__tests__/vat-refund-single-source.test.ts index 228498aa..8ab64b9d 100644 --- a/backend/src/__tests__/vat-refund-single-source.test.ts +++ b/backend/src/__tests__/vat-refund-single-source.test.ts @@ -43,5 +43,12 @@ describe('부가세 환급 판정의 단일 출처', () => { const rule = codeOnly(read('shared/pricing/vat-refund.ts')); expect(rule).toMatch(/'simplified'/); expect(rule).toMatch(/'consumer'/); + expect(rule).toMatch(/'vat_exempt'/); + }); + + it('🔴 택배(개인사업자 혜택) 판정도 isIndividualBenefit() 를 거친다 — 면세사업자가 빠지지 않게', () => { + for (const f of BUILDERS) { + expect(codeOnly(read(f)), `${f}`).toMatch(/is_individual:\s*isIndividualBenefit\(/); + } }); }); diff --git a/backend/src/routes/quotes.ts b/backend/src/routes/quotes.ts index 3e09428c..9b63615e 100644 --- a/backend/src/routes/quotes.ts +++ b/backend/src/routes/quotes.ts @@ -211,7 +211,7 @@ async function buildParams( const trim_price = bodyOnly ? 0 : rawTrim; const bizType = (customer?.biz_type ?? 'individual') as - 'individual' | 'corporation' | 'simplified' | 'consumer'; + 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer'; // 지방보조금 미적용: 관리자 DB 토글(active=false) 또는 견적별 영업 토글 const localOff = extra?.local_subsidy_off === true || subsidyLoc?.active === false; diff --git a/backend/src/services/quote-calc.ts b/backend/src/services/quote-calc.ts index 6f736854..5ca9c3a3 100644 --- a/backend/src/services/quote-calc.ts +++ b/backend/src/services/quote-calc.ts @@ -9,6 +9,7 @@ import { dieselDeducts, toDieselStatus, type QuoteParams, resolveCarPrice, noVatRefund, + isIndividualBenefit, readCustomOptions, type CustomOption, bodyOnlyParams, vehicleOnlyParams, @@ -140,7 +141,7 @@ export async function buildQuoteParams( const zeroed = extra?.promotion_zeroed ?? []; const { trim_price, option_sum } = assembleOptionSum(selections, price, zeroed, extra?.custom_options ?? []); const bizType = (customer?.biz_type ?? 'individual') as - 'individual' | 'corporation' | 'simplified' | 'consumer'; + 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer'; // 지방보조금 미적용: 관리자 DB 토글(subsidy_local.active=false) 또는 견적별 영업 토글 const localOff = extra?.local_subsidy_off === true || subsidyLoc?.active === false; @@ -160,7 +161,7 @@ export async function buildQuoteParams( no_vat_refund: noVatRefund(bizType), is_sosang: customer?.is_sosang ?? false, sosang_rate: subsidyNat?.sosang_rate ? Number(subsidyNat.sosang_rate) : 0.3, - is_individual: bizType === 'individual', + is_individual: isIndividualBenefit(bizType), // 면세사업자도 개인사업자 혜택(택배) has_transport_license: customer?.has_transport_license ?? false, takbae_rate: TAKBAE_RATE, body_price: Math.round(option_sum * 1.1), // I16 VAT포함(옛 0원처리가 반영된 합계) diff --git a/backend/src/services/quote-pdf.ts b/backend/src/services/quote-pdf.ts index 3b2b9d8a..355f9c96 100644 --- a/backend/src/services/quote-pdf.ts +++ b/backend/src/services/quote-pdf.ts @@ -28,7 +28,7 @@ const BODYTYPE_DISP: Record = { BODY_REEFER: '냉장/냉동', BO const TOP_DISP: Record = { TOP_LOW: '저상', TOP_STD: '표준' }; const DOOR_DISP: Record = { DOOR_SWING: '여닫이', DOOR_SLIDE: '슬라이딩', DOOR_EVSLIDE: 'EV미닫이', DOOR_COUPANG: '미닫이', DOOR_FOLD: '양문미닫이' }; const PART_DISP: Record = { PART_NET: '그물망', PART_REEFER: '냉장/냉동 이동식', PART_NONE: 'X' }; -const BIZ_DISP: Record = { individual: '개인사업자', corporation: '법인사업자', simplified: '간이과세자', consumer: '일반구매자' }; +const BIZ_DISP: Record = { individual: '개인사업자', corporation: '법인사업자', simplified: '간이과세자', vat_exempt: '면세사업자', consumer: '일반구매자' }; const ox = (on: boolean) => (on ? 'O' : 'X'); // ── 포맷 ── @@ -225,7 +225,7 @@ export async function generateQuotePdf(quoteId: number): Promise const bodyOnly = inp['body_only'] === true; /** 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다. */ const vehicleOnly = !bodyOnly && inp['vehicle_only'] === true; - /** 일반구매자(비사업자) — 부가세 환급 대상이 아니다 */ + /** 일반구매자·간이과세자·면세사업자 — 부가세 환급 대상이 아니다 */ const noVatRefund = params.no_vat_refund === true; const data = { diff --git a/frontend/src/api/quotes.ts b/frontend/src/api/quotes.ts index 55e4abc9..89f88d5f 100644 --- a/frontend/src/api/quotes.ts +++ b/frontend/src/api/quotes.ts @@ -31,7 +31,7 @@ export interface SaveQuoteRequest extends Partial { ceo_name?: string // 대표이사 — 법인사업자일 때만(계약서 서명블록) email?: string phone?: string - biz_type: 'individual' | 'corporation' | 'simplified' | 'consumer' + biz_type: 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer' is_sosang: boolean region?: string address?: string diff --git a/frontend/src/components/CustomerViewModal.tsx b/frontend/src/components/CustomerViewModal.tsx index 998495e7..0003f069 100644 --- a/frontend/src/components/CustomerViewModal.tsx +++ b/frontend/src/components/CustomerViewModal.tsx @@ -9,7 +9,7 @@ import { useEscapeClose } from '../lib/escClose' // 영업의 「수정」 폼을 그대로 쓰지 않고, 같은 항목을 읽기 전용으로 보여준다. // 값은 견적 저장 시 함께 굳어진 입력 스냅샷(quote.inputs)과 고객 마스터에서 그대로 읽는다. const BIZ_KO: Record = { - individual: '개인사업자', corporate: '법인사업자', simplified: '간이과세자', personal: '개인', + individual: '개인사업자', corporate: '법인사업자', simplified: '간이과세자', vat_exempt: '면세사업자', personal: '개인', } const DIESEL_KO: Record = { none: '경유차 없음', keep: '경유차 유지 후 전기차 전환', scrap: '경유차 폐차 후 전기차 전환', diff --git a/frontend/src/components/SubsidyInputs.tsx b/frontend/src/components/SubsidyInputs.tsx index 45768eb3..a0574090 100644 --- a/frontend/src/components/SubsidyInputs.tsx +++ b/frontend/src/components/SubsidyInputs.tsx @@ -38,6 +38,7 @@ export const BUSINESS_TYPE_OPTIONS: { value: BusinessType; label: string }[] = [ { value: 'individual', label: '개인사업자' }, { value: 'corporate', label: '법인사업자' }, { value: 'simplified', label: '간이과세자' }, + { value: 'vat_exempt', label: '면세사업자' }, { value: 'consumer', label: '일반구매자' }, ] diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 0c5a7918..a0bc0c49 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -205,6 +205,7 @@ export const EN: Record = { '가 되어 제작 배정을 할 수 있습니다. 견적서·계약서는 이 시점의 내용으로 고정되어 더 이상 고칠 수 없습니다.': 'without an e-signature, and production can be assigned. The quotation and contract are frozen as they are at this point and can no longer be edited.', '가운데 자리': 'Middle digits', '간이과세자': 'Simplified taxpayer', + '면세사업자': 'VAT-exempt business', '값': 'Value', '같은 고객·같은 옵션으로 새 견적을 만듭니다 (새 번호·임시저장)': 'Creates a new quote with the same customer and options (new number, draft)', '개월수': 'Months', diff --git a/frontend/src/lib/liveQuote.ts b/frontend/src/lib/liveQuote.ts index e002f197..b25356e4 100644 --- a/frontend/src/lib/liveQuote.ts +++ b/frontend/src/lib/liveQuote.ts @@ -1,6 +1,6 @@ import type { ApiPricingBundle, CustomerInfo } from '@shared/types/index' import type { QuoteResult } from '@shared/pricing/core' -import { calcQuote, assembleOptionSum, makePriceLookup, groupOfPriceCode, bodyOnlyParams, vehicleOnlyParams, noVatRefund, resolveCarPrice, TAKBAE_RATE, DEFAULT_TAX_EXEMPT_TYPE, rowState } from '@shared/pricing/core' +import { calcQuote, assembleOptionSum, makePriceLookup, groupOfPriceCode, bodyOnlyParams, vehicleOnlyParams, noVatRefund, isIndividualBenefit, resolveCarPrice, TAKBAE_RATE, DEFAULT_TAX_EXEMPT_TYPE, rowState } from '@shared/pricing/core' import type { CustomOption, CustomOptionDraft } from '@shared/pricing/core' /** @@ -114,7 +114,7 @@ export function buildLiveTotal(args: LiveTotalArgs): QuoteResult | null { no_vat_refund: noVatRefund(biz), is_sosang: subsidyInputs.is_small_business ?? false, sosang_rate: bundle.subsidy_national?.sosang_rate ?? 0.3, - is_individual: biz === 'individual', + is_individual: isIndividualBenefit(biz), // 면세사업자도 개인사업자 혜택(택배) has_transport_license: subsidyInputs.has_transport_license ?? false, takbae_rate: TAKBAE_RATE, body_price: Math.round(option_sum * 1.1), diff --git a/frontend/src/lib/quoteCustomer.ts b/frontend/src/lib/quoteCustomer.ts index 53c1b0cf..b4f22d1e 100644 --- a/frontend/src/lib/quoteCustomer.ts +++ b/frontend/src/lib/quoteCustomer.ts @@ -10,9 +10,10 @@ import type { QuoteSaveValues } from '../components/QuoteSaveModal' */ /** 화면 표기 → 저장값 */ -export function mapBizType(bt: CustomerInfo['business_type'] | undefined): 'individual' | 'corporation' | 'simplified' | 'consumer' { +export function mapBizType(bt: CustomerInfo['business_type'] | undefined): 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer' { if (bt === 'corporate') return 'corporation' if (bt === 'simplified') return 'simplified' + if (bt === 'vat_exempt') return 'vat_exempt' // 면세사업자 — 개인사업자 혜택, 부가세 환급 불가 if (bt === 'consumer') return 'consumer' // 일반구매자(비사업자) — 부가세 환급 불가 return 'individual' } @@ -21,6 +22,7 @@ export function mapBizType(bt: CustomerInfo['business_type'] | undefined): 'indi export function unmapBizType(v: unknown): CustomerInfo['business_type'] { if (v === 'corporation') return 'corporate' if (v === 'simplified') return 'simplified' + if (v === 'vat_exempt') return 'vat_exempt' if (v === 'consumer') return 'consumer' return 'individual' } diff --git a/shared/pricing/core.ts b/shared/pricing/core.ts index 1faaeb8a..596281c7 100644 --- a/shared/pricing/core.ts +++ b/shared/pricing/core.ts @@ -10,7 +10,7 @@ export type { } from './types.js'; import type { PricingParams, PricingResult } from './types.js'; -import { noVatRefund } from './vat-refund.js'; +import { noVatRefund, isIndividualBenefit } from './vat-refund.js'; export { assembleOptionSum, optionBreakdown, valueUnitPrice, doorAddUnitPrice, @@ -28,7 +28,7 @@ export { bodyOnlyParams } from './body-only.js'; export { vehicleOnlyParams } from './vehicle-only.js'; // 가격바가 보여줄 파생값(계산은 여기 한 곳에만 — 화면·앱이 공유한다) -export { noVatRefund, NO_VAT_REFUND_BIZ_TYPES } from './vat-refund.js'; +export { noVatRefund, NO_VAT_REFUND_BIZ_TYPES, isIndividualBenefit } from './vat-refund.js'; export { resolveCarPrice, trimPriceVatIncluded, resolveTrimLabel, CAR_TRIM_LABEL_MAX } from './car-price.js'; // 커스텀 특장 옵션 — 단가표에 없는 사양을 영업이 직접 적어 넣는다 @@ -75,7 +75,7 @@ export function calcPrice(p: PricingParams): PricingResult { const subsidy_diesel = c.biz_type === 'corporation' && c.diesel_conversion ? s.diesel_conversion : 0; const subsidy_takbae = - c.has_transport_license && c.biz_type === 'individual' + c.has_transport_license && isIndividualBenefit(c.biz_type) ? Math.floor(subsidy_national * s.takbae_rate) : 0; const subsidy_total = diff --git a/shared/pricing/types.ts b/shared/pricing/types.ts index 038f0bba..328a9364 100644 --- a/shared/pricing/types.ts +++ b/shared/pricing/types.ts @@ -1,5 +1,8 @@ -/** individual=개인사업자 · corporation=법인사업자 · simplified=간이과세자 · consumer=일반구매자(부가세 환급 불가) */ -export type BizType = 'individual' | 'corporation' | 'simplified' | 'consumer'; +/** + * individual=개인사업자 · corporation=법인사업자 · simplified=간이과세자 · + * vat_exempt=면세사업자(개인사업자와 같은 혜택, 부가세 환급만 불가) · consumer=일반구매자(부가세 환급 불가) + */ +export type BizType = 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer'; /** 등록비·기타비 상수 (tax_config) */ export interface TaxConfig { diff --git a/shared/pricing/vat-refund.test.ts b/shared/pricing/vat-refund.test.ts index b1103263..6f29b839 100644 --- a/shared/pricing/vat-refund.test.ts +++ b/shared/pricing/vat-refund.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { calcQuote } from './quote.js'; import { calcPrice } from './core.js'; -import { noVatRefund, NO_VAT_REFUND_BIZ_TYPES } from './vat-refund.js'; +import { noVatRefund, NO_VAT_REFUND_BIZ_TYPES, isIndividualBenefit } from './vat-refund.js'; import { QUOTE_PARAMS, REGRESSION_PARAMS } from './fixtures.js'; import type { BizType } from './types.js'; @@ -43,13 +43,17 @@ describe('환급 대상 판정 — 사업자 구분', () => { expect(noVatRefund('consumer')).toBe(true); }); + it('🔴 면세사업자는 환급 대상이 아니다', () => { + expect(noVatRefund('vat_exempt')).toBe(true); + }); + it('개인·법인 사업자는 환급 대상이다', () => { expect(noVatRefund('individual')).toBe(false); expect(noVatRefund('corporation')).toBe(false); }); it('BizType 이 전부 둘 중 한쪽으로 판정된다 — 새 구분이 생기면 여기서 걸린다', () => { - const all: BizType[] = ['individual', 'corporation', 'simplified', 'consumer']; + const all: BizType[] = ['individual', 'corporation', 'simplified', 'vat_exempt', 'consumer']; expect(all.filter(noVatRefund).sort()).toEqual([...NO_VAT_REFUND_BIZ_TYPES].sort()); }); }); @@ -77,3 +81,44 @@ describe('Ver1.21 엔진(calcPrice) — 같은 규칙을 쓴다', () => { expect(r.vat_refunded_price).toBe(r.applied_price - r.vat); }); }); + +/** + * 면세사업자 = **개인사업자와 같은 혜택, 부가세 환급만 0원.** + * + * 부가세는 그대로 내고(공급가 10%), 보조금(국고·지방·소상공인·택배)도 개인사업자와 같다. + * 그러니 실구매가는 정확히 「같은 조건의 개인사업자 + 부가세액」이어야 한다. + */ +describe('면세사업자', () => { + it('🔴 개인사업자 혜택(택배 보조금) 대상이다 — 법인·간이·일반은 아니다', () => { + expect(isIndividualBenefit('individual')).toBe(true); + expect(isIndividualBenefit('vat_exempt')).toBe(true); + expect(isIndividualBenefit('corporation')).toBe(false); + expect(isIndividualBenefit('simplified')).toBe(false); + expect(isIndividualBenefit('consumer')).toBe(false); + }); + + it('🔴 calcPrice — 보조금은 개인사업자와 같고, 실구매가는 부가세만큼 비싸다', () => { + const run = (biz: BizType) => { + const r = calcPrice({ ...REGRESSION_PARAMS, customer: { ...REGRESSION_PARAMS.customer, biz_type: biz } }); + if (r.status !== 'ok') throw new Error('expected ok'); + return r; + }; + const ind = run('individual'); + const ex = run('vat_exempt'); + expect(ex.vat).toBe(ind.vat); + expect(ex.vat).toBeGreaterThan(0); + expect(ex.subsidy_total).toBe(ind.subsidy_total); + expect(ex.subsidy_takbae).toBe(ind.subsidy_takbae); + expect(ex.subsidy_takbae).toBeGreaterThan(0); + expect(ex.vat_refunded_price).toBe(ex.applied_price); + expect(ex.real_price).toBe(ind.real_price + ind.vat); + }); + + it('🔴 calcQuote — 개인사업자 파라미터에서 환급만 막으면 환급액 0, 실구매가는 그만큼 비싸다', () => { + const ind = calcQuote({ ...QUOTE_PARAMS, is_individual: true, no_vat_refund: noVatRefund('individual') }); + const ex = calcQuote({ ...QUOTE_PARAMS, is_individual: isIndividualBenefit('vat_exempt'), no_vat_refund: noVatRefund('vat_exempt') }); + expect(ex.vat_refund_price).toBe(0); + expect(ex.subsidy_total).toBe(ind.subsidy_total); + expect(ex.real_price).toBeGreaterThan(ind.real_price); + }); +}); diff --git a/shared/pricing/vat-refund.ts b/shared/pricing/vat-refund.ts index 365bfa3d..e271d94c 100644 --- a/shared/pricing/vat-refund.ts +++ b/shared/pricing/vat-refund.ts @@ -4,6 +4,7 @@ * 환급을 못 받는 쪽: * · `consumer` 일반구매자(비사업자) — 매입세액공제 자체가 없다 * · `simplified` 간이과세자 — 세금계산서로 매입세액을 돌려받지 못한다 + * · `vat_exempt` 면세사업자 — 부가세는 내지만 매입세액공제를 받지 못한다 * * ⚠️ 이 판정을 호출부에서 `biz === 'consumer'` 처럼 다시 쓰지 말 것. * 실제로 그렇게 두 곳(서버·화면)에 흩어져 있었고, 간이과세자를 넣을 때 @@ -13,9 +14,19 @@ import type { BizType } from './types.js'; /** 부가세 환급 대상이 아닌 사업자 구분 */ -export const NO_VAT_REFUND_BIZ_TYPES: readonly BizType[] = ['consumer', 'simplified']; +export const NO_VAT_REFUND_BIZ_TYPES: readonly BizType[] = ['consumer', 'simplified', 'vat_exempt']; /** 이 사업자 구분은 부가세를 환급받지 못하는가 */ export function noVatRefund(biz: BizType | string | null | undefined): boolean { return NO_VAT_REFUND_BIZ_TYPES.includes(biz as BizType); } + +/** + * 개인사업자 혜택(택배 보조금) 대상인가. + * + * 면세사업자는 개인사업자와 **같은 혜택**을 받는다 — 다른 점은 부가세 환급뿐이다. + * 판정을 호출부에서 `biz === 'individual'` 로 다시 쓰면 면세사업자가 빠진다. + */ +export function isIndividualBenefit(biz: BizType | string | null | undefined): boolean { + return biz === 'individual' || biz === 'vat_exempt'; +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 5863fa3f..d7dc16ab 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -42,8 +42,11 @@ export interface ModelOptionsResponse { // ── 고객 정보 ────────────────────────────────────────────────────────── -/** individual=개인사업자 · corporate=법인사업자 · simplified=간이과세자 · consumer=일반구매자(부가세 환급 불가) */ -export type BusinessType = 'individual' | 'corporate' | 'simplified' | 'consumer'; +/** + * individual=개인사업자 · corporate=법인사업자 · simplified=간이과세자 · + * vat_exempt=면세사업자(개인사업자와 같은 혜택, 부가세 환급만 불가) · consumer=일반구매자(부가세 환급 불가) + */ +export type BusinessType = 'individual' | 'corporate' | 'simplified' | 'vat_exempt' | 'consumer'; export interface CustomerInfo { /** 법인사업자면 **상호**, 그 외에는 **성명**. 계약서 `{{buyer_name}}` 으로 나간다. */