+
+ {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)}
+
+ )}
+
+ );
+ })}
+
+
+ {points.map((point) => (
+ {point.label}
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+
+ {segments.map((segment) => (
+
+ ))}
+
+ {points.map((point, index) => {
+ const text = tip(point, format);
+ if (point.value === null) {
+ return (
+
+ 자료 부족
+
+ );
+ }
+ return (
+
+ {point.key === strong && {format(point.value)} }
+
+ );
+ })}
+
+
+ {points.map((point) => (
+ {point.label}
+ ))}
+
+
+ );
+}
+
+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을 함께 담는다.