From 043222bed2f7cc6575e21f43ddb1b924069725da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:25:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B09):=20=EA=B8=B0=EC=B4=88=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=C2=B7=EC=A4=91=EA=B8=B0=20=ED=83=AD=20=EC=BC=AC=20?= =?UTF-8?q?=E2=80=94=20=EB=AA=A9=EB=A1=9D=ED=91=9C=20=EB=84=B7=EC=9D=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EB=9C=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「표가 났는데 안 보이면 낸 것이 아님」. 꺼져 있던 탭 둘을 켜고 표를 붙임. - 새 파일 B09_Estimation_UI_BaseData.ts — 화면 조립부가 이미 1,200줄을 넘어 분리. 상태를 안 들고 그리기만 함. - 기초자료 탭: 노무비(118)·재료비(1)·경비(613) 목록표. 중기 탭: 중기목록표(5). - ⚠ 빈 표를 그냥 두지 않음 — 재료비목록표가 한 줄뿐인 사유를 표에 띄움. 「다 채운 것」으로 읽히면 안 됨. - ⚠ 계산 과정을 감추지 않음(8-13) — 조종원 환산이 실무·교본과 다르다는 사실과 그 영향(기계 든 공종 +19~24%)을 중기 탭에 적음. 잡재료가 연료에 접혀 있다는 것도. - 경비목록표는 취득가(천원), 시간당 사용료는 중기 탭이라는 안내를 달았음. 실측(공용 브라우저): 기초자료 표 3장 732줄 · 중기 5줄, 사유 문구 넷 다 뜸. ⚠ 백엔드 재시작이 있어야 새 조회가 붙음(재시작 전 404 → 후 200). Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_UI_BaseData.ts | 194 +++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 34 +++- 2 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_BaseData.ts diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts new file mode 100644 index 00000000..e43bdb7f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -0,0 +1,194 @@ +/* ============================================================================= + * B09_Estimation_UI_BaseData.ts + * 기초자료 탭 · 중기 탭 — 목록표 넷을 그린다 (사용자 확정 12번 「내야 할 표 16개 전체」). + * + * 기초자료 탭 : 노무비목록표 · 재료비목록표 · 경비목록표 + * 중기 탭 : 중기목록표 (합계 + 노무·재료·경비 3분할) + * + * 서식은 지어내지 않았다 — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를 + * 그대로 옮겼다. 칸 이름·차례가 그 시트와 같다. + * + * 화면 조립부(`B09_Estimation_UI_Page.ts`)가 이미 700줄을 크게 넘어 여기로 뺐다. + * 표를 그리는 일만 하고 **상태를 들지 않는다** — 부르는 쪽이 자료를 넘긴다. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { API_BASE_URL } from "@config/config_frontend"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 목록표 한 줄 — 실무 시트 칸 그대로. */ +export interface BaseDataRow { + code: string; + name: string; + spec: string; + unit: string; + unit_price_krw: string | null; + note: string; +} + +/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */ +export interface MachineRow { + code: string; + name: string; + spec: string; + unit: string; + total_krw: string | null; + labor_krw: string | null; + material_krw: string | null; + expense_krw: string | null; + note: string; +} + +export interface BaseDataDto { + status: string; + labor: BaseDataRow[]; + material: BaseDataRow[]; + expense: BaseDataRow[]; + machine: MachineRow[]; +} + +export async function fetchBaseData(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`base-data ${response.status}`); + return (await response.json()) as BaseDataDto; +} + +function money(value: string | null): string { + if (value === null || value === "") return ""; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value; +} + +function head(text: string): HTMLElement { + const el = document.createElement("div"); + el.className = "b09-hint"; + el.style.fontWeight = "600"; + el.textContent = text; + return el; +} + +function note(text: string): HTMLElement { + const el = document.createElement("div"); + el.className = "b09-hint"; + el.textContent = text; + return el; +} + +function table(headers: string[], rows: string[][], leftCols: number[]): HTMLElement { + const el = document.createElement("table"); + el.className = "b09-sheet"; + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + headers.forEach((text, index) => { + const th = document.createElement("th"); + th.textContent = text; + if (leftCols.includes(index)) th.className = "b09-left"; + headRow.append(th); + }); + thead.append(headRow); + const tbody = document.createElement("tbody"); + for (const cells of rows) { + const tr = document.createElement("tr"); + cells.forEach((text, index) => { + const td = document.createElement("td"); + td.textContent = text; + if (leftCols.includes(index)) td.className = "b09-left"; + tr.append(td); + }); + tbody.append(tr); + } + el.append(thead, tbody); + return el; +} + +/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */ +function catalogTable(rows: BaseDataRow[]): HTMLElement { + return table( + ["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"], + rows.map((row) => [ + row.code, + row.name, + row.spec, + row.unit, + money(row.unit_price_krw), + row.note, + ]), + [0, 1, 2, 5], + ); +} + +/** + * 기초자료 탭 — 목록표 셋. + * + * ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다. + * 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다). + */ +export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void { + const groups: Array<[string, BaseDataRow[], string]> = [ + ["노무비목록표", data.labor, ""], + [ + "재료비목록표", + data.material, + data.material.length <= 1 + ? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다." + : "", + ], + ["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."], + ]; + for (const [title, rows, hint] of groups) { + body.append(head(`${title} (${rows.length})`)); + if (hint) body.append(note(hint)); + if (rows.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + continue; + } + body.append(catalogTable(rows)); + } +} + +/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */ +export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void { + body.append(head(`중기목록표 (${data.machine.length})`)); + if (data.machine.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + return; + } + body.append( + table( + ["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"], + data.machine.map((row) => [ + row.code, + row.name, + row.spec, + row.unit, + money(row.total_krw), + money(row.labor_krw), + money(row.material_krw), + money(row.expense_krw), + row.note, + ]), + [0, 1, 2, 8], + ), + ); + // ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다. + body.append( + note( + "조종원 노임은 「노임 ÷ 8시간」으로 셉니다. 실무·임도교본은 여기에 " + + "「× 16/12 × 25/20」(약 1.67배)을 더 곱합니다 — 그 계수의 규정 원문(기재부 " + + "예정가격 작성기준)을 아직 확인하지 못해 적용하지 않았습니다. 확정되면 " + + "기계가 든 공종 단가가 약 19~24% 오릅니다.", + ), + ); + body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다.")); +} + +/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */ +export function drawBaseDataError(body: HTMLElement): void { + body.append(note(L("B09_Estimation_Tab_Pending"))); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 36ffc8a4..fce92be4 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -16,6 +16,12 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { + drawBaseDataTab, + drawMachineTab, + fetchBaseData, + type BaseDataDto, +} from "./B09_Estimation_UI_BaseData"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; @@ -560,10 +566,10 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["boq", "B09_Estimation_Tab_Boq", true], ["unit_price", "B09_Estimation_Tab_UnitPrice", true], ["price_basis", "B09_Estimation_Tab_PriceBasis", true], - ["machine", "B09_Estimation_Tab_Machine", false], + ["machine", "B09_Estimation_Tab_Machine", true], ["duration", "B09_Estimation_Tab_Duration", false], ["supply", "B09_Estimation_Tab_Supply", true], - ["base_data", "B09_Estimation_Tab_BaseData", false], + ["base_data", "B09_Estimation_Tab_BaseData", true], ]; function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { @@ -773,6 +779,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const form: CostFormState = { ...INITIAL_FORM }; let activeTab = "cost_sheet"; + let baseData: BaseDataDto | null = null; let sheet: CostSheetDto | null = null; let unitPriceList: UnitPriceListDto | null = null; let unitPriceDetail: UnitPriceDetailDto | null = null; @@ -1125,6 +1132,29 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawMaterialTab(); return; } + if (activeTab === "base_data" || activeTab === "machine") { + // 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다. + if (!baseData) { + const loading = document.createElement("div"); + loading.className = "b09-empty"; + loading.textContent = L("B09_Estimation_Tab_Pending"); + body.append(loading); + if (projectId) { + void fetchBaseData(projectId) + .then((data) => { + baseData = data; + drawBody(); + }) + .catch(() => { + /* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */ + }); + } + return; + } + if (activeTab === "machine") drawMachineTab(body, baseData); + else drawBaseDataTab(body, baseData); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; From 11394d40119dc6ca75b04bca1729b36ada2715ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:32:38 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(B05):=20=EB=8F=8C=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=EC=97=90=20=E3=80=8C=EC=A1=B0=EB=8B=AC(=EC=B1=84?= =?UTF-8?q?=EC=A7=91/=EA=B5=AC=EC=9E=85)=E3=80=8D=20=EC=B9=B8=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20=E2=80=94=20=EA=B8=B0=EB=B3=B8=20=EC=B1=84=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진(`B08_Quantity_Engine_UnitQuantity.STONE_SUPPLY_KEYS`)은 이미 `stone_supply` 를 읽는데 저장 칸이 없어 저장이 거부됐다(`정의되지 않은 옵션입니다: stone_supply`). `back_len_cm` 때와 같은 계열 — 읽는 키와 저장 칸이 어긋난 자리. - 붙인 곳: 돌쌓기(찰·메) · 큰돌쌓기 · 골막이 · 바닥막이 · 기슭막이 여섯. - 기본 「채집」 — 사용자 확정 ②(2026-09-09) 「기본은 캔다, 구조물마다 바꿀 수 있게」. 별표2 「석축 등에 필요한 야면석 등은 가급적 현장에서 채취·사용」이 근거. - 저장 왕복을 실제로 해 봄: PUT 200 → 되읽기에서 `stone_supply="구입"` 그대로 나옴. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 1e898cfb..bfb217ae 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -770,6 +770,15 @@ "required": true, "phase": "detail" }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": ["채집", "구입"], + "default": "채집", + "required": false, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -844,6 +853,15 @@ "required": true, "phase": "detail" }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": ["채집", "구입"], + "default": "채집", + "required": false, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -983,6 +1001,15 @@ "required": true, "phase": "detail" }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": ["채집", "구입"], + "default": "채집", + "required": false, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -1079,6 +1106,15 @@ "required": true, "phase": "detail" }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": ["채집", "구입"], + "default": "채집", + "required": false, + "phase": "detail" + }, { "key": "length_m", "label": "길이", @@ -1115,6 +1151,15 @@ "required": false, "phase": "b05" }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": ["채집", "구입"], + "default": "채집", + "required": false, + "phase": "detail" + }, { "key": "area_m2", "label": "면적", @@ -1214,6 +1259,15 @@ "required": false, "phase": "b05" }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": ["채집", "구입"], + "default": "채집", + "required": false, + "phase": "detail" + }, { "key": "height_m", "label": "높이", From 594f2d59225189c7c1673bf53fe7520aa24a56df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:32:46 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(B08):=20=EB=B6=80=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=EA=B0=9C=EC=86=8C=EB=A5=BC=20[=EC=A0=80=EC=9E=A5]?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=EB=8F=84=20=EB=84=A3=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20(=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=E2=91=AC=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 어제 넣은 `ancillary_counts` 를 **엔진은 읽는데 저장 요청 모양에는 없었음** — 화면에서 넣을 길이 없어 스크립트로만 넣히던 자리였음(`stone_supply` 가 레지스트리에 없어 저장이 거부되던 것과 같은 계열 — **읽는 자리와 넣는 자리가 안 맞는 것**). `QuantitySettingsBody` 에 칸을 두고, 「통째로 갈아 끼우는 칸」 목록에 넣음 — 병합이면 개소를 **지울 수가 없음**(0 으로 되돌리려면 통째 교체라야 함). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Router_Earthwork.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 63894e8c..5ccf4c94 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -213,6 +213,10 @@ class QuantitySettingsBody(BaseModel): concrete_placing_method: str | None = None # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). topsoil_thickness_m: float | None = None + # 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬). + # ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고 + # 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다. + ancillary_counts: dict[str, float] | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. @@ -262,7 +266,8 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - _save_quantity, root, values, - ("rock_methods", "material_supply", "concrete_placing_method") + NULLABLE_SETTING_KEYS, + ("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts") + + NULLABLE_SETTING_KEYS, ) except Exception: logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) From 1eff0931aec768d64e551595368c5a0c992b9e18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:36:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(B08):=20=EB=B2=84=EB=A6=BC=20=ED=83=80?= =?UTF-8?q?=EC=84=A4=EC=9D=98=20=EA=B0=88=EB=9E=98=EB=A5=BC=20=E3=80=8C?= =?UTF-8?q?=EB=AC=B4=EA=B7=BC=EA=B5=AC=EC=A1=B0=EB=AC=BC=E3=80=8D=EB=A1=9C?= =?UTF-8?q?=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=E2=80=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EB=A7=8C=20=EA=B0=80=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 앞 커밋(`bfd10b57`)이 `variant_value` 까지 「무근,버림」으로 바꿔 **금액이 통째로 빠졌음**(B09 실측 275,584원 · 본체 합계 9,587,677 → 9,312,093). 품셈 12-1-1 의 갈래는 **무근구조물·소형구조물·철근구조물 셋뿐**이라 「무근,버림」은 **없는 갈래**였음. ⇒ 갈래(`variant_value`·`structure_kind`)는 **무근구조물**로 되돌리고, 실무 줄 이름은 **표시 문구(`spec`·`spec_detail`)에만** 둠. 근거 — 봉화 제50호표가 줄 이름은 「레미콘타설(장비) 무근,버림」인데 **단가는 「무근」과 같음**. 실무도 줄만 가르고 품은 무근 것을 씀. ⚠ 받는 쪽이 문자열을 눅여 읽는 방식은 쓰지 않기로 함 — 그러면 다음 갈래가 조용히 잘못 붙음. **보내는 쪽에서 바로잡는 것**이 맞음. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 19f02bbf..0b19c353 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -406,9 +406,16 @@ def _structure_rows( #: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) -#: 버림 타설 줄의 갈래 이름 — **실무 내역 표기 그대로**(「레미콘타설(장비) 무근,버림」). -#: ⚠ 버림은 늘 무근이라 구조물 종류(무근/철근)를 따라가지 않는다. -BLINDING_PLACING_KIND = "무근,버림" +#: 버림 타설 줄에 **표시**할 이름 — 실무 내역 표기 그대로(「레미콘타설(장비) 무근,버림」). +#: ⚠⚠ **표시 문구일 뿐 갈래가 아니다.** 품셈 12-1-1 의 갈래는 무근/철근/소형 셋뿐이고 +#: **「버림」이라는 열이 없다.** 실무도 줄 이름만 「무근,버림」이고 품은 무근 것을 쓴다 +#: (봉화 제50호표 단가가 「무근」과 같음). +#: ⇒ `variant_value`·`structure_kind` 는 **「무근구조물」 그대로** 보내고 여기 이름은 +#: `spec` 에만 쓴다. 갈래 축에 없는 값을 보내면 받는 쪽이 단가를 못 고른다 +#: (2026-09-09 실측: 275,584원이 통째로 빠졌다). +BLINDING_PLACING_LABEL = "무근,버림" +#: 버림이 실제로 쓰는 품셈 갈래 — 무근이다. +BLINDING_PLACING_KIND = "무근구조물" def _placing_rows( @@ -449,13 +456,20 @@ def _placing_rows( volume = float(component.get("amount") or 0.0) if volume <= 0: continue - kind = BLINDING_PLACING_KIND if name == "버림콘크리트" else structure_kind(structure) - buckets[kind] = buckets.get(kind, 0.0) + volume + # (갈래, 표시 이름) 으로 담는다 — 갈래는 품셈 축, 표시는 실무 줄 이름. + if name == "버림콘크리트": + key = (BLINDING_PLACING_KIND, BLINDING_PLACING_LABEL) + else: + kind = structure_kind(structure) + key = (kind, kind) + buckets[key] = buckets.get(key, 0.0) + volume rows = [ { "work_item_code": code, "name": "콘크리트 타설", - "spec": kind, + # ⚠ 표시 이름과 갈래를 **가른다** — 표시는 실무 줄 이름(「무근,버림」), + # 갈래(`variant_value`)는 품셈 축(무근/철근/소형)이라야 단가가 붙는다. + "spec": label, "unit": "㎥", "quantity": volume, "quantity_gross": None, @@ -468,7 +482,7 @@ def _placing_rows( "station_from": None, "station_to": None, "excavation_method": None, - "spec_detail": kind, + "spec_detail": label, "composite_parts": None, "structure_kind": kind, "blocked_kind": None, @@ -485,7 +499,7 @@ def _placing_rows( "in_bill_reason": "", "origin": ORIGIN_STRUCTURE, } - for kind, volume in sorted(buckets.items()) + for (kind, label), volume in sorted(buckets.items()) ] notes: list[str] = [] if rows and used_default: