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:
@@ -30,14 +30,20 @@ from common_util.common_util_provenance import (
|
||||
TIER_CALC,
|
||||
TIER_EXCLUDED,
|
||||
TIER_FINAL,
|
||||
TIER_INPUT,
|
||||
TIER_STANDARD,
|
||||
TIER_SURVEY,
|
||||
TIER_UNCLASSIFIED,
|
||||
ColumnProvenance,
|
||||
provenance_payload,
|
||||
sheet_provenance,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Provenance_Common import _label, _note_column
|
||||
from B09_Estimation.B09_Estimation_Provenance_Sources import (
|
||||
base_reference_fuel_sheet,
|
||||
base_reference_labor_sheet,
|
||||
basis_sheet_tables,
|
||||
material_comparison_sheet,
|
||||
price_basis_sheet,
|
||||
)
|
||||
|
||||
#: 요율이 어디서 오는지 — 원가계산서 여러 열이 같은 문장을 쓴다.
|
||||
_RATE_SOURCE = (
|
||||
@@ -46,40 +52,6 @@ _RATE_SOURCE = (
|
||||
"어느 판으로 섰는지는 좌측 패널 「요율 판」과 산출기초 ① 에 지문까지 남음"
|
||||
)
|
||||
|
||||
#: 이름표 열(코드·명칭·규격·단위)이 공통으로 쓰는 문장.
|
||||
_CATALOG_SOURCE = "단가판·품셈 표의 이름을 그대로 옮긴 자리 — 여기서 짓지 않음"
|
||||
|
||||
#: 「비고」가 왜 미분류인지 — 표마다 같은 말을 쓴다.
|
||||
_NOTE_RULE = (
|
||||
"이 칸은 등급을 붙일 열이 아니라 **다른 열의 사유를 담는 그릇**이다. "
|
||||
"안에 든 조각마다 닿는 열이 다르므로(갈래 근거는 규격, 반영률은 수량, "
|
||||
"막힘 사유는 단가) 호버는 조각을 그 열의 칸에만 띄운다"
|
||||
)
|
||||
|
||||
|
||||
def _label(key: str, label: str, extra: str = "") -> ColumnProvenance:
|
||||
"""이름표 열 — 값을 낳은 것이 아니라 옮겨 적은 자리."""
|
||||
return ColumnProvenance(
|
||||
key=key,
|
||||
label=label,
|
||||
tier=TIER_STANDARD,
|
||||
formula="옮겨 적은 값 (여기서 계산하지 않음)",
|
||||
source=_CATALOG_SOURCE + (f" · {extra}" if extra else ""),
|
||||
)
|
||||
|
||||
|
||||
def _note_column(code: str) -> ColumnProvenance:
|
||||
"""「비고」 열 — 여섯 어디에도 안 맞아 `unclassified` 로 둔다(PLAN 8-36 ㉮)."""
|
||||
return ColumnProvenance(
|
||||
key="note",
|
||||
label="비고",
|
||||
tier=TIER_UNCLASSIFIED,
|
||||
formula="줄에 달린 사유 조각을 차례로 이어 붙인 글",
|
||||
source="조각마다 원천이 다름 — 조각별 원천은 그 조각이 닿는 열의 카드에 뜸",
|
||||
rule=_NOTE_RULE,
|
||||
code=code,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ① 공사원가계산서
|
||||
@@ -460,9 +432,11 @@ def estimation_provenance() -> dict[str, Any] | None:
|
||||
|
||||
시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다.
|
||||
|
||||
⚠ 아직 사전이 없는 장 — 단가산출근거·설계서 구성·산출기초·환율및기초자료·
|
||||
자재단가대비표·산출 조건. 앞 넷은 **값을 낳는 표가 아니라 모으는 표**라
|
||||
열 사전보다 먼저 「사전 대상인가」를 정해야 한다(PLAN 8-36 ㉴).
|
||||
⚠ **일부러 안 붙인 둘** (2026-09-12 두 창 합의)
|
||||
· **설계서 구성표** — 프로젝트가 낳은 값이 아니라 「무슨 문서를 낼 것인가」 목록이라
|
||||
원천도 식도 없다. 없는 것을 지어 붙이면 「분류가 있다」는 거짓만 남는다(조사표 ㉴).
|
||||
· **산출 조건(좌측 패널)** — 표가 아니라 입력 칸이고, 칸 밑 근거 한 줄이 이미 같은
|
||||
일을 한다. 두 벌이 되면 어긋난다.
|
||||
"""
|
||||
return provenance_payload(
|
||||
{
|
||||
@@ -474,5 +448,10 @@ def estimation_provenance() -> dict[str, Any] | None:
|
||||
"material_unknown": sheet_provenance(_material_columns(excluded=True)),
|
||||
"machine": machine_sheet(),
|
||||
"catalog": catalog_sheet(),
|
||||
"material_comparison": material_comparison_sheet(),
|
||||
"base_reference_labor": base_reference_labor_sheet(),
|
||||
"base_reference_fuel": base_reference_fuel_sheet(),
|
||||
"price_basis": price_basis_sheet(),
|
||||
**basis_sheet_tables(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""B09 근거 사전이 **여러 장에서 같이 쓰는 조각** (PLAN 8-36 ④).
|
||||
|
||||
왜 따로 두나
|
||||
사전이 두 파일로 갈리면서(값을 낳는 장 / 바깥에서 오거나 모으는 장) 두 쪽이 같은
|
||||
이름표 열과 같은 「비고」 설명을 쓴다. 한쪽에 두고 다른 쪽이 가져가면 **고리가 생겨**
|
||||
(`Provenance` → `Provenance_Sources` → `Provenance`) 불러들이지 못한다.
|
||||
그래서 두 쪽이 함께 바라보는 자리를 따로 뒀다.
|
||||
|
||||
⚠ 여기에는 **여러 장이 실제로 같이 쓰는 것만** 둔다. 한 장만 쓰는 문장을 여기로 올리면
|
||||
사전을 읽을 때 그 장에서 눈이 떠나 버린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from common_util.common_util_provenance import (
|
||||
TIER_STANDARD,
|
||||
TIER_UNCLASSIFIED,
|
||||
ColumnProvenance,
|
||||
)
|
||||
|
||||
#: 이름표 열(코드·명칭·규격·단위)이 공통으로 쓰는 문장.
|
||||
CATALOG_SOURCE = "단가판·품셈 표의 이름을 그대로 옮긴 자리 — 여기서 짓지 않음"
|
||||
|
||||
#: 「비고」가 왜 미분류인지 — 표마다 같은 말을 쓴다.
|
||||
NOTE_RULE = (
|
||||
"이 칸은 등급을 붙일 열이 아니라 **다른 열의 사유를 담는 그릇**이다. "
|
||||
"안에 든 조각마다 닿는 열이 다르므로(갈래 근거는 규격, 반영률은 수량, "
|
||||
"막힘 사유는 단가) 호버는 조각을 그 열의 칸에만 띄운다"
|
||||
)
|
||||
|
||||
|
||||
def _label(key: str, label: str, extra: str = "") -> ColumnProvenance:
|
||||
"""이름표 열 — 값을 낳은 것이 아니라 옮겨 적은 자리."""
|
||||
return ColumnProvenance(
|
||||
key=key,
|
||||
label=label,
|
||||
tier=TIER_STANDARD,
|
||||
formula="옮겨 적은 값 (여기서 계산하지 않음)",
|
||||
source=CATALOG_SOURCE + (f" · {extra}" if extra else ""),
|
||||
)
|
||||
|
||||
|
||||
def _note_column(code: str) -> ColumnProvenance:
|
||||
"""「비고」 열 — 여덟 어디에도 안 맞아 `unclassified` 로 둔다(PLAN 8-36 ㉮)."""
|
||||
return ColumnProvenance(
|
||||
key="note",
|
||||
label="비고",
|
||||
tier=TIER_UNCLASSIFIED,
|
||||
formula="줄에 달린 사유 조각을 차례로 이어 붙인 글",
|
||||
source="조각마다 원천이 다름 — 조각별 원천은 그 조각이 닿는 열의 카드에 뜸",
|
||||
rule=NOTE_RULE,
|
||||
code=code,
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""B09 **바깥에서 온 값**과 **모으는 표**의 근거 사전 (PLAN 8-36 ④).
|
||||
|
||||
왜 파일을 갈랐나
|
||||
`B09_Estimation_Provenance.py` 가 700줄을 넘었다(CLAUDE.md 4장). 가르는 금은
|
||||
**값을 낳는 장**과 **값이 바깥에서 오거나 모으기만 하는 장**이다 —
|
||||
· 저쪽: 원가계산서·내역서·일위대가·자재대·중기·기초자료 목록표
|
||||
· 이쪽: 자재단가대비표·환율및기초자료·단가산출근거·산출기초
|
||||
이쪽 넷은 식이 얇거나 아예 없고, 대신 **「어느 판에서 왔나」**가 알맹이다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_provenance import (
|
||||
TIER_BLOCKED,
|
||||
TIER_CALC,
|
||||
TIER_INPUT,
|
||||
TIER_STANDARD,
|
||||
TIER_UNCLASSIFIED,
|
||||
ColumnProvenance,
|
||||
sheet_provenance,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Provenance_Common import _label, _note_column
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ⑥ 자재단가대비표 — **원천 다섯 중 하나를 고르는** 자리
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def material_comparison_sheet() -> dict[str, Any]:
|
||||
"""실무 시트의 「기.가 · 유.물 · 견적」 약호가 곧 채택 근거다.
|
||||
|
||||
⚠ 원천 칸이 **여럿이라도 뜻은 하나**다(어느 판이 얼마라 했나). 슬롯마다 열 키를
|
||||
따로 두면 사전이 다섯 벌이 되고 판이 늘 때마다 어긋난다 — 한 키를 나눠 쓴다.
|
||||
"""
|
||||
return sheet_provenance(
|
||||
[
|
||||
_label("code", "코드번호"),
|
||||
_label("name", "명 칭"),
|
||||
_label("spec", "규 격"),
|
||||
_label("unit", "단위"),
|
||||
ColumnProvenance(
|
||||
key="slot_price",
|
||||
label="원천 단가",
|
||||
tier=TIER_STANDARD,
|
||||
formula="그 판이 적어 놓은 값을 그대로 옮김",
|
||||
source="물가지·거래가격·업체 견적 등 판마다 다름 — 오른쪽 「페이지」 칸이 "
|
||||
"어느 쪽에서 왔는지 가리킨다",
|
||||
rule="⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — "
|
||||
"0 으로 때우지 않는다",
|
||||
code="B09_Estimation_Lists_Sources.py:79 material_price_comparison",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="slot_page",
|
||||
label="페이지",
|
||||
tier=TIER_STANDARD,
|
||||
formula="그 값이 실린 쪽수·출처 표기",
|
||||
source="실무 시트의 「기.가 1,024」 같은 약호 — 사람이 원문을 찾아갈 열쇠",
|
||||
code="B09_Estimation_Lists_Sources.py:79 material_price_comparison",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="adopted_price_krw",
|
||||
label="적 용",
|
||||
tier=TIER_CALC,
|
||||
formula="원천 다섯 중 **채택한 하나**의 값",
|
||||
source="채택한 칸은 표에서 굵게 선다 — 표가 스스로 「어느 값을 썼나」를 밝힌다",
|
||||
rule="⚠ **값 안에 고름이 숨은 열**(PLAN 8-36 ㉯). 실무 약호가 곧 채택 규칙이다 "
|
||||
"— 「기.가」(정부 기준가격) · 「유.물」(물가정보) · 「견적」(업체). "
|
||||
"슬롯 6 이 곧 적용 단가라 그 자리에 값이 서면 「적용」 칸을 따로 안 세운다",
|
||||
code="B09_Estimation_Lists_Sources.py:79 material_price_comparison",
|
||||
),
|
||||
_note_column("B09_Estimation_Lists_Sources.py:79"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ⑦ 환율및기초자료 — **바깥에서 온 값**과 **우리가 고른 것**이 갈리는 장
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def base_reference_labor_sheet() -> dict[str, Any]:
|
||||
"""② 인건비 — 공표 노임을 시간당으로 푼 표."""
|
||||
return sheet_provenance(
|
||||
[
|
||||
_label("code", "코드번호"),
|
||||
_label("name", "직 종"),
|
||||
ColumnProvenance(
|
||||
key="day_wage_krw",
|
||||
label="일 당",
|
||||
tier=TIER_STANDARD,
|
||||
formula="공표 노임을 그대로 옮김",
|
||||
source="대한건설협회 시중노임 공표값. ⚠ **기본급여액뿐**이라 제수당·상여금·"
|
||||
"퇴직급여충당금은 따로 계상해야 한다",
|
||||
rule="표본이 얇은 직종(조사현장 5곳 미만 `*` · 미조사 `**`)은 금액은 서되 "
|
||||
"그 사실을 화면이 따로 알린다",
|
||||
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="hourly_krw",
|
||||
label="시간당",
|
||||
tier=TIER_CALC,
|
||||
formula="일당 ÷ 8시간",
|
||||
source="⚠ **나눈 값을 자르지 않고 그대로 둔다** — 여기서 원 단위로 자르면 "
|
||||
"기계 시간당 사용료가 조금씩 어긋난다. 자르는 자리는 일위대가·내역서 쪽",
|
||||
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="formula",
|
||||
label="산 식",
|
||||
tier=TIER_CALC,
|
||||
formula="그 줄이 실제로 쓴 셈을 사람이 읽게 적은 한 줄",
|
||||
source="서버가 값과 같이 지음 — 화면이 따로 짓지 않는다",
|
||||
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def base_reference_fuel_sheet() -> dict[str, Any]:
|
||||
"""③ 단가 및 재료비 — 유가 한 줄. 지역을 고르면 기계 연료비가 다시 선다."""
|
||||
return sheet_provenance(
|
||||
[
|
||||
_label("item", "항 목"),
|
||||
ColumnProvenance(
|
||||
key="price_krw",
|
||||
label="단 가",
|
||||
tier=TIER_STANDARD,
|
||||
formula="공시가를 그대로 옮김 (전국 또는 고른 시도)",
|
||||
source="한국석유공사 공시가. 이 값이 바뀌면 **기계 연료비가 통째로 다시 선다**",
|
||||
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="scope",
|
||||
label="적용 범위",
|
||||
tier=TIER_INPUT,
|
||||
formula="사용자가 고른 유가 범위 (전국 공시가 또는 현장 시도)",
|
||||
source="안 고르면 전국 공시가로 선다 — 고른 적이 없다는 뜻이지 "
|
||||
"「전국이 맞다」는 뜻이 아니다",
|
||||
rule="⚠ **자료가 없는 시도는 고를 수 없게 둔다.** 고르게만 해 두고 값이 없으면 "
|
||||
"조용히 틀린 값이 선다",
|
||||
code="B09_Estimation_Lists_Sources.py:44 fuel_scopes",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="effective_date",
|
||||
label="기준일",
|
||||
tier=TIER_STANDARD,
|
||||
formula="그 공시가의 기준일",
|
||||
source="어느 판으로 섰는지는 산출기초 ① 에 지문까지 남는다",
|
||||
code="B09_Estimation_Lists_Sources.py:137 base_reference_data",
|
||||
),
|
||||
_label("dataset_id", "자료", "판 이름 — 산출기초 ① 의 같은 낱말"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ⑧ 모으는 표 둘 — **값을 낳지 않는다.** 식은 비우고 「어디서 왔나」만 적는다
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def price_basis_sheet() -> dict[str, Any]:
|
||||
"""단가산출근거 목록 — 내역서 줄의 단가가 어느 일위대가에서 왔는지.
|
||||
|
||||
⚠ **여기서 값을 다시 셈하지 않는다.** 그래서 `formula` 를 비웠다 — 없는 식을
|
||||
지어 적으면 읽는 사람이 「여기서 계산한다」고 잘못 읽는다.
|
||||
"""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="number",
|
||||
label="번호",
|
||||
tier=TIER_STANDARD,
|
||||
source="내역서에 **쓰인 차례**대로 매긴 번호 — 실무 참조번호(「단산 46」)가 "
|
||||
"곧 이 번호다",
|
||||
code="B09_Estimation_PriceBasis.py:74 build_price_basis",
|
||||
),
|
||||
_label("name", "공종", "공종 이름 + 규격"),
|
||||
_label("unit", "단위"),
|
||||
ColumnProvenance(
|
||||
key="unit_price_krw",
|
||||
label="단가",
|
||||
tier=TIER_CALC,
|
||||
source="한 층 아래 일위대가 본표의 합계를 **그대로 옮긴 값** — 그 표는 "
|
||||
"일위대가 탭에서 본다(본문 「참조」가 그 코드를 가리킨다)",
|
||||
code="B09_Estimation_PriceBasis.py:74 build_price_basis",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def basis_sheet_tables() -> dict[str, dict[str, Any]]:
|
||||
"""산출기초 네 표. **모으기만 하는 장**이라 식이 없다.
|
||||
|
||||
⚠ 이 장이 답하는 물음은 「이 값이 얼마인가」가 아니라 **「어느 판·어느 근거로
|
||||
섰나」** 다. 그래서 `source` 와 등급만 채운다.
|
||||
"""
|
||||
versions = sheet_provenance(
|
||||
[
|
||||
_label("dataset_id", "자료", "판 이름"),
|
||||
_label("file", "파일", "그 판의 파일 이름"),
|
||||
ColumnProvenance(
|
||||
key="effective_date",
|
||||
label="기준일",
|
||||
tier=TIER_STANDARD,
|
||||
source="그 판이 언제 것인가 — 값이 달라졌을 때 가장 먼저 보는 자리",
|
||||
code="B09_Estimation_BasisSheet.py:44 dataset_versions",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="sha256",
|
||||
label="지문(앞 12)",
|
||||
tier=TIER_UNCLASSIFIED,
|
||||
source="파일 해시. 값도 근거도 아니고 **재현성 표식**이라 여섯 어디에도 "
|
||||
"안 맞는다(PLAN 8-36 ㉰). 「같은 판으로 다시 세웠나」를 가르는 데만 쓴다",
|
||||
code="B09_Estimation_BasisSheet.py:44 dataset_versions",
|
||||
),
|
||||
]
|
||||
)
|
||||
chosen = sheet_provenance(
|
||||
[
|
||||
_label("item", "항 목", "고를 수 있는 자리의 이름"),
|
||||
ColumnProvenance(
|
||||
key="value",
|
||||
label="고른 값",
|
||||
tier=TIER_INPUT,
|
||||
source="사용자가 산출 조건에서 고른 값 — 비어 있으면 **고른 적이 없어 "
|
||||
"확정 기본값으로 돌고 있다**는 뜻이다",
|
||||
code="B09_Estimation_BasisSheet.py:77 chosen_conditions",
|
||||
),
|
||||
]
|
||||
)
|
||||
items = sheet_provenance(
|
||||
[
|
||||
_label("code", "코드"),
|
||||
_label("name", "공 종"),
|
||||
_label("unit", "단위"),
|
||||
ColumnProvenance(
|
||||
key="notes",
|
||||
label="근 거",
|
||||
tier=TIER_STANDARD,
|
||||
source="그 공종의 단가가 **어느 품셈 표·어느 [주]** 를 따랐는지. 줄에 달린 "
|
||||
"근거를 모아 온 것이라 여기서 새로 짓지 않는다",
|
||||
code="B09_Estimation_BasisSheet.py:106 work_item_basis",
|
||||
),
|
||||
]
|
||||
)
|
||||
gaps = sheet_provenance(
|
||||
[
|
||||
_label("kind", "갈 래", "못 채운 사유의 갈래"),
|
||||
_label("code", "코드"),
|
||||
ColumnProvenance(
|
||||
key="reason",
|
||||
label="사 유",
|
||||
tier=TIER_BLOCKED,
|
||||
source="⚠ **0 으로 때우지 않고 남겨 둔 자리.** 근거가 오면 채워질 자리이지 "
|
||||
"「세면 안 되는 자리」가 아니다",
|
||||
code="B09_Estimation_BasisSheet.py:134 open_gaps",
|
||||
),
|
||||
]
|
||||
)
|
||||
return {
|
||||
"basis_versions": versions,
|
||||
"basis_chosen": chosen,
|
||||
"basis_items": items,
|
||||
"basis_gaps": gaps,
|
||||
}
|
||||
@@ -320,13 +320,15 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"material_comparison": material_price_comparison(build),
|
||||
"base_reference": base_reference_data(
|
||||
build, str(settings.get("fuel_region") or "") or None
|
||||
),
|
||||
}
|
||||
content=_with_provenance(
|
||||
{
|
||||
"status": "success",
|
||||
"material_comparison": material_price_comparison(build),
|
||||
"base_reference": base_reference_data(
|
||||
build, str(settings.get("fuel_region") or "") or None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 단가 원천 표 실패: project_id=%s", project_id)
|
||||
@@ -349,7 +351,9 @@ async def get_basis_sheet(project_id: UUID) -> JSONResponse:
|
||||
build = await _build_for(project_id)
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
return JSONResponse(content={"status": "success", **basis_sheet(build, settings)})
|
||||
return JSONResponse(
|
||||
content=_with_provenance({"status": "success", **basis_sheet(build, settings)})
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 산출기초 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
|
||||
|
||||
@@ -337,7 +337,16 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
|
||||
note.textContent = line.note;
|
||||
|
||||
mark(name, prov, "name");
|
||||
mark(amount, prov, "amount_krw");
|
||||
// ⚠ **같은 열 안에서 줄마다 등급이 갈리는 첫 자리.** 중간줄(간접노무비 따위)은
|
||||
// `calc` 인데 마지막줄 셋은 계약으로 나가는 `final` 이다. 열 사전은 등급이 하나뿐이라
|
||||
// 여기서 칸에 덮어 심는다 — 나머지 칸은 생략해 열 등급을 그대로 물려받는다.
|
||||
const isFinalLine =
|
||||
line.key === "total_cost" || line.key === "contract_amount" || line.key === "grand_total";
|
||||
const amountColumn = prov?.columns.amount_krw;
|
||||
// 등급을 빼면 공용 쪽이 열 등급으로 채워 주지만, **여기서 명시**해 두면 그 채움이
|
||||
// 없는 판에서도 띠 색이 제대로 붙는다.
|
||||
if (amountColumn)
|
||||
markProvenanceCell(amount, "amount_krw", isFinalLine ? "final" : amountColumn.tier);
|
||||
mark(rate, prov, "rate_percent");
|
||||
mark(basis, prov, "formula_text");
|
||||
mark(note, prov, "note");
|
||||
@@ -1163,19 +1172,23 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
const list = document.createElement("table");
|
||||
list.className = "b09-sheet b09-up-list";
|
||||
list.innerHTML = "<thead><tr><th>번호</th><th>공종</th><th>단위</th><th>단가</th></tr></thead>";
|
||||
// 모으기만 하는 표라 사전에 **식이 없다** — 「어느 표에서 왔나」만 카드에 뜬다.
|
||||
const prov = bill.provenance?.sheets?.price_basis;
|
||||
const pbKeys = ["number", "name", "unit", "unit_price_krw"];
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const entry of entries) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const text of [
|
||||
[
|
||||
String(entry.number),
|
||||
`${entry.name} ${entry.spec}`.trim(),
|
||||
entry.unit,
|
||||
entry.unit_price_krw,
|
||||
]) {
|
||||
].forEach((text, index) => {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = text;
|
||||
mark(td, prov, pbKeys[index]);
|
||||
tr.append(td);
|
||||
}
|
||||
});
|
||||
tr.style.cursor = "pointer";
|
||||
if (entry.code === priceBasis) tr.style.fontWeight = "600";
|
||||
tr.addEventListener("click", () => {
|
||||
@@ -1185,6 +1198,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
tbody.append(tr);
|
||||
}
|
||||
list.append(tbody);
|
||||
attachProvenance(list, prov);
|
||||
split.append(list);
|
||||
|
||||
const picked = entries.find((entry) => entry.code === priceBasis) ?? null;
|
||||
@@ -1320,6 +1334,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
void fetchBasisSheet(projectId)
|
||||
.then((data) => {
|
||||
basisSheet = data;
|
||||
noteProvenance(data.provenance);
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -1426,6 +1441,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
void fetchPriceSources(projectId)
|
||||
.then((data) => {
|
||||
priceSources = data;
|
||||
noteProvenance(data.provenance);
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
Reference in New Issue
Block a user