Merge origin/dev into sub_laptop_1 — 양식 파일 충돌 풂(돌 줄 부채 걷음 + 반올림 칸 둘 다 살림)

- dev 8210c2b7: 돌 줄 이름 「돌」 + 규격에 종류(_downstream_name 걷음) 그대로 취함
- sub_laptop_1 cf0ccfac: _user_basis(식·반올림 근거) · m당 풀이 × 연장 그대로 둠
- 병합 뒤 전체 1535 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 18:53:57 +09:00
co-authored by Claude Opus 5
10 changed files with 130 additions and 70 deletions
@@ -151,6 +151,7 @@ class MaterialRow:
name: str
unit: str
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
net_amount: float = 0.0 # 순수량 — 할증 전
surcharge_pct: float | None = None # None = 미확보
supply: str = SUPPLY_UNKNOWN
@@ -159,6 +160,11 @@ class MaterialRow:
basis: str = ""
sources: list[str] = field(default_factory=list)
@property
def supply_key(self) -> str:
"""관급구분 설정(`material_supply`)을 찾는 열쇠 — 이름 + 규격."""
return f"{self.name} {self.spec}".strip()
@property
def total_amount(self) -> float:
"""합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다."""
@@ -255,11 +261,36 @@ def surcharge_lookup_name(name: str, concrete_placing_method: str | None) -> tup
return name, ""
def _add(rows: dict[tuple[str, str, str], MaterialRow], item: dict[str, Any]) -> MaterialRow:
"""(이름·규격·단위)가 같은 줄에 더한다 — 규격이 다르면 다른 자재다(돌 종류·강도)."""
name = str(item.get("name") or "").strip()
spec = str(item.get("spec") or "").strip()
unit = str(item.get("unit") or "").strip()
row = rows.setdefault((name, spec, unit), MaterialRow(name=name, unit=unit, spec=spec))
row.net_amount += float(item.get("amount") or 0.0)
if item.get("surcharge_included"):
row.surcharge_included = True
return row
def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
"""그 줄의 관급구분 설정 — 이름+규격이 먼저, 없으면 옛 열쇠.
⚠ 옛 열쇠 둘을 받는다 — 이름만(규격이 갈리기 전 「채움콘크리트」) · 규격만(돌 줄이
종류 이름으로 서던 때 「야면석·호박돌」, 2026-09-13 걷음).
안 받으면 사용자가 정한 값이 사라진다.
"""
for key in (row.supply_key, row.name, row.spec):
if key and key in supply:
return supply[key]
return None
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
"""`destination == "material"` 만 모은다. 나머지는 세어서 보인다."""
rows: dict[tuple[str, str], MaterialRow] = {}
rows: dict[tuple[str, str, str], MaterialRow] = {}
skipped: dict[str, int] = {}
for structure in unit_quantity_table.get("structures", []):
label = str(structure.get("name") or structure.get("type_id") or "")
@@ -268,12 +299,7 @@ def _collect(
if destination != ACCEPTED_DESTINATION:
skipped[destination] = skipped.get(destination, 0) + 1
continue
name = str(component.get("name") or "").strip()
unit = str(component.get("unit") or "").strip()
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
row.net_amount += float(component.get("amount") or 0.0)
if component.get("surcharge_included"):
row.surcharge_included = True
row = _add(rows, component)
if label and label not in row.sources:
row.sources.append(label)
return rows, skipped
@@ -298,12 +324,7 @@ def build_table(
for item in extra_materials:
if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION:
continue
name = str(item.get("name") or "").strip()
unit = str(item.get("unit") or "").strip()
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
row.net_amount += float(item.get("amount") or 0.0)
if item.get("surcharge_included"):
row.surcharge_included = True
row = _add(rows, item)
source = str(item.get("source") or "")
if source and source not in row.sources:
row.sources.append(source)
@@ -312,13 +333,14 @@ def build_table(
missing_rate: list[str] = []
missing_supply: list[str] = []
missing_install_by: list[str] = []
for (name, _unit), row in rows.items():
row.supply, row.install_by = _supply_of(supply.get(name))
for row in rows.values():
name = row.name
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
if row.supply == SUPPLY_UNKNOWN:
missing_supply.append(name)
missing_supply.append(row.supply_key)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
if row.supply == SUPPLY_OWNER and row.install_by is None:
missing_install_by.append(name)
missing_install_by.append(row.supply_key)
if row.surcharge_included:
continue
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
@@ -328,7 +350,7 @@ def build_table(
if rate is None:
missing_rate.append(name)
ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit))
ordered = sorted(rows.values(), key=lambda item: (item.name, item.spec, item.unit))
return {
"columns": [
"자재명",
@@ -343,6 +365,9 @@ def build_table(
"rows": [
{
"name": row.name,
"spec": row.spec,
# 관급구분을 고르는 열쇠 — 화면이 이 이름으로 설정에 적는다.
"supply_key": row.supply_key,
"unit": row.unit,
"net_amount": row.net_amount,
"surcharge_pct": row.surcharge_pct,
@@ -178,17 +178,6 @@ def _library_rows(
return rows
def _downstream_name(result: dict[str, Any]) -> str:
"""뒤 단계(자재총괄·인계)가 찾는 이름 — 돌 줄만 종류 이름으로.
⚠ 명세 13장 Ⓒ 는 이름 고정 「돌」 + `spec` 이지만, 자재총괄·할증표·인계가 아직 **이름으로**
찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). ⏳ 부채 — 걷는 시점은 PLAN 10장.
"""
if result["name"] == "" and result.get("spec"):
return str(result["spec"])
return str(result["name"])
def _user_basis(row: dict[str, Any]) -> str:
"""사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로."""
parts = []
@@ -279,7 +268,8 @@ def replace_with_templates(
if result.get("error"):
quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}")
continue
name = _downstream_name(result)
# 돌 줄도 이름 「돌」 + 규격에 종류 그대로(명세 13장 Ⓒ) — 자재총괄이 규격까지 보고 묶음.
name = str(result["name"])
user = source.get("source") == "user"
basis = (
_user_basis(source)
@@ -294,7 +284,7 @@ def replace_with_templates(
str(source.get("destination") or ""),
basis,
source="user" if user else engine_source.get(name, ""),
spec="" if name != result["name"] else str(result.get("spec") or ""),
spec=str(result.get("spec") or ""),
)
)
quantity.components = components
@@ -383,7 +383,6 @@ DESTINATION = {
"돌쌓기": "unit_price",
"돌붙임": "unit_price",
"깬돌": "material",
"야면석": "material",
"고임돌": "material",
"막자갈": "material",
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
@@ -406,11 +405,8 @@ DESTINATION = {
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
"입적": "reference",
"석적": "reference",
# 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다.
"야면석·호박돌": "material",
"깬잡석": "material",
"견치돌": "material",
# 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」).
# 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로
# (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다.
"": "material",
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
@@ -901,8 +897,8 @@ def stone_masonry(
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
kind_label = str(picked.get("kind") or "")
# ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 이름·무게는
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 「돌」).
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸).
chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip()
stone_kind_name = (
chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label
@@ -1001,12 +997,13 @@ def stone_masonry(
else:
components.append(
Component(
stone_name,
"",
"ton",
masonry_area * stone_ton,
DESTINATION.get(stone_name, "material"),
DESTINATION[""],
weight_basis,
source=weight_source,
spec=stone_kind_name,
)
)
@@ -1555,11 +1552,12 @@ def build_table(
totals: dict[str, dict[str, Any]] = {}
for item in quantities:
for component in item.components:
key = f"{component.name}|{component.unit}"
key = f"{component.name}|{component.spec}|{component.unit}"
entry = totals.setdefault(
key,
{
"name": component.name,
"spec": component.spec,
"unit": component.unit,
"amount": 0.0,
"destination": component.destination,
@@ -73,7 +73,8 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = {
},
"돌붙임(메)": {
"back_len_m": 0.30,
"stone_name": "야면석",
"stone_name": "",
"stone_kind": "야면석", # 규격 칸으로(명세 13장 Ⓒ)
"stone_spec": "20×20×30㎝",
"stone_ton_per_m2": 0.42,
"wedge_stone_m3_per_m2": 0.07,
@@ -283,7 +284,7 @@ def erosion_check_dam(
facing = top_m * (top_t - back_m)
base_area = masonry + facing
rows: list[tuple[str, str, float, str]] = [
rows: list[tuple] = [
("돌쌓기", "", masonry, f"정면적 {front_area:.4f}× √(1+n²) · 기울기 {slope_note}"),
("돌붙임", "", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} 뒷길이 {back_m:g})"),
(
@@ -305,10 +306,11 @@ def erosion_check_dam(
else:
rows.append(
(
stone_name,
"",
"ton",
_ceil2(base_area * stone_ton),
f"(돌쌓기+돌붙임) {base_area:.4f}{weight_tail}",
kind, # 종류는 규격 칸으로(명세 13장 Ⓒ)
)
)
wedge_per = coeff["wedge_stone_m3_per_m2"]
@@ -358,8 +360,8 @@ def erosion_check_dam(
)
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
for name, unit, amount, basis, *spec in rows
]
mpa, mpa_basis = fill_concrete_mpa(options)
fill_per = coeff["fill_concrete_m3_per_m2"]
@@ -548,7 +550,7 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
return [], ["면적이 없어 전개하지 않음"]
back = table["back_len_m"]
rows: list[tuple[str, str, float, str]] = [
rows: list[tuple] = [
("돌붙임", "", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"),
("입적", "", area_m2 * back, f"면적 × 두께 {back:g}m"),
(
@@ -561,6 +563,7 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
if form == "돌붙임(찰)"
else " (원문 직접값 — 야면석은 유도식이 안 맞음)"
),
table.get("stone_kind", ""),
),
(
"고임돌",
@@ -581,8 +584,8 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
rows.append(("터파기", "", area_m2 * back, f"면적 × 두께 {back:g}m"))
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
for name, unit, amount, basis, *spec in rows
]
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
if form == "돌붙임(찰)":
+14 -7
View File
@@ -21,6 +21,10 @@ import {
export interface MaterialRow {
name: string;
/** 규격 — 돌 종류·콘크리트 강도(명세 13장 Ⓒ). 같은 이름도 규격이 다르면 다른 줄. */
spec?: string;
/** 관급구분 설정 열쇠 — 이름 + 규격(서버 `MaterialRow.supply_key`). */
supply_key?: string;
unit: string;
net_amount: number;
surcharge_pct: number | null;
@@ -101,6 +105,8 @@ export interface UnitQuantityStructure {
/** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */
basis_kind?: string;
source?: string;
/** 규격 — 돌 종류·채움 강도. 이름은 「돌」로 고정이고 종류는 여기(명세 13장 Ⓒ). */
spec?: string;
/** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */
reuse_count?: number | null;
reuse_note?: string;
@@ -326,7 +332,8 @@ export function renderMaterialGrid(
const body = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(row.name, "b08-grid__station"));
const key = row.supply_key ?? row.name;
tr.append(textCell(key, "b08-grid__station"));
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.net_amount, 2)));
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
@@ -334,7 +341,7 @@ export function renderMaterialGrid(
tr.append(textCell(num(row.total_amount, 2)));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
const chosen = options.choices[row.name] ?? {
const chosen = options.choices[key] ?? {
supply: row.supply,
install_by: row.install_by,
};
@@ -343,20 +350,20 @@ export function renderMaterialGrid(
INSTALL_BY_OPTIONS,
chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다
(value) => {
const current = options.choices[row.name] ?? chosen;
options.choices[row.name] = { supply: current.supply, install_by: value || null };
const current = options.choices[key] ?? chosen;
options.choices[key] = { supply: current.supply, install_by: value || null };
options.onChange();
},
);
tr.append(
choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => {
const current = options.choices[row.name] ?? chosen;
const current = options.choices[key] ?? chosen;
const next = {
// 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다.
supply: value,
install_by: value === "owner_supplied" ? (current.install_by ?? null) : null,
};
options.choices[row.name] = next;
options.choices[key] = next;
// ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도
// 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리).
const select = installCell.querySelector("select") as HTMLSelectElement | null;
@@ -501,7 +508,7 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
tr.append(textCell(first ? structure.name : "", "b08-grid__station"));
tr.append(textCell(first ? spec : ""));
first = false;
tr.append(textCell(component.name));
tr.append(textCell(`${component.name} ${component.spec ?? ""}`.trim()));
tr.append(textCell(component.unit, "b08-grid__unit"));
tr.append(textCell(num(component.amount, 3)));
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
@@ -70,8 +70,9 @@ def test_양식_갈음은_값을_안_움직인다() -> None:
want["spec"],
)
assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"]
# 돌 줄은 뒤 단계가 찾는 종류 이름으로(1장 코드 잇기 끝나기 전) · 관측 출처는 그대로.
stone = next(c for c in wet_after["components"] if c["name"] == "야면석·호박")
# 돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음) · 관측 출처는 그대로.
stone = next(c for c in wet_after["components"] if c["name"] == "")
assert stone["spec"] == "야면석·호박돌"
assert stone["source"] == "uljin_library"
@@ -77,7 +77,8 @@ def test_돌_중량은_값을_두고_근거를_남긴다() -> None:
**어느 조건에서 다는지** 달라졌으므로 조건을 명시한다.
"""
got, _ = 성분(stone_kind="야면석·호박돌")
야면석 = got["야면석·호박"]
야면석 = got[""] # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ)
assert 야면석.spec == "야면석·호박돌"
assert abs(야면석.amount - got["돌쌓기"].amount * 0.88) < 1e-9
assert "울진" in 야면석.basis and "품셈·교본에는 돌중량표가 없음" in 야면석.basis
assert 야면석.source == "uljin_library"
@@ -153,6 +153,43 @@ def test_단위가_다르면_다른_줄() -> None:
assert table["row_count"] == 2
def test_규격이_다르면_다른_줄이고_옛_관급_열쇠도_받음() -> None:
"""돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음).
설정 열쇠 종류 이름(야면석·호박돌)·이름만(채움콘크리트) 받아야
사용자가 정한 값이 사라짐.
"""
table = build_table(
원단위표(
성분("", "ton", 3.0, spec="야면석·호박돌"),
성분("", "ton", 2.0, spec="깬잡석"),
성분("채움콘크리트", "", 1.0, spec="180"),
성분("채움콘크리트", "", 4.0, spec="210"),
),
supply_map={
"야면석·호박돌": SUPPLY_CONTRACTOR, # 옛 열쇠(종류 이름)
"돌 깬잡석": SUPPLY_OWNER, # 새 열쇠(이름 + 규격)
"채움콘크리트": SUPPLY_CONTRACTOR, # 옛 열쇠(이름만)
},
)
by = {(r["name"], r["spec"]): r for r in table["rows"]}
assert table["row_count"] == 4
assert by[("", "야면석·호박돌")]["net_amount"] == pytest.approx(3.0)
assert by[("", "야면석·호박돌")]["supply"] == SUPPLY_CONTRACTOR
assert by[("", "깬잡석")]["supply"] == SUPPLY_OWNER
assert by[("", "깬잡석")]["supply_key"] == "돌 깬잡석"
assert by[("채움콘크리트", "210")]["supply"] == SUPPLY_CONTRACTOR
assert table["missing_install_by_materials"] == ["돌 깬잡석"]
def test_바닥막이_메붙임_돌은_규격에_야면석() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill
components, _notes = bed_sill(10.0, {"form": "돌붙임(메)"})
stone = next(c for c in components if c.unit == "ton")
assert (stone.name, stone.spec, stone.destination) == ("", "야면석", "material")
# ── 할증률은 데이터에서, 없으면 드러낸다 ────────────────────────────
@@ -104,12 +104,9 @@ def test_양식_값이_전개와_같다(case: dict) -> None:
)
# ⓘ 「실무 관행」 계수가 돌종류를 지우던 전개 결함은 2026-09-13 고침(브레인 판정) — 차이 없음.
for row, component in zip(rows, components):
if row["name"] == "":
# 명세 13장 Ⓒ — 양식은 이름 고정 + 규격에 종류, 전개는 종류를 이름으로 씀.
assert component["name"] == (row["spec"] or "")
else:
assert row["name"] == component["name"]
assert row["spec"] == component["spec"], (row, component)
# 명세 13장 Ⓒ — 돌 줄도 양식·전개 모두 이름 고정 「돌」 + 규격에 종류(2026-09-13).
assert row["name"] == component["name"]
assert row["spec"] == component["spec"], (row, component)
assert template["rows"][row["seq"] - 1]["destination"] == component["destination"], row
assert float(row["amount"]) == pytest.approx(component["amount"], rel=1e-9, abs=1e-12), (
row["name"],
+4 -3
View File
@@ -275,7 +275,7 @@ def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None:
"""2026-09-13 브레인 판정 — 계수 열 고르기와 돌종류는 다른 축.
전개는 실무 관행 고르면 야면석이 ·계산식 무게(0.918) 조용히 섰음.
이제 계수만 참고자료 (고임돌 0.15) 가고, 돌은 야면석 이름·관측 무게(0.88) 지킴.
이제 계수만 참고자료 (고임돌 0.15) 가고, 돌은 야면석 규격·관측 무게(0.88) 지킴.
"""
result = expand(
돌쌓기찰(
@@ -288,9 +288,10 @@ def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None:
)
)
돌쌓기 = 성분(result, "돌쌓기").amount
assert 성분(result, "야면석·호박").amount == pytest.approx(돌쌓기 * 0.88)
= 성분(result, "") # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ)
assert .spec == "야면석·호박돌"
assert .amount == pytest.approx(돌쌓기 * 0.88)
assert 성분(result, "고임돌").amount == pytest.approx(돌쌓기 * 0.15) # 관행 열
assert not any(c.name == "" for c in result.components)
def test_물구멍관_길이는_평균두께() -> None: