From 6ece38c3e6ec07475a3e07ed9c6fa010768ded45 Mon Sep 17 00:00:00 2001 From: Minseok Kang <0815km@naver.com> Date: Tue, 29 Sep 2026 16:41:50 +0900 Subject: [PATCH] feat(market): show the market analysis as a report with provisional sales Rebuild /markets as a sectioned report (overview and scopes, store counts, sales, customer mix, detailed-industry competition, source table) under a shared top tab bar. The report API now returns ADR 0002 provisional values (monthly sales, per-store monthly sales, average payment) with their basis links; they are shown next to the raw value and never stored or copied into plan assumptions. Co-Authored-By: Claude Opus 5.5 --- app/src/app/api/markets/report/route.ts | 5 + app/src/app/globals.css | 699 +++++++++-- app/src/components/ReportNav.tsx | 37 + app/src/features/market/MarketExplorer.tsx | 1065 +++++++++++------ app/src/features/market/QuarterTrendChart.tsx | 87 -- app/src/features/market/ReportCharts.tsx | 180 +++ app/src/features/market/market.test.ts | 45 + app/src/features/market/provisional.ts | 52 + app/src/features/market/types.ts | 14 +- app/src/lib/format-won.test.ts | 25 + app/src/lib/format-won.ts | 41 + docs/ARCHITECTURE.md | 1 + docs/PRODUCT.md | 4 +- docs/domains/market.md | 4 + 14 files changed, 1745 insertions(+), 514 deletions(-) create mode 100644 app/src/components/ReportNav.tsx delete mode 100644 app/src/features/market/QuarterTrendChart.tsx create mode 100644 app/src/features/market/ReportCharts.tsx create mode 100644 app/src/features/market/provisional.ts create mode 100644 app/src/lib/format-won.test.ts create mode 100644 app/src/lib/format-won.ts diff --git a/app/src/app/api/markets/report/route.ts b/app/src/app/api/markets/report/route.ts index 8bdc2f4..8813e53 100644 --- a/app/src/app/api/markets/report/route.ts +++ b/app/src/app/api/markets/report/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { marketSummaryQuerySchema } from '@/features/market/schema'; +import { PROVISIONAL_SALES_BASIS, provisionalQuarterSales } from '@/features/market/provisional'; import { getMarketSummary } from '@/features/market/read'; import type { MarketReport } from '@/features/market/types'; import { apiError, internalError, invalidZod } from '@/lib/api'; @@ -44,6 +45,10 @@ export async function GET(request: Request) { sourceRelease: outcome.payload.release.releaseKey, asOf: latestQuarter, nullReason: outcome.payload.dataStatus === 'NOT_PROVIDED' ? outcome.payload.dataStatusMessage : null, + provisionalSales: { + basis: PROVISIONAL_SALES_BASIS, + quarters: outcome.payload.quarters.map(provisionalQuarterSales), + }, }; return NextResponse.json(report); } catch (error) { diff --git a/app/src/app/globals.css b/app/src/app/globals.css index ca176bc..89cac00 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -621,9 +621,6 @@ footer { align-items: center; gap: 10px; } -.market-hero { - padding-bottom: 30px; -} .notice-box { background: #fff7df; border: 1px solid #e6cb83; @@ -721,20 +718,6 @@ footer { .market-results > * { min-width: 0; } -.summary-head h2 { - font-size: 27px; - letter-spacing: -1px; - margin: 0 0 6px; -} -.summary-head p { - margin: 0 0 4px; - color: var(--muted); - font-size: 13px; -} -.summary-head .name-mismatch { - color: var(--accent); - font-weight: 700; -} .data-status { border-radius: 12px; padding: 15px 18px; @@ -776,41 +759,6 @@ footer { border-radius: 5px; font-size: 12px; } -.chart-scroll { - overflow-x: auto; -} -.trend-chart { - display: block; -} -.chart-bar { - fill: var(--green); -} -.chart-missing-bar { - fill: #f0efe6; - stroke: #c9d3ca; - stroke-dasharray: 4 4; -} -.chart-axis-label { - fill: var(--muted); - font-size: 11px; - text-anchor: middle; -} -/* 왼쪽 끝에 두는 라벨이라 가운데 정렬하면 앞부분이 잘린다. */ -.chart-max-label { - fill: var(--muted); - font-size: 11px; - text-anchor: start; -} -.chart-value-label { - fill: var(--ink); - font-size: 10px; - text-anchor: middle; -} -.chart-missing-label { - fill: var(--accent); - font-size: 10px; - text-anchor: middle; -} .table-note { color: var(--muted); font-size: 12px; @@ -878,9 +826,6 @@ footer { grid-template-columns: 1fr; } } -.funding-hero { - padding-bottom: 30px; -} .status-counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1340,11 +1285,6 @@ footer { } /* Market report breakdowns */ -.scope-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} .scope-card { border-radius: 13px; padding: 18px; @@ -1371,19 +1311,6 @@ footer { font-size: 12px; line-height: 1.5; } -.breakdown-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} -.breakdown-card { - border: 1px solid var(--line); - border-radius: 13px; - padding: 18px; -} -.breakdown-card h4 { - margin: 0 0 14px; -} .metric-bars { display: grid; gap: 9px; @@ -1457,9 +1384,7 @@ footer { padding: 24px 18px 18px; } .choice-grid.three, - .profile-summary-grid, - .scope-grid, - .breakdown-grid { + .profile-summary-grid { grid-template-columns: 1fr; } .choice-card { @@ -1556,3 +1481,625 @@ footer { font-size: 13px; font-weight: 400; } + +/* 보고서 화면(상권 분석·정책자금 추천) */ +:root { + --report-deep: #163d31; + --report-accent: #1f7a57; + --report-accent-soft: #8fc3a8; + --report-orange: #c7792b; + --report-line: #e3e5df; + --report-soft: #eef4f0; + --report-warm: #fbf5e8; + --report-panel: #f7f8f4; + --report-text: #3f524b; +} +.report-nav { + display: block; + padding: 0; + background: #fff; + border-bottom: 1px solid var(--report-line); + position: sticky; + top: 0; + z-index: 5; +} +.report-nav-inner { + max-width: 1180px; + margin: auto; + padding: 0 38px; + min-height: 64px; + display: flex; + align-items: center; + gap: 36px; +} +.report-brand { + font-size: 18px; + font-weight: 800; + color: var(--ink); + text-decoration: none; +} +.report-nav nav { + display: flex; + gap: 28px; + align-self: stretch; + overflow-x: auto; + scrollbar-width: none; +} +.report-nav nav a { + display: flex; + align-items: center; + padding-top: 3px; + border-bottom: 3px solid transparent; + color: var(--muted); + font-size: 14px; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} +.report-nav nav a.active { + color: var(--ink); + font-weight: 800; + border-bottom-color: var(--report-accent); +} +.report-nav-meta { + margin-left: auto; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} +.report-page { + overflow-x: clip; +} +.report-page [id] { + scroll-margin-top: 84px; +} +.report-head { + padding: 40px 0 26px; +} +.report-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0; + color: var(--muted); + font-size: 12px; +} +.report-tag { + border-radius: 999px; + padding: 4px 10px; + background: #e3f1ea; + color: #1f6b4c; + font-size: 11px; + font-weight: 800; +} +.report-head h1 { + font-size: clamp(26px, 3.6vw, 34px); + line-height: 1.3; + letter-spacing: -0.8px; + margin: 14px 0 12px; +} +.report-head h1 em { + font-style: normal; + color: var(--report-accent); +} +.report-lead { + margin: 0; + color: var(--muted); + font-size: 14px; +} +.report-chips { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; +} +.report-chips li { + background: #fff; + border: 1px solid var(--report-line); + border-radius: 999px; + padding: 7px 14px; + font-size: 13px; + font-weight: 700; +} +.report-chips span { + margin-right: 8px; + color: var(--muted); + font-weight: 500; +} +.report-picker { + background: #fff; + border-color: var(--report-line); +} +.report-picker h2 { + font-size: 15px; +} +.report-picker button { + background: var(--report-deep); + color: #fff; +} +.picker-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.picker-actions .picker-note { + margin: 0; +} +.report-results { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 18px; + margin: 8px 0 28px; +} +.report-results > * { + min-width: 0; +} +.report-limits { + margin-top: 12px; +} +.report-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} +.report-stat { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 150px; + padding: 22px; + background: #fff; + border: 1px solid var(--report-line); + border-radius: 16px; +} +.report-stat span, +.report-stat small { + color: var(--muted); + font-size: 13px; +} +.report-stat small { + font-size: 12px; + line-height: 1.5; +} +.report-stat strong { + font-size: 32px; + line-height: 1.2; + letter-spacing: -1px; +} +.report-stat strong.accent { + color: var(--report-accent); +} +.report-stat strong.muted { + color: var(--muted); + font-size: 22px; +} +.report-stat a { + color: var(--report-accent); +} +.report-stat.primary { + background: var(--report-deep); + border-color: var(--report-deep); + color: #fff; +} +.report-stat.primary span, +.report-stat.primary small { + color: #c9d8d0; +} +.report-section { + min-width: 0; + padding: 30px; + background: #fff; + border: 1px solid var(--report-line); + border-radius: 18px; +} +.report-section.plain { + padding: 12px 0 0; + background: transparent; + border: 0; +} +.report-section > h2 { + margin: 0 0 14px; + font-size: 22px; + letter-spacing: -0.5px; +} +.report-section.plain > h2 { + margin-bottom: 6px; +} +.report-kicker { + margin: 0 0 6px; + color: var(--report-accent); + font-size: 12px; + font-weight: 800; +} +.report-section-desc { + margin: 0 0 18px; + color: var(--muted); + font-size: 13px; +} +.report-section-lead { + margin-bottom: 22px; + padding: 14px 18px; + border-radius: 12px; + background: #f4f5f1; + color: var(--report-text); + font-size: 13px; +} +.report-section-lead p { + margin: 0; +} +.report-section-lead p + p { + margin-top: 6px; +} +.basis-links a, +.report-details summary { + color: var(--report-accent); +} +.report-details { + margin-top: 16px; + font-size: 13px; +} +.report-details summary { + cursor: pointer; + font-weight: 700; +} +.report-details[open] summary { + margin-bottom: 12px; +} +.provisional-tag { + display: inline-block; + border-radius: 999px; + padding: 2px 8px; + background: #fbefd9; + color: #935612; + font-size: 11px; + font-weight: 800; +} + +/* 상권 분석 보고서 */ +.report-split { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px; +} +.report-split + .report-split { + margin-top: 30px; + padding-top: 26px; + border-top: 1px solid var(--report-line); +} +.report-block { + min-width: 0; +} +.report-block h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 10px; + font-size: 15px; +} +.report-block h3.block-subhead { + margin-top: 26px; +} +.report-sentence { + margin: 0 0 12px; + color: #2d443c; + font-size: 14px; +} +.report-sentence strong { + color: var(--report-accent); +} +.raw-value { + margin: -4px 0 12px; + color: var(--muted); + font-size: 12px; +} +.chart-unit { + margin: 14px 0 6px; + color: var(--muted); + font-size: 11px; +} +.fact-grid, +.mini-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} +.mini-stats { + margin-top: 16px; +} +.fact-grid > div, +.mini-stats > div { + padding: 12px 14px; + background: var(--report-panel); + border: 1px solid var(--report-line); + border-radius: 12px; +} +.fact-grid dt, +.mini-stats dt { + color: var(--muted); + font-size: 12px; +} +.fact-grid dd, +.mini-stats dd { + margin: 4px 0 0; + font-size: 14px; + font-weight: 800; + overflow-wrap: anywhere; +} +.scope-stack { + display: grid; + align-content: start; + gap: 10px; +} +.report-section .name-mismatch { + margin: 14px 0 0; + color: var(--accent); + font-size: 13px; + font-weight: 700; +} +.report-section .data-status { + margin-top: 14px; +} +.store-mix-bar { + display: flex; + gap: 2px; + height: 14px; + border-radius: 7px; + overflow: hidden; +} +.mix-general { + background: var(--report-accent); +} +.mix-franchise { + background: var(--report-orange); +} +.mix-legend { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + margin: 10px 0 0; + padding: 0; + color: var(--report-text); + font-size: 12px; +} +.mix-legend i { + display: inline-block; + width: 10px; + height: 10px; + margin-right: 6px; + border-radius: 3px; + vertical-align: -1px; +} +.report-chart { + margin: 0; +} +.column-plot { + display: flex; + align-items: flex-end; + gap: 6px; + height: 170px; + padding-top: 26px; + border-bottom: 1px solid #cfd6cf; +} +.column-slot, +.line-dot, +.line-missing { + outline: none; +} +.column-slot { + position: relative; + flex: 1 1 0; + min-width: 0; + height: 100%; + display: flex; + align-items: flex-end; + justify-content: center; +} +.column-bar { + display: block; + width: min(36px, 70%); + border-radius: 4px 4px 0 0; + background: var(--report-accent-soft); +} +.column-bar.strong { + background: var(--report-accent); +} +.column-missing { + display: flex; + align-items: flex-end; + justify-content: center; + width: min(36px, 70%); + height: 100%; + padding-bottom: 4px; + border: 1px dashed #c9d3ca; + border-bottom: 0; + border-radius: 4px 4px 0 0; + background: repeating-linear-gradient(45deg, #f5f4ee 0 4px, #fff 4px 8px); + color: var(--accent); + font-size: 10px; + line-height: 1.2; + text-align: center; + word-break: keep-all; +} +.column-value { + position: absolute; + left: 50%; + transform: translateX(-50%); + color: var(--ink); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} +.column-slot:first-child .column-value { + left: 0; + transform: none; +} +.column-slot:last-child .column-value { + left: auto; + right: 0; + transform: none; +} +.line-plot { + position: relative; + height: 170px; + border-bottom: 1px solid #cfd6cf; +} +.line-plot svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} +.line-path { + fill: none; + stroke: var(--report-accent); + stroke-width: 2; +} +.line-dot { + position: absolute; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border: 2px solid var(--report-accent); + border-radius: 50%; + background: #fff; +} +.line-dot.strong { + background: var(--report-accent); +} +.line-value { + position: absolute; + right: -4px; + bottom: 14px; + color: var(--ink); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} +.line-missing { + position: absolute; + bottom: 6px; + transform: translateX(-50%); + color: var(--accent); + font-size: 10px; + white-space: nowrap; +} +.column-slot:focus-visible, +.line-dot:focus-visible, +.line-missing:focus-visible { + outline: 2px solid var(--report-accent); + outline-offset: 2px; +} +.column-slot:hover::after, +.column-slot:focus-visible::after, +.line-dot:hover::after, +.line-dot:focus-visible::after, +.line-missing:hover::after, +.line-missing:focus-visible::after { + content: attr(data-tip); + position: absolute; + z-index: 3; + left: 50%; + top: 0; + transform: translate(-50%, calc(-100% - 6px)); + padding: 5px 9px; + border-radius: 7px; + background: var(--ink); + color: #fff; + font-size: 11px; + font-weight: 700; + white-space: nowrap; + pointer-events: none; +} +.chart-axis { + display: flex; + gap: 6px; + margin-top: 6px; +} +.chart-axis span { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} +.share-bars { + display: grid; + gap: 8px; +} +.share-row { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) 56px; + align-items: center; + gap: 10px; + font-size: 12px; +} +.share-track { + height: 10px; + overflow: hidden; + border-radius: 5px; + background: #eef0eb; +} +.share-track span { + display: block; + height: 100%; + border-radius: 0 4px 4px 0; + background: var(--report-accent-soft); +} +.share-row.strong .share-track span { + background: var(--report-accent); +} +.share-row strong { + text-align: right; +} +@media (max-width: 850px) { + .report-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .report-split { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .report-nav-inner { + flex-wrap: wrap; + gap: 4px 16px; + min-height: 0; + padding: 12px 18px 0; + } + .report-nav nav { + order: 3; + width: 100%; + min-height: 44px; + gap: 20px; + } + .report-head { + padding: 28px 0 20px; + } + .report-stat { + min-height: 0; + padding: 16px; + } + .report-stat strong { + font-size: 26px; + } + .report-section { + padding: 20px 16px; + } + .report-stats { + grid-template-columns: 1fr; + } + .picker-actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/app/src/components/ReportNav.tsx b/app/src/components/ReportNav.tsx new file mode 100644 index 0000000..27ab660 --- /dev/null +++ b/app/src/components/ReportNav.tsx @@ -0,0 +1,37 @@ +import Link from 'next/link'; + +const TABS = [ + { key: 'plan', href: '/', label: '창업 비용 · 목표 매출' }, + { key: 'markets', href: '/markets', label: '상권 분석' }, + { key: 'funding', href: '/funding', label: '정책자금 추천' }, +] as const; + +// 보고서 화면(상권 분석·정책자금 추천)이 함께 쓰는 상단 탭. meta는 오른쪽에 두는 +// 기준일 같은 짧은 문장이다. +export default function ReportNav({ + active, + meta, +}: Readonly<{ active: (typeof TABS)[number]['key']; meta?: string | null }>) { + return ( +
+
+ + TrendBench + + + {meta && {meta}} +
+
+ ); +} diff --git a/app/src/features/market/MarketExplorer.tsx b/app/src/features/market/MarketExplorer.tsx index 16996c2..5a8e027 100644 --- a/app/src/features/market/MarketExplorer.tsx +++ b/app/src/features/market/MarketExplorer.tsx @@ -1,8 +1,11 @@ 'use client'; -import Link from 'next/link'; +import Decimal from 'decimal.js'; import { useCallback, useEffect, useRef, useState } from 'react'; -import QuarterTrendChart, { type ChartPoint } from './QuarterTrendChart'; +import ReportNav from '@/components/ReportNav'; +import { formatManWon, formatWonExact } from '@/lib/format-won'; +import { ColumnChart, LineChart, ShareBars, shares, type ChartDatum } from './ReportCharts'; +import type { ProvisionalQuarterSales } from './provisional'; import { MARKET_LIMITATIONS, type AreaInfo, @@ -11,7 +14,6 @@ import { type MarketReport, type QuarterIndicatorRow, type ReleaseInfo, - type SalesBreakdownPoint, } from './types.ts'; import type { BusinessCategoryInfo, BusinessSummary } from '@/features/business-directory/types'; @@ -199,227 +201,242 @@ export default function MarketExplorer() { ); const selectedBusinessCategory = businessCategories.find((category) => category.code === detailedIndustryCode) ?? null; + const latestLabel = summary?.quarters.at(-1)?.label ?? null; - return ( -
-
- - TrendBench창업 준비의 기준 - -
- - 내 창업 계획 - - - 자금 후보 - -
-
- -
-

공개 상권 탐색

-

- 자치구에서 상권으로, -
- 상권에서 업종으로. -

-

- 서울시 상권분석서비스의 공개 원값을 그대로 보여줍니다. 로그인 없이 볼 수 있고, 개인 재무계획에는 값을 자동으로 - 넣지 않습니다. + const competition = ( + <> + {!detailedIndustryCode && !businessLoading && ( +

+ 위 조건에서 세부 업종을 고르면 {districtCode ? '이 자치구의' : '자치구'} 소진공 점포 스냅샷으로 경쟁 점포를 + 보여 줘요.

-
- - {booting && ( + )} + {businessLoading && (

- 상권 데이터를 확인하고 있습니다. + 세부 업종 경쟁 현황을 불러오는 중…

)} - - {!booting && bootError && ( + {!businessLoading && businessError && (
-

상권 데이터를 불러오지 못했습니다.

-

{bootError}

+

경쟁 현황을 불러오지 못했습니다.

+

{businessError}

)} + {!businessLoading && businessSummary && } + + ); - {!booting && !bootError && !activeRelease && ( -
-

활성 상권 릴리스가 없습니다.

-

- 운영자가 npm --prefix app run market:load로 검증된 릴리스를 활성화하기 전까지는 조회할 상권 - 지표가 없습니다. 자료가 없는 상태를 0으로 대신 표시하지 않습니다. + return ( + <> + +

+
+

+ 공개 데이터 + 서울시 상권분석서비스 관측값이며 개인 예상매출이 아니에요. 로그인 없이 볼 수 있어요.

-
- )} - - {!booting && activeRelease && ( - <> -
-

이 화면의 값이 무엇이고 무엇이 아닌지

-
    - {MARKET_LIMITATIONS.map((limitation) => ( -
  • {limitation}
  • - ))} + {summary ? ( +

    + 서울시 {summary.area.districtName} {summary.area.displayName} {summary.industry.displayName} 상권 + 분석 +

    + ) : ( +

    상권 분석 보고서

    + )} +

    + {summary + ? `${summary.area.displayName} 상권에서 ${summary.industry.displayName} 창업에 참고할 공개 지표를 모았어요.` + : '자치구 → 상권 → 업종 순으로 고르면 공개 지표로 보고서를 만들어요.'} +

    + {summary && ( +
      +
    • + 자치구 + {summary.area.districtName} +
    • +
    • + 상권 + {summary.area.displayName} · {summary.area.areaTypeName} +
    • +
    • + 업종 + {summary.industry.displayName} 전체 +
    • +
    • + 경쟁 범위 + {selectedBusinessCategory ? selectedBusinessCategory.name : '세부 업종 미선택'} +
    -
- - - -
-

상권과 업종 선택

-
- - - + - + + -
-

- 업종 표시명은 제품 문서의 이름을 우선하고 원본 명칭을 함께 보존합니다. 상권 표시명은 영역 파일 명칭을 - 우선하며, 매출·점포 파일의 명칭이 다르면 아래에 함께 표시합니다. -

-
- - {(industryCode || detailedIndustryCode) && ( -
-
- 매출·고객 분석 - - {industries.find((item) => item.code === industryCode)?.displayName ?? '상위 업종 미선택'} 전체 기준 - -

서울시 상권분석서비스(추정매출-상권) 관측값이며 개인 예상매출이 아닙니다.

-
-
- 경쟁 점포 현황 - - {selectedBusinessCategory ? `${selectedBusinessCategory.name} 세부 업종 기준` : '세부 업종 미선택'} - -

소진공 상가(상권)정보의 점포 스냅샷이며 매출 데이터가 아닙니다.

+ {filteredBusinessCategories.map((category) => ( + + ))} + + {businessCategoryMessage} +
+

+ 업종 표시명은 제품 문서의 이름을 우선하고 원본 명칭을 함께 보존합니다. 상권 표시명은 영역 파일 명칭을 + 우선하며, 매출·점포 파일의 명칭이 다르면 보고서에 함께 표시합니다. +

- )} -
- {areaError && ( -
-

상권 목록을 불러오지 못했습니다.

-

{areaError}

-
- )} - {districtCode !== '' && areas.length === 0 && !areasLoading && !areaError && ( -

이 자치구에는 등록된 상권이 없습니다.

- )} - {summaryLoading && ( -

- 분기 지표를 불러오는 중… -

- )} - {!summaryLoading && summaryError && ( -
-

분기 지표를 불러오지 못했습니다.

-

{summaryError}

-
- )} - {!summaryLoading && !summaryError && !summary && ( -

- 자치구 → 상권 → 업종 순으로 선택하면 가용 분기와 분기별 원본 지표를 표시합니다. -

- )} - {!summaryLoading && summary && } - {businessLoading && ( -

- 세부 업종 경쟁 현황을 불러오는 중… -

- )} - {!businessLoading && businessError && ( -
-

경쟁 현황을 불러오지 못했습니다.

-

{businessError}

-
- )} - {!businessLoading && businessSummary && } -
- - )} +
+ {areaError && ( +
+

상권 목록을 불러오지 못했습니다.

+

{areaError}

+
+ )} + {districtCode !== '' && areas.length === 0 && !areasLoading && !areaError && ( +

이 자치구에는 등록된 상권이 없습니다.

+ )} + {summaryLoading && ( +

+ 분기 지표를 불러오는 중… +

+ )} + {!summaryLoading && summaryError && ( +
+

분기 지표를 불러오지 못했습니다.

+

{summaryError}

+
+ )} + {!summaryLoading && !summaryError && !summary && ( +

자치구 → 상권 → 업종 순으로 선택하면 보고서를 표시합니다.

+ )} + {!summaryLoading && summary && ( + + )} + {!summary && (businessLoading || businessError || businessSummary) && competition} +
+ +
+

이 보고서의 값이 무엇이고 무엇이 아닌지

+
    + {MARKET_LIMITATIONS.map((limitation) => ( +
  • {limitation}
  • + ))} +
+
-
TrendBench · 공개 상권 지표 · 내부 MVP · 공개 출시 준비 완료 상태가 아닙니다.
-
+ + + )} + +
TrendBench · 공개 상권 지표 · 내부 MVP · 공개 출시 준비 완료 상태가 아닙니다.
+ + ); } @@ -481,39 +498,152 @@ function ReleaseCard({ release }: { readonly release: ReleaseInfo }) { ); } -function SummaryPanel({ +type Change = { direction: 'UP' | 'DOWN' | 'SAME'; diff: string; percent: string | null }; + +function compareValues(current: string | number | null, previous: string | number | null): Change | null { + if (current === null || previous === null) return null; + const now = new Decimal(current); + const before = new Decimal(previous); + const diff = now.minus(before); + return { + direction: diff.isZero() ? 'SAME' : diff.isPositive() ? 'UP' : 'DOWN', + diff: diff.abs().toFixed(0), + percent: before.isZero() + ? null + : diff.abs().div(before).times(100).toDecimalPlaces(1, Decimal.ROUND_HALF_UP).toFixed(1), + }; +} + +// "전분기 대비 3개(+1.3%) 늘었어요."처럼 한 문장으로 쓴다. 비교할 값이 없으면 0으로 보지 않고 +// 비교하지 않았다고 쓴다. +function describeChange(change: Change | null, base: string, format: (abs: string) => string): string { + if (!change) return `${base} 값이 없어 비교하지 않았어요.`; + if (change.direction === 'SAME') return `${base}와 같아요.`; + const sign = change.direction === 'UP' ? '+' : '-'; + const percent = change.percent === null ? '' : `(${sign}${change.percent}%)`; + return `${base} 대비 ${format(change.diff)}${percent} ${change.direction === 'UP' ? '늘었어요' : '줄었어요'}.`; +} + +function sameQuarterLastYear(quarters: readonly QuarterIndicatorRow[], row: QuarterIndicatorRow) { + const code = `${Number(row.quarter.slice(0, 4)) - 1}${row.quarter.slice(4)}`; + return quarters.find((quarter) => quarter.quarter === code) ?? null; +} + +function ReportSection({ + index, + title, + lead, + children, +}: Readonly<{ index: number; title: string; lead?: React.ReactNode; children: React.ReactNode }>) { + return ( +
+

+ {index}. {title} +

+ {lead &&
{lead}
} + {children} +
+ ); +} + +function ProvisionalTag() { + return 잠정; +} + +function MarketReportView({ summary, selectedArea, + detailedCategoryName, + competition, }: { readonly summary: MarketReport; readonly selectedArea: AreaInfo | null; + readonly detailedCategoryName: string | null; + readonly competition: React.ReactNode; }) { - const points: ChartPoint[] = summary.quarters.map((quarter) => ({ - quarter: quarter.quarter, - label: quarter.label, - shortLabel: shortQuarter(quarter.quarter), - amount: quarter.salesAmount === null ? null : Number(quarter.salesAmount), - })); - const observedQuarters = summary.quarters.filter((quarter) => quarter.salesStatus === 'OBSERVED').length; + const { quarters } = summary; + const industry = summary.industry.displayName; + const latest = quarters.at(-1) ?? null; + const previous = quarters.length > 1 ? quarters[quarters.length - 2] : null; + const lastYear = latest ? sameQuarterLastYear(quarters, latest) : null; + const provisional = new Map( + summary.provisionalSales.quarters.map((row) => [row.quarter, row]), + ); + const latestProvisional = latest ? (provisional.get(latest.quarter) ?? null) : null; + const previousProvisional = previous ? (provisional.get(previous.quarter) ?? null) : null; + const observedQuarters = quarters.filter((quarter) => quarter.salesStatus === 'OBSERVED').length; const statusLabel = summary.dataStatus === 'AVAILABLE' - ? `가용 분기 ${summary.quarters.length}개 모두 매출 원값이 있습니다.` + ? `가용 분기 ${quarters.length}개 모두 매출 원값이 있습니다.` : summary.dataStatus === 'PARTIAL' - ? `가용 분기 ${summary.quarters.length}개 중 매출 원값이 있는 분기 ${observedQuarters}개입니다.` + ? `가용 분기 ${quarters.length}개 중 매출 원값이 있는 분기 ${observedQuarters}개입니다.` : '이 상권·업종 조합의 관측 자료가 없습니다.'; + const series = (value: (quarter: QuarterIndicatorRow) => number | null): ChartDatum[] => + quarters.map((quarter) => ({ key: quarter.quarter, label: shortQuarter(quarter.quarter), value: value(quarter) })); + const provisionalSeries = (field: keyof Omit) => + series((quarter) => { + const value = provisional.get(quarter.quarter)?.[field] ?? null; + return value === null ? null : Number(value); + }); + const manWon = (value: number) => formatManWon(String(Math.round(value))); + const manWonText = (value: string) => formatManWon(value); + const countText = (value: string) => `${numberFormat.format(BigInt(value))}개`; + const caseText = (value: string) => `${numberFormat.format(BigInt(value))}건`; + return ( <> -
-

{summary.area.displayName}

-

- {summary.area.districtName} · {summary.area.areaTypeName} · 상권 코드 {summary.area.areaCode} · 업종{' '} - {summary.industry.displayName} ({summary.industry.code}) -

-

- 업종 원본 명칭 {summary.industry.sourceName} · 원천 분류{' '} - {summary.industry.sourceCategoryVersion} · 상권 원본 명칭 {summary.area.sourceName} -

+ +
+
+
+
자치구
+
{summary.area.districtName}
+
+
+
상권 유형
+
{summary.area.areaTypeName}
+
+
+
상권 코드
+
{summary.area.areaCode}
+
+
+
가용 분기
+
+ {quarters.length === 0 + ? '자료 없음' + : `${quarters.length}개 · ${quarters[0].label} ~ ${quarters.at(-1)!.label}`} +
+
+
+
업종 원본 명칭
+
+ {summary.industry.sourceName} ({summary.industry.code}) +
+
+
+
상권 원본 명칭
+
{summary.area.sourceName}
+
+
+
+
+ 매출·고객 분석 범위 + {summary.observedScope.label} 전체 +

{summary.observedScope.statement}

+
+
+ 경쟁 점포 범위 + {detailedCategoryName ? `${detailedCategoryName} 세부 업종` : '세부 업종 미선택'} +

소진공 상가(상권)정보의 자치구 점포 스냅샷이며 매출 데이터가 아닙니다.

+
+
+
{summary.area.nameMismatch && (

원본 명칭이 다릅니다: 영역 파일 {summary.area.sourceName}, 매출·점포 파일{' '} @@ -523,50 +653,251 @@ function SummaryPanel({ {!summary.area.nameMismatch && selectedArea?.observedName && (

매출·점포 파일의 관측 명칭: {selectedArea.observedName}

)} -
- -
- {statusLabel} - {summary.dataStatusMessage && {summary.dataStatusMessage}} -
- -
- 관측 데이터 범위 - {summary.observedScope.label} 전체 기준 -

{summary.observedScope.statement}

-
+
+ {statusLabel} + {summary.dataStatusMessage && {summary.dataStatusMessage}} +
+ - {summary.quarters.length === 0 ? ( + {!latest ? (

자료 부족

- {summary.area.displayName} · {summary.industry.displayName} 조합에는 적재된 분기 지표가 없습니다. 값을 0으로 - 만들지 않고 자료 부족으로 표시합니다. 다른 상권이나 업종을 선택해 주세요. + {summary.area.displayName} · {industry} 조합에는 적재된 분기 지표가 없습니다. 값을 0으로 만들지 않고 자료 + 부족으로 표시합니다. 다른 상권이나 업종을 선택해 주세요.

) : ( <> -
-

분기별 매출 원값 추이 (단위 미확정)

- -

- 세로 막대는 매출 원값을, 빗금 칸은 자료가 없는 분기를 나타냅니다. 정확한 값은 아래 표에 있습니다. -

-
+ +
+
+

a. 점포 수

+ {latest.similarIndustryStoreCount === null ? ( +

{latest.label} 유사 업종 점포 수가 제공되지 않았어요.

+ ) : ( +

+ {latest.label} 기준 {industry} 점포(유사 업종 점포 수)는{' '} + {formatCount(latest.similarIndustryStoreCount)}개예요.{' '} + {describeChange( + compareValues(latest.similarIndustryStoreCount, previous?.similarIndustryStoreCount ?? null), + '전분기', + countText, + )} + {lastYear && + ` ${describeChange( + compareValues(latest.similarIndustryStoreCount, lastYear.similarIndustryStoreCount), + '전년 같은 분기', + countText, + )}`} +

+ )} + +
+
+
{latest.label} 개업
+
{formatCount(latest.openedStoreCount)}개
+
+
+
{latest.label} 폐업
+
{formatCount(latest.closedStoreCount)}개
+
+
+
+
+

b. 점포 수 변화

+

단위: 개 · 유사 업종 점포 수

+ quarter.similarIndustryStoreCount)} + format={(value) => `${numberFormat.format(value)}개`} + /> +
+
+
- {summary.quarters.at(-1)?.salesBreakdown && } + +

+ {summary.provisionalSales.basis.statement} 계산 결과에 저장하지 않고 재무 가정에 + 자동으로 넣지 않아요. +

+

+ 근거({summary.provisionalSales.basis.decision}):{' '} + {summary.provisionalSales.basis.sources.map((source, index) => ( + + {index > 0 && ' · '} + + {source.label} + + + ))} +

+ + } + > +
+
+

+ a. 월매출 +

+ {latestProvisional?.monthlySalesAmount ? ( + <> +

+ {latest.label} {industry} 월매출(잠정)은{' '} + {formatManWon(latestProvisional.monthlySalesAmount)}이에요.{' '} + {describeChange( + compareValues( + latestProvisional.monthlySalesAmount, + previousProvisional?.monthlySalesAmount ?? null, + ), + '전분기', + manWonText, + )} +

+

+ 원값 {formatWonExact(latest.salesAmount!)} (당월_매출_금액)을 분기 월평균으로 해석했어요. +

+ + ) : ( +

{latest.label} 매출 원값이 제공되지 않아 월매출을 표시하지 않아요.

+ )} +

단위: 원 · 분기별 월매출(잠정)

+ +
+
+

+ b. 점포당 월매출 +

+ {latestProvisional?.perStoreMonthlySalesAmount ? ( + <> +

+ {latest.label} 점포당 월매출(잠정)은{' '} + {formatManWon(latestProvisional.perStoreMonthlySalesAmount)}이에요.{' '} + {describeChange( + compareValues( + latestProvisional.perStoreMonthlySalesAmount, + previousProvisional?.perStoreMonthlySalesAmount ?? null, + ), + '전분기', + manWonText, + )} +

+

+ 원값 {formatWonExact(latest.salesAmount!)} ÷ 유사 업종 점포{' '} + {formatCount(latest.similarIndustryStoreCount)}개 ={' '} + {formatWonExact(latestProvisional.perStoreMonthlySalesAmount)}. 상권 평균이며 개인 예상매출이 + 아니에요. +

+ + ) : ( +

+ 매출 원값이나 유사 업종 점포 수가 없어 {latest.label} 점포당 월매출을 계산하지 않았어요. +

+ )} +

단위: 원 · 분기별 점포당 월매출(잠정)

+ +
+
+
+
+

c. 매출 건수

+ {latest.salesCount ? ( +

+ {latest.label} 매출 건수 원값은 {caseText(latest.salesCount)}이에요.{' '} + {describeChange(compareValues(latest.salesCount, previous?.salesCount ?? null), '전분기', caseText)}{' '} + 방문자·주문자 수가 아니에요. +

+ ) : ( +

{latest.label} 매출 건수 원값이 제공되지 않았어요.

+ )} +

단위: 건 · 매출 건수 원값

+ (quarter.salesCount === null ? null : Number(quarter.salesCount)))} + format={(value) => `${numberFormat.format(value)}건`} + /> +
+
+

d. 결제 1건당 평균 금액

+ {latestProvisional?.averagePaymentAmount ? ( + <> +

+ {latest.label} 결제 1건당 평균 금액은{' '} + {formatWonExact(latestProvisional.averagePaymentAmount)}이에요.{' '} + {describeChange( + compareValues( + latestProvisional.averagePaymentAmount, + previousProvisional?.averagePaymentAmount ?? null, + ), + '전분기', + formatWonExact, + )} +

+

+ 매출 원값 ÷ 매출 건수 원값. 같은 기간 값끼리 나눠 시간 단위 해석과 무관해요. +

+ + ) : ( +

매출 원값이나 건수가 없어 평균 금액을 계산하지 않았어요.

+ )} +

단위: 원

+ `${numberFormat.format(value)}원`} + /> +
+
+
+ + + {latest.salesBreakdown ? ( + + ) : ( +

{latest.label} 요일·시간대·성별·연령대 구성 자료가 제공되지 않았어요.

+ )} +
-
-

분기별 원본 지표 · 가장 오래된 분기부터

+ + {competition} + + +
+ + @@ -576,110 +907,152 @@ function SummaryPanel({ - {summary.quarters.map((quarter) => ( - + {quarters.map((quarter) => ( + ))}
- {summary.area.displayName} · {summary.industry.displayName} · 가용 분기 {summary.quarters.length}개 + {summary.area.displayName} · {industry} · 가용 분기 {quarters.length}개 · 가장 오래된 분기부터
분기 매출 원값 (원)월매출 잠정 (원)점포당 월매출 잠정 (원) 매출 건수 원값 일반 점포 수 유사 업종 점포 수

- 매출 원값은 API에서 원 단위 정수 문자열로 전달되며, 표에서는 읽기 쉽도록 천 단위 구분만 넣었습니다. 점포 - 수는 제공된 값만 그대로 표시하고, 없는 값은 0이 아니라 자료 부족으로 표시합니다. + 금액은 API에서 원 단위 정수 문자열로 전달되며, 표에서는 천 단위 구분만 넣었습니다. 없는 값은 0이 아니라 + 자료 부족으로 표시합니다.

-
- -
-

지표 정의와 원본 열

-
- - - - - - - - - - - {summary.indicators.map((indicator) => ( - - - - - +
+ 지표 정의와 원본 열 +
+
지표단위원본 열 (2024 / 2025)해석 제한
{indicator.label}{indicator.unitConfirmed ? indicator.unit : '단위 미확정'} - {indicator.sourceColumn[2024]} / {indicator.sourceColumn[2025]} - {indicator.note}
+ + + + + + - ))} - -
지표단위원본 열 (2024 / 2025)해석 제한
-
-
+ + + {summary.indicators.map((indicator) => ( + + {indicator.label} + {indicator.unitConfirmed ? indicator.unit : '단위 미확정'} + + {indicator.sourceColumn[2024]} / {indicator.sourceColumn[2025]} + + {indicator.note} + + ))} + + + + + )} ); } -function SalesBreakdownPanel({ quarter }: { readonly quarter: QuarterIndicatorRow }) { - const breakdown = quarter.salesBreakdown; - if (!breakdown) return null; +// 일반 점포와 프랜차이즈 점포의 구성. 두 값 중 하나라도 없으면 비율을 만들지 않는다. +function StoreMix({ quarter }: { readonly quarter: QuarterIndicatorRow }) { + const general = quarter.storeCount; + const franchise = quarter.franchiseStoreCount; + if (general === null || franchise === null || general + franchise === 0) { + return

일반·프랜차이즈 점포 구성 자료가 없어 비율을 표시하지 않아요.

; + } + const generalPercent = Math.round((general / (general + franchise)) * 1000) / 10; return ( -
-

{quarter.label} 매출 구성 · 상위 업종 기준

-

- 금액 비중은 각 분류 안에서만 계산합니다. 개인 고객 구성이나 세부 업종 구성으로 해석하지 않습니다. -

-
- - - - +
+
+ +
-
    - {breakdown.limitations.map((item) => ( -
  • {item}
  • - ))} +
      +
    • + + 일반 점포 {numberFormat.format(general)}개 ({generalPercent.toFixed(1)}%) +
    • +
    • + + 프랜차이즈 {numberFormat.format(franchise)}개 ({(100 - generalPercent).toFixed(1)}%) +
); } -function MetricBars({ title, points }: { readonly title: string; readonly points: SalesBreakdownPoint[] }) { - const values = points.map((point) => BigInt(point.salesAmount)); - const total = values.reduce((sum, value) => sum + value, 0n); +function topShareLabel(points: readonly { key: string; label: string; salesAmount: string }[]): string | null { + const rows = shares(points.map((point) => ({ key: point.key, label: point.label, amount: point.salesAmount }))); + const top = rows.reduce<(typeof rows)[number] | null>( + (best, row) => (row.percent !== null && (best === null || row.percent > (best.percent ?? -1)) ? row : best), + null, + ); + return top?.label ?? null; +} + +function CustomerBreakdown({ quarter }: { readonly quarter: QuarterIndicatorRow }) { + const breakdown = quarter.salesBreakdown!; + const toShare = (points: typeof breakdown.dayOfWeek) => + points.map((point) => ({ key: point.key, label: point.label, amount: point.salesAmount })); + const toColumns = (points: typeof breakdown.dayOfWeek): ChartDatum[] => + shares(toShare(points)).map((row) => ({ key: row.key, label: row.label, value: row.percent })); + const topAge = topShareLabel(breakdown.age); + const topGender = topShareLabel(breakdown.gender); + const topDay = topShareLabel(breakdown.dayOfWeek); + const topTime = topShareLabel(breakdown.timeOfDay); + const percent = (value: number) => `${value.toFixed(1)}%`; return ( -
-

{title}

-
- {points.map((point, index) => { - const percent = total === 0n ? 0 : Number((values[index] * 1000n) / total) / 10; - return ( -
- {point.label} -
- -
- {percent.toFixed(1)}% -
- ); - })} + <> + {topAge && topGender && topDay && topTime && ( +

+ 연령대는 {topAge}, 성별은 {topGender} 매출 비중이 가장 높고,{' '} + {topDay}요일과 {topTime}에 매출이 가장 많아요. +

+ )} +
+
+

a. 성별

+ +

b. 연령대

+ +
+
+

c. 요일별 매출 비중

+

단위: % · 매출 금액 비중

+ +

d. 시간대별 매출 비중

+

단위: % · 매출 금액 비중

+ +
-
+
    + {breakdown.limitations.map((item) => ( +
  • {item}
  • + ))} +
+ ); } function CompetitionPanel({ summary }: { readonly summary: BusinessSummary }) { return ( -
-

- {summary.district.name} {summary.category.name} 경쟁 점포 -

-
- 경쟁 범위 - {numberFormat.format(summary.businessCount)}개 점포 -

{summary.competitionScope.statement}

-
+
+

+ {summary.district.name} {summary.category.name} 점포는{' '} + {numberFormat.format(summary.businessCount)}개예요. {summary.competitionScope.statement} +

{summary.businesses.length === 0 ? (

해당 자치구에서 관측된 점포가 없습니다. 0건은 활성 스냅샷 조회 결과입니다.

) : ( @@ -705,24 +1078,26 @@ function CompetitionPanel({ summary }: { readonly summary: BusinessSummary }) { ); } -function QuarterRow({ quarter }: { readonly quarter: QuarterIndicatorRow }) { +function QuarterRow({ + quarter, + provisional, +}: { + readonly quarter: QuarterIndicatorRow; + readonly provisional: ProvisionalQuarterSales | undefined; +}) { + const won = (value: string | null | undefined) => + value === null || value === undefined ? ( + 자료 부족 + ) : ( + numberFormat.format(BigInt(value)) + ); return ( {quarter.label} - - {quarter.salesAmount === null ? ( - 자료 부족 - ) : ( - numberFormat.format(Number(quarter.salesAmount)) - )} - - - {quarter.salesCount === null ? ( - 자료 부족 - ) : ( - numberFormat.format(Number(quarter.salesCount)) - )} - + {won(quarter.salesAmount)} + {won(provisional?.monthlySalesAmount)} + {won(provisional?.perStoreMonthlySalesAmount)} + {won(quarter.salesCount)} {formatCount(quarter.storeCount)} {formatCount(quarter.similarIndustryStoreCount)} {formatCount(quarter.franchiseStoreCount)} diff --git a/app/src/features/market/QuarterTrendChart.tsx b/app/src/features/market/QuarterTrendChart.tsx deleted file mode 100644 index 73b21e9..0000000 --- a/app/src/features/market/QuarterTrendChart.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client'; - -export type ChartPoint = { quarter: string; label: string; shortLabel: string; amount: number | null }; - -const numberFormat = new Intl.NumberFormat('ko-KR'); - -// Dependency-free bar chart: the table next to it carries the exact values, so -// the chart only has to show the shape of the trend and where data is missing. -export default function QuarterTrendChart({ points }: { readonly points: readonly ChartPoint[] }) { - const width = Math.max(320, points.length * 72 + 24); - const height = 210; - // 위쪽 여백은 최대값 라벨 줄이다. 가장 높은 막대의 값 라벨과 겹치지 않게 막대 영역 위에 둔다. - const padding = { top: 32, right: 14, bottom: 46, left: 14 }; - const plotHeight = height - padding.top - padding.bottom; - const observed = points.map((point) => point.amount).filter((amount): amount is number => amount !== null); - const maximum = observed.length > 0 ? Math.max(...observed) : 0; - const slot = (width - padding.left - padding.right) / Math.max(points.length, 1); - const barWidth = Math.min(42, slot * 0.62); - const described = points - .map((point) => `${point.label} ${point.amount === null ? '자료 부족' : `${numberFormat.format(point.amount)}원`}`) - .join(', '); - - return ( -
- - - {maximum > 0 && ( - - 최대 {numberFormat.format(maximum)}원 - - )} - {points.map((point, index) => { - const center = padding.left + slot * index + slot / 2; - const barHeight = - point.amount === null || maximum === 0 ? 0 : Math.max(2, (point.amount / maximum) * plotHeight); - const y = height - padding.bottom - barHeight; - return ( - - {point.amount === null ? ( - - ) : ( - <> - - - {formatCompact(point.amount)} - - - )} - - {point.shortLabel} - - {point.amount === null && ( - - 자료 부족 - - )} - - ); - })} - -
- ); -} -function formatCompact(amount: number): string { - if (amount >= 100000000) return `${(amount / 100000000).toFixed(1)}억`; - if (amount >= 10000) return `${Math.round(amount / 10000)}만`; - return numberFormat.format(amount); -} diff --git a/app/src/features/market/ReportCharts.tsx b/app/src/features/market/ReportCharts.tsx new file mode 100644 index 0000000..339f8de --- /dev/null +++ b/app/src/features/market/ReportCharts.tsx @@ -0,0 +1,180 @@ +'use client'; + +// 의존성 없는 보고서 차트. 정확한 값은 보고서 아래 원본 지표 표에 있으므로 차트는 +// 추세의 모양과 자료가 없는 칸만 보여 주고, 값은 강조한 칸과 마우스·키보드 초점의 +// 말풍선에만 적는다. + +export type ChartDatum = { key: string; label: string; value: number | null }; + +type Highlight = 'last' | 'max' | 'none'; + +function highlightKey(points: readonly ChartDatum[], highlight: Highlight): string | null { + const observed = points.filter((point): point is ChartDatum & { value: number } => point.value !== null); + if (observed.length === 0 || highlight === 'none') return null; + if (highlight === 'last') return observed.at(-1)!.key; + return observed.reduce((best, point) => (point.value > best.value ? point : best)).key; +} + +function tip(point: ChartDatum, format: (value: number) => string): string { + return `${point.label} ${point.value === null ? '자료 부족' : format(point.value)}`; +} + +export function ColumnChart({ + title, + points, + format, + highlight = 'last', +}: Readonly<{ + title: string; + points: readonly ChartDatum[]; + format: (value: number) => string; + highlight?: Highlight; +}>) { + const values = points.flatMap((point) => (point.value === null ? [] : [point.value])); + const maximum = values.length > 0 ? Math.max(...values) : 0; + const strong = highlightKey(points, highlight); + return ( +
+
+ {points.map((point) => { + const percent = point.value === null || maximum === 0 ? 0 : Math.max(1.5, (point.value / maximum) * 100); + const text = tip(point, format); + return ( +
+ {point.value === null ? ( + 자료 부족 + ) : ( + + )} + {point.key === strong && point.value !== null && ( + + {format(point.value)} + + )} +
+ ); + })} +
+ +
+ ); +} + +export function LineChart({ + title, + points, + format, +}: Readonly<{ title: string; points: readonly ChartDatum[]; format: (value: number) => string }>) { + const values = points.flatMap((point) => (point.value === null ? [] : [point.value])); + const low = values.length > 0 ? Math.min(...values) : 0; + const high = values.length > 0 ? Math.max(...values) : 0; + // 기준선이 0이 아니다. 작은 변화가 크게 보이지 않도록 세로 폭을 값의 20% 이상으로 잡는다. + // 모든 점에 초점 말풍선을, 마지막 점에 값을 단다. + const span = Math.max(high - low, high * 0.2, 1); + const center = (high + low) / 2; + const top = center + span * 0.65; + const bottom = Math.max(0, center - span * 0.65); + const x = (index: number) => ((index + 0.5) / points.length) * 100; + const y = (value: number) => ((top - value) / (top - bottom)) * 100; + const strong = highlightKey(points, 'last'); + + // 자료가 없는 분기에서 선을 끊는다. 없는 값을 이어 그려 추세를 만들지 않는다. + const segments: string[] = []; + let current: string[] = []; + points.forEach((point, index) => { + if (point.value === null) { + if (current.length > 1) segments.push(current.join(' ')); + current = []; + return; + } + current.push(`${x(index)},${y(point.value)}`); + }); + if (current.length > 1) segments.push(current.join(' ')); + + return ( +
+
+ + {points.map((point, index) => { + const text = tip(point, format); + if (point.value === null) { + return ( + + 자료 부족 + + ); + } + return ( + + {point.key === strong && {format(point.value)}} + + ); + })} +
+ +
+ ); +} + +export type ShareDatum = { key: string; label: string; amount: string }; + +// 한 분류 안에서 금액 비중(%)을 소수 첫째 자리까지 구한다. 합계가 0이면 비중을 만들지 않는다. +export function shares(points: readonly ShareDatum[]): { key: string; label: string; percent: number | null }[] { + const values = points.map((point) => BigInt(point.amount)); + const total = values.reduce((sum, value) => sum + value, 0n); + return points.map((point, index) => ({ + key: point.key, + label: point.label, + percent: total === 0n ? null : Number((values[index] * 1000n + total / 2n) / total) / 10, + })); +} + +export function ShareBars({ title, points }: Readonly<{ title: string; points: readonly ShareDatum[] }>) { + const rows = shares(points); + const top = rows.reduce<(typeof rows)[number] | null>( + (best, row) => (row.percent !== null && (best === null || row.percent > (best.percent ?? -1)) ? row : best), + null, + ); + return ( +
+ {rows.map((row) => ( +
+ {row.label} +
+ +
+ {row.percent === null ? '자료 부족' : `${row.percent.toFixed(1)}%`} +
+ ))} +
+ ); +} diff --git a/app/src/features/market/market.test.ts b/app/src/features/market/market.test.ts index 5982963..8daa665 100644 --- a/app/src/features/market/market.test.ts +++ b/app/src/features/market/market.test.ts @@ -24,6 +24,7 @@ import { loadMarketRelease, readMarketSources } from './loader.ts'; import { basisPeriodCode, compareQuarters, parseQuarter, quarterLabel } from './quarter.ts'; import { MARKET_SOURCE_FILES, releaseKeyFor, type SourceFileRecord } from './source-files.ts'; import { MarketSourceError, isQuarterCode, parseAmountCell, parseCountCell } from './validation.ts'; +import { provisionalQuarterSales } from './provisional.ts'; import { getMarketSummary, listMarketAreas, listSupportedIndustries } from './read.ts'; import { readZipMember } from './zip.ts'; @@ -402,6 +403,50 @@ describe('quarter codes', () => { }); }); +describe('provisional sales (ADR 0002)', () => { + it('matches the hand-checked sample: 성수1가1동 커피·음료 2025년 4분기', () => { + const result = provisionalQuarterSales({ + quarter: '20254', + salesAmount: '184219542', + salesCount: null, + similarIndustryStoreCount: 11, + }); + expect(result.monthlySalesAmount).toBe('184219542'); + expect(result.perStoreMonthlySalesAmount).toBe('16747231'); + expect(result.averagePaymentAmount).toBeNull(); + }); + + it('rounds half up to whole won as strings', () => { + const result = provisionalQuarterSales({ + quarter: '20251', + salesAmount: '5', + salesCount: '2', + similarIndustryStoreCount: 2, + }); + expect(result.perStoreMonthlySalesAmount).toBe('3'); + expect(result.averagePaymentAmount).toBe('3'); + }); + + it('keeps a missing or zero denominator as null instead of 0', () => { + expect( + provisionalQuarterSales({ quarter: '20251', salesAmount: '1000', salesCount: '0', similarIndustryStoreCount: 0 }), + ).toEqual({ + quarter: '20251', + monthlySalesAmount: '1000', + perStoreMonthlySalesAmount: null, + averagePaymentAmount: null, + }); + expect( + provisionalQuarterSales({ quarter: '20251', salesAmount: null, salesCount: '10', similarIndustryStoreCount: 3 }), + ).toEqual({ + quarter: '20251', + monthlySalesAmount: null, + perStoreMonthlySalesAmount: null, + averagePaymentAmount: null, + }); + }); +}); + describe('source header mapping', () => { it('maps the recorded 2024 Korean store headers', () => { const index = resolveColumnIndex(STORES_SCHEMA_2024, [...STORES_HEADERS_2024]); diff --git a/app/src/features/market/provisional.ts b/app/src/features/market/provisional.ts new file mode 100644 index 0000000..d497031 --- /dev/null +++ b/app/src/features/market/provisional.ts @@ -0,0 +1,52 @@ +import Decimal from 'decimal.js'; +import type { QuarterIndicatorRow } from './types.ts'; + +// ADR 0002: 매출 원값의 시간 단위(#3)와 점포당 분모(#12)가 공식 확정되기 전까지 쓰는 +// 잠정 해석. 계산 결과(plan_results)에 저장하지 않고, 사용자 가정에 자동으로 넣지 않는다. +export const PROVISIONAL_SALES_BASIS = { + decision: 'ADR 0002', + statement: + '서울시 추정매출 당월_매출_금액을 분기 월평균으로 해석한 잠정 값입니다. 시간 단위와 점포당 분모가 공식 확정되면 바뀔 수 있으며, 해석이 틀리면 약 3배 차이가 납니다.', + sources: [ + { + label: '서울 열린데이터광장 추정매출 데이터셋 FAQ', + url: 'https://data.seoul.go.kr/dataList/OA-15572/F/1/datasetView.do', + }, + { + label: '서울시 상권분석서비스 설명자료 8쪽', + url: 'https://culture.seoul.go.kr/culture/cmmn/file/fileDown.do?atchFileId=af365d87d23c4c5b8411d3ba236292f6&bbsId=&fileSn=2&menuNo=200051', + }, + ], +} as const; + +export type ProvisionalQuarterSales = { + quarter: string; + // 월매출(잠정) = 당월_매출_금액 원값. 분기 월평균으로 해석한다. + monthlySalesAmount: string | null; + // 점포당 월매출(잠정) = 원값 ÷ 유사_업종_점포_수. 분모가 없거나 0이면 null이다. + perStoreMonthlySalesAmount: string | null; + // 결제 1건당 평균 금액 = 원값 ÷ 당월_매출_건수. 같은 기간 값끼리 나누므로 시간 단위 해석과 무관하다. + averagePaymentAmount: string | null; +}; + +function divideWon(amount: string, divisor: Decimal.Value): string { + return new Decimal(amount).div(divisor).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toFixed(0); +} + +export function provisionalQuarterSales( + row: Pick, +): ProvisionalQuarterSales { + const amount = row.salesAmount; + return { + quarter: row.quarter, + monthlySalesAmount: amount, + perStoreMonthlySalesAmount: + amount === null || row.similarIndustryStoreCount === null || row.similarIndustryStoreCount === 0 + ? null + : divideWon(amount, row.similarIndustryStoreCount), + averagePaymentAmount: + amount === null || row.salesCount === null || new Decimal(row.salesCount).isZero() + ? null + : divideWon(amount, row.salesCount), + }; +} diff --git a/app/src/features/market/types.ts b/app/src/features/market/types.ts index 6d8c083..c36dc6f 100644 --- a/app/src/features/market/types.ts +++ b/app/src/features/market/types.ts @@ -1,3 +1,5 @@ +import type { PROVISIONAL_SALES_BASIS, ProvisionalQuarterSales } from './provisional.ts'; + export const MARKET_SCHEMA_VERSION = 'market-schema-v1.1.0'; export const MARKET_DEFINITION_VERSION = 'seoul-market-definition-2024-2025-v2'; export const INDUSTRY_CATEGORY_VERSION = 'seoul-service-industry-2024-2025'; @@ -47,7 +49,7 @@ export const INDICATOR_DEFINITIONS: readonly IndicatorDefinition[] = [ unit: '원', unitConfirmed: false, sourceColumn: { 2024: '당월_매출_금액', 2025: '당월_매출_금액' }, - note: '출처 열 그대로의 금액입니다. 월 합계인지 분기 합계인지 확정되지 않아 월매출로 환산하지 않습니다.', + note: '출처 열 그대로의 금액입니다. 시간 단위가 공식 확정되지 않았습니다. 화면의 월매출(잠정)은 이 값을 분기 월평균으로 해석한 값입니다(ADR 0002).', }, { key: 'salesCount', @@ -102,10 +104,9 @@ export const INDICATOR_DEFINITIONS: readonly IndicatorDefinition[] = [ // Shown verbatim in the API response and on the exploration screen. export const MARKET_LIMITATIONS: readonly string[] = [ '공개 상권 지표이며 개인 점포의 예상매출이 아닙니다.', - '매출 원값의 월·분기 단위가 확정되지 않아 월매출로 환산하지 않습니다.', - '점포당 매출을 계산하지 않습니다.', + '매출 원값의 시간 단위와 점포당 분모가 공식 확정되지 않았습니다. 월매출·점포당 월매출은 ADR 0002의 잠정 해석이며 원값과 함께 표시합니다.', '자료가 없는 조합은 0으로 채우지 않고 자료 부족으로 표시합니다.', - '상권 원값을 재무계획의 월매출 가정에 자동으로 입력하지 않습니다.', + '상권 원값과 잠정 값을 재무계획의 월매출 가정에 자동으로 입력하지 않습니다.', ]; export type QuarterIndicatorRow = { @@ -204,6 +205,11 @@ export type MarketReport = MarketSummary & { sourceRelease: string; asOf: string | null; nullReason: string | null; + // ADR 0002 잠정 해석. 분기 순서는 quarters와 같다. + provisionalSales: { + basis: typeof PROVISIONAL_SALES_BASIS; + quarters: ProvisionalQuarterSales[]; + }; }; export type MarketAreaList = { diff --git a/app/src/lib/format-won.test.ts b/app/src/lib/format-won.test.ts new file mode 100644 index 0000000..d990356 --- /dev/null +++ b/app/src/lib/format-won.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { formatKoreanWon, formatManWon, formatWonExact } from './format-won.ts'; + +describe('won display text', () => { + it('keeps every won when writing 억·만 units', () => { + expect(formatKoreanWon('100000000')).toBe('1억 원'); + expect(formatKoreanWon('30000000')).toBe('3,000만 원'); + expect(formatKoreanWon('123456789')).toBe('1억 2,345만 6,789원'); + expect(formatKoreanWon('0')).toBe('0원'); + expect(formatKoreanWon('9999')).toBe('9,999원'); + }); + + it('rounds a summary to 만 원 half up', () => { + expect(formatManWon('29623456')).toBe('2,962만 원'); + expect(formatManWon('29625000')).toBe('2,963만 원'); + expect(formatManWon('4473000000')).toBe('44억 7,300만 원'); + expect(formatManWon('68604')).toBe('7만 원'); + expect(formatManWon('6789')).toBe('6,789원'); + expect(formatManWon('-56000000')).toBe('-5,600만 원'); + }); + + it('writes the exact source value with separators only', () => { + expect(formatWonExact('184219542')).toBe('184,219,542원'); + }); +}); diff --git a/app/src/lib/format-won.ts b/app/src/lib/format-won.ts new file mode 100644 index 0000000..1117dd0 --- /dev/null +++ b/app/src/lib/format-won.ts @@ -0,0 +1,41 @@ +// 화면 표시용 금액 문구. API와 계산은 원 단위 정수 문자열을 그대로 쓰고, 여기서는 +// 읽기 쉬운 억·만 단위 문장만 만든다. + +const numberFormat = new Intl.NumberFormat('ko-KR'); + +const EOK = 100000000n; +const MAN = 10000n; + +function joinUnits(won: bigint): string { + const negative = won < 0n; + let rest = negative ? -won : won; + const eok = rest / EOK; + rest %= EOK; + const man = rest / MAN; + const unit = rest % MAN; + const parts: string[] = []; + if (eok > 0n) parts.push(`${numberFormat.format(eok)}억`); + if (man > 0n) parts.push(`${numberFormat.format(man)}만`); + const endsWithUnit = unit === 0n && parts.length > 0; + if (!endsWithUnit) parts.push(numberFormat.format(unit)); + return `${negative ? '-' : ''}${parts.join(' ')}${endsWithUnit ? ' 원' : '원'}`; +} + +/** 정확한 금액을 억·만 단위로 쓴다. 예: 123456789 → "1억 2,345만 6,789원" */ +export function formatKoreanWon(value: string): string { + return joinUnits(BigInt(value)); +} + +/** 만 원 단위로 반올림(0.5 이상 올림)한 요약 금액. 1만 원 미만은 그대로 쓴다. */ +export function formatManWon(value: string): string { + const won = BigInt(value); + const magnitude = won < 0n ? -won : won; + if (magnitude < MAN) return joinUnits(won); + const rounded = ((magnitude + MAN / 2n) / MAN) * MAN; + return joinUnits(won < 0n ? -rounded : rounded); +} + +/** 원 단위 정수 문자열을 천 단위 구분만 넣어 쓴다. */ +export function formatWonExact(value: string): string { + return `${numberFormat.format(BigInt(value))}원`; +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19b73a9..b808659 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,6 +41,7 @@ Operator load commands (*:load) | 책임 | 위치 | |---|---| | 페이지·Route Handler | `app/src/app` | +| 여러 화면이 함께 쓰는 UI(보고서 상단 탭) | `app/src/components` | | DB schema·migration | `app/prisma` | | 운영 적재·검증·하네스 명령 | `app/scripts` | | 브라우저 종단 흐름 (desktop·mobile) | `app/e2e/`: `core-flow.spec.ts`(가입 → 저장·계산 → 재조회 → 소유권 차단), `rent-benchmark.spec.ts`, `franchise.spec.ts` | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index be22a32..67ab277 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -16,7 +16,7 @@ | 영역 | 제공 범위 | 기준 | |---|---|---| -| 상권 분석(`/markets`, 공개) | 상권별 매출 금액·건수, 점포·개폐업, 요일·시간대·성별·연령대 구성 | 서울시 상권분석서비스 상위 업종, 2024년 1분기부터 적재 시점의 최신 분기까지 | +| 상권 분석(`/markets`, 공개) | 상권별 보고서: 매출 금액·건수, 월매출·점포당 월매출(잠정), 결제 1건당 평균 금액, 점포·개폐업, 요일·시간대·성별·연령대 구성 | 서울시 상권분석서비스 상위 업종, 2024년 1분기부터 적재 시점의 최신 분기까지. 잠정 값은 [ADR 0002](decisions/0002-provisional-market-sales.md)를 따름 | | 경쟁 점포 | 자치구·세부 업종별 점포 수와 최대 50개 목록 | 소진공 상가(상권)정보 스냅샷 | | 사업 조건 | 자치구, 상위·세부 업종, 면적, 층, 상가 유형 | 계획에 재무 입력과 분리해 저장 | | 임대료 참고 | 조사 상권별 ㎡당 월 임대료(대표·층별), 공실률, 추이, 면적을 곱한 월 참고값 | 한국부동산원 상업용부동산 임대동향조사 서울, 2024년 3분기부터 적재 시점의 최신 분기까지. 재무 입력에 자동으로 넣지 않음 | @@ -31,7 +31,7 @@ ## 제외 범위 - 세부 업종 매출·고객·배달 분석: 공식 출처가 없다. -- 월 환산과 점포당 매출: 공식 정의가 확정되지 않아 아직 제공하지 않는다. 확정 전에 제공할 때는 [ADR 0002](decisions/0002-provisional-market-sales.md)의 잠정 해석을 따른다. +- 확정된 월 환산과 점포당 매출: 공식 정의가 확정되지 않아 [ADR 0002](decisions/0002-provisional-market-sales.md)의 잠정 값으로만 보여 주고, 계산 결과에 저장하지 않는다. - 특정 점포의 임대료 시세 추정: 부동산원 값은 조사 상권의 표본 평균이다. - 프랜차이즈 개인 예상매출과 창업 금액 세부 내역: 공정위 수치는 전국 평균이고, 기타 금액의 세부 항목과 기준 점포 면적은 정보공개서 본문에만 있다. - POS 연동, 예측 ML, 종합 점수, 신용 판단, 자동 승인, 지도, 주소 자동 매핑, 전국 확대, 자동 공고 해석 diff --git a/docs/domains/market.md b/docs/domains/market.md index 90fa53c..bec8080 100644 --- a/docs/domains/market.md +++ b/docs/domains/market.md @@ -27,6 +27,10 @@ - `/api/markets/areas?districtCode=...`: 자치구 목록은 항상 반환하고, `districtCode`가 있으면 그 자치구의 상권을 반환한다. 없는 자치구 코드는 400 `INVALID_DISTRICT_CODE`다. - `/api/markets/summary?areaCode=...&industryCode=...[&areaType=...]`: 가용 분기를 오래된 순으로, 분기별 원본 지표를 반환한다. 매출 원값은 원 단위 정수 문자열이고 점포 수는 숫자다. - `/api/markets/report?areaCode=...&industryCode=...[&areaType=...]`: summary에 상위 업종 집계 범위, 기준분기, 요일·시간대·성별·연령별 원본 구성을 더한다. + - `provisionalSales`에 분기별 [ADR 0002](../decisions/0002-provisional-market-sales.md) 잠정 값과 근거 링크를 담는다. 계산은 `app/src/features/market/provisional.ts`가 한다. + - 월매출(잠정)은 매출 원값 그대로, 점포당 월매출(잠정)은 원값 ÷ 유사 업종 점포 수, 결제 1건당 평균 금액은 원값 ÷ 매출 건수다. 원 단위 정수 문자열이며 `ROUND_HALF_UP`로 반올림한다. + - 원값이나 분모가 없거나 분모가 0이면 `null`이다. 0으로 채우지 않는다. + - `/markets` 보고서는 이 값 옆에 "잠정"과 근거, 원값을 함께 표시한다. 계산 결과(`plan_results`)에 저장하지 않고 재무 가정에 자동으로 넣지 않는다. - 상태 구분: 활성 릴리스 없음 503 `RELEASE_UNAVAILABLE`, 형식 오류 400 `INVALID_AREA_CODE`·`INVALID_INDUSTRY_CODE`, 없는 상권 404 `AREA_NOT_FOUND`, 같은 코드가 여러 상권 구분에 있으면 400 `AMBIGUOUS_AREA_CODE`, 원천에 있으나 미지원 업종은 400 `UNSUPPORTED_INDUSTRY`다. - 자료가 없는 조합은 0이 아니라 `dataStatus: NOT_PROVIDED`와 빈 `quarters`로, 매출만 없는 분기는 `salesStatus: NOT_PROVIDED`와 `salesAmount: null`로 응답한다. - 응답에는 지표 정의(원본 열·단위·해석 제한)와 제한 문구, 릴리스 출처·기준기간·입수일·파일별 SHA-256을 함께 담는다.