feat(B09): 근거 사전 네 장 추가·칸 단위 최종 등급·사전 파일 셋으로 분리

- 자재단가대비표: 적용 칸을 calc+rule(기.가·유.물·견적), 값 없는 원천 칸은 blocked
- 환율및기초자료: 공표·공시값 standard 와 사용자가 고른 유가 범위 input 을 가름
- 단가산출근거·산출기초: 모으는 표라 식을 비우고 source·등급만 채움
- 설계서 구성표·산출 조건 패널은 일부러 뺌(프로젝트 값이 아니거나 근거 한 줄과 중복)
- 원가계산서 금액은 총원가·도급금액·총계 세 줄에만 칸 단위 final 을 심음
- 700줄을 넘어 Provenance / _Sources / _Common 셋으로 가름(공통 조각을 한쪽에 두면
  불러들이기가 고리를 이룸)

사전 16장 92열, 등급 여덟이 모두 쓰임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
This commit is contained in:
2026-09-12 16:15:03 +09:00
co-authored by Claude Opus 5
parent 6d1ef43491
commit aadb4f6756
6 changed files with 424 additions and 61 deletions
+53 -10
View File
@@ -402,6 +402,7 @@ export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto):
export interface BasisSheetDto {
status: string;
provenance?: ProvenancePayload;
note: string;
summary: string;
dataset_versions: Array<{
@@ -440,6 +441,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
row.sha256 || "—",
]),
[0, 1, 2, 3],
["dataset_id", "file", "effective_date", "sha256"],
data.provenance?.sheets?.basis_versions,
),
);
@@ -452,6 +455,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
["항 목", "고른 값"],
data.chosen_conditions.map((row) => [row.item, row.value]),
[0, 1],
["item", "value"],
data.provenance?.sheets?.basis_chosen,
),
);
}
@@ -462,6 +467,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
["코드", "공 종", "단위", "근 거"],
data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]),
[0, 1, 2, 3],
["code", "name", "unit", "notes"],
data.provenance?.sheets?.basis_items,
),
);
@@ -474,6 +481,8 @@ export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
["갈 래", "코드", "사 유"],
data.gaps.map((row) => [row.kind, row.code, row.reason]),
[0, 1, 2],
["kind", "code", "reason"],
data.provenance?.sheets?.basis_gaps,
),
);
body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다."));
@@ -522,6 +531,7 @@ export interface FuelScope {
export interface PriceSourcesDto {
status: string;
provenance?: ProvenancePayload;
material_comparison: {
slot_names: string[];
rows: MaterialComparisonRow[];
@@ -568,7 +578,11 @@ export async function fetchPriceSources(projectId: string): Promise<PriceSources
* 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다.
* 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다.
*/
function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HTMLElement {
function comparisonTable(
slotNames: string[],
rows: MaterialComparisonRow[],
sheet?: ProvenanceSheet,
): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
@@ -609,35 +623,45 @@ function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HT
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
const put = (text: string, left = false): void => {
/** 칸 하나 — `key` 를 주면 근거 호버가 붙는다(사전에 없는 열은 아무 일도 안 한다). */
const put2 = (td: HTMLElement, key: string, tier?: string): void => {
const column = sheet?.columns[key];
if (column) markProvenanceCell(td, key, tier ?? column.tier);
};
const put = (text: string, left = false, key?: string): void => {
const td = document.createElement("td");
td.textContent = text;
if (left) td.className = "b09-left";
if (key) put2(td, key);
tr.append(td);
};
put(row.code, true);
put(row.name, true);
put(row.spec, true);
put(row.unit);
put(row.code, true, "code");
put(row.name, true, "name");
put(row.spec, true, "spec");
put(row.unit, false, "unit");
for (const slot of row.slots) {
const td = document.createElement("td");
td.textContent = money(slot.price_krw);
// 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다.
if (slot.adopted) td.style.fontWeight = "700";
// ⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — 막힌 자리로 표시한다.
put2(td, "slot_price", slot.price_krw === null ? "blocked" : undefined);
tr.append(td);
const page = document.createElement("td");
page.textContent = slot.source_note;
page.className = "b09-left";
put2(page, "slot_page");
tr.append(page);
}
if (!appliedIsLastSlot) {
put(money(row.adopted_price_krw));
put(money(row.adopted_price_krw), false, "adopted_price_krw");
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
}
put(row.note, true);
put(row.note, true, "note");
tbody.append(tr);
}
el.append(thead, tbody);
attachProvenance(el, sheet);
return el;
}
@@ -647,6 +671,8 @@ function baseReferenceSections(
data: PriceSourcesDto["base_reference"],
projectId: string,
reload: () => void,
laborSheet?: ProvenanceSheet,
fuelSheet?: ProvenanceSheet,
): void {
body.append(head("환율및기초자료 — ① 환율"));
body.append(note(data.exchange.note));
@@ -666,6 +692,8 @@ function baseReferenceSections(
row.formula,
]),
[0, 1, 4],
["code", "name", "day_wage_krw", "hourly_krw", "formula"],
laborSheet,
),
);
}
@@ -693,6 +721,8 @@ function baseReferenceSections(
],
],
[0, 2, 3, 4],
["item", "price_krw", "scope", "effective_date", "dataset_id"],
fuelSheet,
),
);
@@ -765,11 +795,24 @@ export function drawPriceSourcesSections(
if (comparison.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(comparisonTable(comparison.slot_names, comparison.rows));
body.append(
comparisonTable(
comparison.slot_names,
comparison.rows,
data.provenance?.sheets?.material_comparison,
),
);
}
for (const text of comparison.notes) body.append(note(text));
baseReferenceSections(body, data.base_reference, projectId, reload);
baseReferenceSections(
body,
data.base_reference,
projectId,
reload,
data.provenance?.sheets?.base_reference_labor,
data.provenance?.sheets?.base_reference_fuel,
);
}
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */