Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca1a45b2b | ||
|
|
72ec6c2135 | ||
|
|
0bb14ce2b3 | ||
|
|
ecd25ae9ee | ||
|
|
1550c7caf6 | ||
|
|
99a79e8e0b | ||
|
|
60d7c005bd | ||
|
|
26d9092a9b | ||
|
|
29a0e64b30 | ||
|
|
64e43463f4 | ||
|
|
254007573f | ||
|
|
64a52df680 | ||
|
|
a744388276 | ||
|
|
70d9329fb6 | ||
|
|
42909a01b9 | ||
|
|
5d2e1edbde | ||
|
|
8dd452d8f4 | ||
|
|
31decd1558 | ||
|
|
9e240d886d | ||
|
|
9022640b5f | ||
|
|
00919e86e2 | ||
|
|
b95ad5e838 | ||
|
|
9d59a32898 | ||
|
|
eff340aad3 | ||
|
|
ef03daa482 | ||
|
|
fc825dc9d3 |
@@ -52,6 +52,14 @@ FACE_DRESSING_FILL_SUGGESTED = (
|
||||
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
|
||||
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
|
||||
)
|
||||
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
|
||||
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
|
||||
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
|
||||
ROOT_REMOVAL_EXCAVATOR_SUGGESTED = (
|
||||
"0.7",
|
||||
"산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준"
|
||||
" 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음",
|
||||
)
|
||||
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
|
||||
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
|
||||
FACE_DRESSING_NOTE = (
|
||||
|
||||
@@ -115,6 +115,7 @@ def build_handoff(
|
||||
stand_volume_class: str | None = None,
|
||||
face_dressing_cut_class: str | None = None,
|
||||
face_dressing_fill_class: str | None = None,
|
||||
root_removal_excavator_m3: str | None = None,
|
||||
priced_sheets: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
|
||||
@@ -134,6 +135,8 @@ def build_handoff(
|
||||
"stand_volume_class": stand_volume_class,
|
||||
"face_dressing_cut_class": face_dressing_cut_class,
|
||||
"face_dressing_fill_class": face_dressing_fill_class,
|
||||
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
|
||||
"root_removal_excavator_m3": root_removal_excavator_m3,
|
||||
}
|
||||
rows, misses = _earthwork_rows(
|
||||
summary_table, table, methods, bench_cut_depth_m, variant_inputs
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
|
||||
@@ -151,16 +152,24 @@ def _earthwork_rows(
|
||||
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
|
||||
blocked_kind = mismatch[1] if mismatch else None
|
||||
blocked_reason = mismatch[2] if mismatch else ""
|
||||
inputs = {**(variant_inputs or {}), "ground_class": ground}
|
||||
variant_value = (
|
||||
(entry or {}).get("variant_value")
|
||||
or {
|
||||
**(variant_inputs or {}),
|
||||
"ground_class": ground,
|
||||
}.get(str((entry or {}).get("variant_from") or ""))
|
||||
or inputs.get(str((entry or {}).get("variant_from") or ""))
|
||||
or None
|
||||
)
|
||||
# 입력 둘을 한 갈래 값으로 엮는 매핑(9-21 「크기·등급」 · 09-14) — 하나라도 비면 안 엮음.
|
||||
template = str((entry or {}).get("variant_template") or "")
|
||||
needs = [name for _, name, _, _ in string.Formatter().parse(template) if name]
|
||||
template_ready = all(inputs.get(name) for name in needs)
|
||||
if template and template_ready:
|
||||
variant_value = template.format(**inputs)
|
||||
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
|
||||
if code and not variant_value and (entry or {}).get("variant_missing_reason"):
|
||||
if (
|
||||
code
|
||||
and (not variant_value or not template_ready)
|
||||
and (entry or {}).get("variant_missing_reason")
|
||||
):
|
||||
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
|
||||
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
|
||||
if code is None and not is_subtotal:
|
||||
@@ -441,6 +450,15 @@ def _structure_rows(
|
||||
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
|
||||
variant_axis = str(entry.get("variant_axis") or "") or None
|
||||
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
|
||||
if variant_axis == "back_len_cm":
|
||||
# 뒷길이만은 **수량표·구조물도가 실제로 쓴 값**과 출처를 싣는다 — 안 고른 벽(관 유입·
|
||||
# 유출부 기슭막이는 늘 빔)도 [주]⑩ 하한으로 물량이 섰는데 갈래만 못 골랐음(09-14).
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import back_length_cm
|
||||
|
||||
back, default_note = back_length_cm(structure)
|
||||
variant_value = str(back) if variant_value in (None, "") else variant_value
|
||||
used = f"뒷길이 {back}㎝ — {default_note or '저장 제원'}"
|
||||
class_basis = " · ".join(part for part in (class_basis, used) if part)
|
||||
secondary_axes = [
|
||||
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
|
||||
for axis in entry.get("secondary_axes") or []
|
||||
|
||||
@@ -19,13 +19,36 @@ from typing import Any
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
|
||||
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
|
||||
TIERS = ("personal", "company", "program")
|
||||
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음).
|
||||
TIERS = ("personal", "received", "company", "program")
|
||||
RECEIVED_SUBDIR = "library_received"
|
||||
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
|
||||
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
||||
LIBRARY_SUBDIR = "library"
|
||||
|
||||
|
||||
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
|
||||
PROGRAM_SUBDIR = "_program"
|
||||
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
|
||||
ORIGIN_MASKED_KEYS = ("project", "file")
|
||||
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
|
||||
|
||||
|
||||
def program_library_dir() -> Path:
|
||||
"""프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결).
|
||||
|
||||
git `resources/library_structure`(`TEMPLATE_DIR`)는 **씨앗** — 쓰기 금지(데이터 3층의 초기값 자리).
|
||||
"""
|
||||
return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR
|
||||
|
||||
|
||||
def program_items() -> list[dict[str, Any]]:
|
||||
"""프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임)."""
|
||||
stored = _items(program_library_dir())
|
||||
codes = {item.get("code") for item in stored}
|
||||
return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)]
|
||||
|
||||
|
||||
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
||||
dirs: dict[str, Path] = {}
|
||||
@@ -33,8 +56,9 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
|
||||
if user_id is not None:
|
||||
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
||||
dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR
|
||||
dirs["company"] = company / LIBRARY_SUBDIR
|
||||
dirs["program"] = TEMPLATE_DIR
|
||||
dirs["program"] = program_library_dir()
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -44,6 +68,13 @@ def _items(folder: Path) -> list[dict[str, Any]]:
|
||||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
|
||||
|
||||
|
||||
def _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]:
|
||||
"""단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더."""
|
||||
if tier == "program":
|
||||
return program_items()
|
||||
return _items(dirs[tier]) if tier in dirs else []
|
||||
|
||||
|
||||
def item_kind(item: dict[str, Any]) -> str:
|
||||
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
|
||||
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
|
||||
@@ -66,16 +97,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
|
||||
}
|
||||
for tier in TIERS
|
||||
if tier in dirs
|
||||
for item in _items(dirs[tier])
|
||||
for item in _tier_items(dirs, tier)
|
||||
if item.get("type_id") == type_id and item.get("code")
|
||||
]
|
||||
|
||||
|
||||
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
|
||||
folder = dirs.get(tier)
|
||||
if folder is None:
|
||||
if tier not in dirs:
|
||||
return None
|
||||
return next((item for item in _items(folder) if item.get("code") == code), None)
|
||||
return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None)
|
||||
|
||||
|
||||
def project_library_dir(project_root: str | Path) -> Path:
|
||||
@@ -98,7 +128,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]
|
||||
|
||||
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
|
||||
"""
|
||||
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
|
||||
return [*program_items(), *project_templates(project_root).values()]
|
||||
|
||||
|
||||
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
||||
@@ -127,9 +157,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
|
||||
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _personal_code(folder: Path, type_id: Any) -> str:
|
||||
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
|
||||
same = [item for item in _items(folder) if item.get("type_id") == type_id]
|
||||
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
|
||||
"""그 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
|
||||
|
||||
프로그램 기본은 씨앗까지 봄(`existing`) — 씨앗 코드로 덮어써야 작업본이 씨앗을 이김.
|
||||
"""
|
||||
items = _items(folder) if existing is None else existing
|
||||
same = [item for item in items if item.get("type_id") == type_id]
|
||||
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
||||
|
||||
|
||||
@@ -145,16 +179,19 @@ def save_personal(
|
||||
template: dict[str, Any],
|
||||
overrides: dict[str, Any] | None,
|
||||
unit_price_rows: list[dict[str, Any]] | None = None,
|
||||
tier: str = "personal",
|
||||
) -> str:
|
||||
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드.
|
||||
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
|
||||
|
||||
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||
⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||
⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||
⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
|
||||
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
||||
|
||||
code = _personal_code(folder, template.get("type_id"))
|
||||
existing = program_items() if tier == "program" else None
|
||||
code = _personal_code(folder, template.get("type_id"), existing)
|
||||
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
||||
dropped = {"default_formula", "default_rounding"}
|
||||
rows = [
|
||||
@@ -162,9 +199,13 @@ def save_personal(
|
||||
for row in overridden_rows(template, overrides)
|
||||
]
|
||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||
if tier == "program" and isinstance(item.get("origin"), dict):
|
||||
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
|
||||
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
|
||||
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
|
||||
if unit_price_rows is not None:
|
||||
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
||||
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
|
||||
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
|
||||
return code
|
||||
|
||||
|
||||
@@ -186,8 +227,18 @@ def pin_program_templates(project_root: str | Path) -> int:
|
||||
"""
|
||||
pinned = project_templates(project_root)
|
||||
count = 0
|
||||
for item in _items(TEMPLATE_DIR):
|
||||
for item in program_items():
|
||||
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
||||
import_item(project_root, item, "program")
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def share_item(item: dict[str, Any], to_folder: Path, sender: dict[str, Any]) -> str:
|
||||
"""[동료에게 보내기] — 내 개인 단 항목을 동료의 **받음 단**에 복사(개인 단을 안 덮음 · 브레인 판정 ①).
|
||||
|
||||
코드는 그대로(다시 보내면 같은 자리를 새로 씀) · 출처 공사명도 그대로(같은 회사 · 판정 ③).
|
||||
"""
|
||||
body = {k: v for k, v in item.items() if k != "imported_from"}
|
||||
_write(to_folder, {**body, "library_tier": "received", "received_from": sender})
|
||||
return str(body["code"])
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -28,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
|
||||
|
||||
|
||||
def load_template(type_id: str) -> dict[str, Any] | None:
|
||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
|
||||
path = TEMPLATE_DIR / f"{type_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
|
||||
|
||||
storage 작업본(시스템 관리자가 고친 것)이 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) — 2026-09-14 관리자 UI.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items
|
||||
|
||||
return next((item for item in program_items() if item.get("type_id") == type_id), None)
|
||||
|
||||
|
||||
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
@@ -153,6 +155,65 @@ def template_sheet(
|
||||
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
|
||||
_PER_LENGTH_UNITS = frozenset({"m"})
|
||||
|
||||
#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음).
|
||||
#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(−6,037,712원 실측).
|
||||
#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음).
|
||||
#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) ·
|
||||
#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것.
|
||||
KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석")
|
||||
KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)"
|
||||
|
||||
|
||||
def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]:
|
||||
"""목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로."""
|
||||
names = {str(row.get("name") or "") for row in template_rows}
|
||||
return [
|
||||
row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names
|
||||
]
|
||||
|
||||
|
||||
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
|
||||
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
|
||||
MISMATCH_HEAD = "⚠ 규격 다름"
|
||||
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
|
||||
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
|
||||
|
||||
def _is_fixed(template: dict[str, Any]) -> bool:
|
||||
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
|
||||
rows = template.get("rows") or []
|
||||
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
|
||||
|
||||
|
||||
def fixed_notes(
|
||||
template: dict[str, Any], engine_notes: list[str], height: Any
|
||||
) -> tuple[list[str], dict[str, float] | None]:
|
||||
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
|
||||
|
||||
전개 사유(뒷길이 표준·돌 종류…)는 고정형 줄과 안 맞아 거짓 사유 → 걷음. 남긴 줄(터파기·버림…)을
|
||||
말하는 것만 머리를 달아 둠. 두 번 불려도(원단위 → 구조물도) 같은 값이 나오게 함.
|
||||
"""
|
||||
item = [part for part in str(template.get("note") or "").split(" · ") if part]
|
||||
kept: list[str] = []
|
||||
for note in engine_notes:
|
||||
if note in item or note.startswith(MISMATCH_HEAD):
|
||||
continue
|
||||
if note.startswith(KEPT_NOTE_HEAD):
|
||||
kept.append(note)
|
||||
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
|
||||
kept.append(KEPT_NOTE_HEAD + note)
|
||||
notes = list(dict.fromkeys(item + kept))
|
||||
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
|
||||
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
|
||||
return notes, None
|
||||
item_h, sheet_h = float(found.group(1)), float(height)
|
||||
notes.insert(
|
||||
0,
|
||||
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
|
||||
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
|
||||
)
|
||||
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
|
||||
|
||||
|
||||
def _library_rows(
|
||||
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
|
||||
@@ -295,7 +356,11 @@ def replace_with_templates(
|
||||
spec=str(result.get("spec") or ""),
|
||||
)
|
||||
)
|
||||
quantity.components = components
|
||||
quantity.components = components + _kept(
|
||||
quantity.components, body["rows"], lambda component: component.name
|
||||
)
|
||||
if _is_fixed(template):
|
||||
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
|
||||
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
|
||||
quantity.library_item = str(template.get("name") or quantity.type_id)
|
||||
|
||||
@@ -343,18 +408,41 @@ def apply_templates(
|
||||
continue
|
||||
members = sheet.get("members") or []
|
||||
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
|
||||
sheet["rows"] = _library_rows(body, solved[index], billing)
|
||||
library_rows = _library_rows(body, solved[index], billing)
|
||||
start = max((row["no"] for row in library_rows), default=0)
|
||||
kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name"))
|
||||
sheet["rows"] = library_rows + [
|
||||
{
|
||||
**row,
|
||||
"no": start + offset,
|
||||
"formula": "",
|
||||
"default_formula": "",
|
||||
"rounding": None,
|
||||
"default_rounding": None,
|
||||
"skipped": False,
|
||||
"reason": KEPT_ROW_REASON,
|
||||
"error": "",
|
||||
}
|
||||
for offset, row in enumerate(kept, start=1)
|
||||
]
|
||||
sheet["unpriced_rows"] = [
|
||||
row["name"]
|
||||
for row in sheet["rows"]
|
||||
if row["unit_amount"] is None and not row["skipped"]
|
||||
]
|
||||
if _is_fixed(template):
|
||||
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
|
||||
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
|
||||
template, sheet.get("notes") or [], sheet.get("height_m")
|
||||
)
|
||||
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
|
||||
sheet["library_item"] = {
|
||||
"type_id": template.get("type_id"),
|
||||
"name": template.get("name"),
|
||||
"code": template.get("code"),
|
||||
"imported_from": template.get("imported_from"),
|
||||
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
|
||||
"origin_project": (template.get("origin") or {}).get("project"),
|
||||
}
|
||||
sheet["formula_sheet"] = body
|
||||
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
@@ -362,32 +363,64 @@ def save_manual(
|
||||
return merged
|
||||
|
||||
|
||||
#: 조각 후보의 최소 겹침 글자 수(2026-09-14 브레인 판정 ① — 「채집」·「타설」이 걸리게).
|
||||
MIN_FRAGMENT = 2
|
||||
|
||||
|
||||
def _longest_common(a: str, b: str) -> int:
|
||||
"""두 글의 가장 긴 공통 조각 길이."""
|
||||
best = 0
|
||||
previous = [0] * (len(b) + 1)
|
||||
for char in a:
|
||||
current = [0]
|
||||
for index, other in enumerate(b):
|
||||
current.append(previous[index] + 1 if char == other else 0)
|
||||
best = max(best, max(current))
|
||||
previous = current
|
||||
return best
|
||||
|
||||
|
||||
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께."""
|
||||
"""고르개 — 단가표 항목 후보. 단가가 섰는지도 함께.
|
||||
|
||||
낱말이 **모두** 든(코드·이름·규격) 항목이 먼저, 그다음 **두 글자 이상 조각이 겹친** 항목을
|
||||
겹친 글자 수 순으로(①). 고정형 줄 이름(깬잡석채집)과 단가표 제목(막돌 채집)은 낱말이 달라
|
||||
전부 포함 방식으로는 후보가 0 이었음. ⚠ 후보만 — 고르는 것은 사람(명세 2장 · 별칭표로 안 맞춤).
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
|
||||
kinds = SEARCH_KINDS[kind]
|
||||
words = query.lower().split()
|
||||
found: list[dict[str, Any]] = []
|
||||
for title in book.titles.values() if words else ():
|
||||
if len(found) >= limit:
|
||||
break
|
||||
# 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
|
||||
compact = re.sub(r"[\W_]+", "", query.lower())
|
||||
scored: list[tuple[int, int, int, Any]] = []
|
||||
for order, title in enumerate(book.titles.values() if words else ()):
|
||||
if title.kind.value not in kinds:
|
||||
continue
|
||||
text = f"{title.code} {title.name} {title.spec}".lower()
|
||||
if all(word in text for word in words):
|
||||
try:
|
||||
money = book.resolve(title.code)
|
||||
total: float | None = float(money.material + money.labor + money.expense)
|
||||
except PriceBookError:
|
||||
total = None
|
||||
found.append(
|
||||
{
|
||||
"code": title.code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": _unit(title.unit),
|
||||
"price": total,
|
||||
}
|
||||
)
|
||||
scored.append((1, len(compact), order, title))
|
||||
continue
|
||||
overlap = _longest_common(
|
||||
compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower())
|
||||
)
|
||||
if overlap >= MIN_FRAGMENT:
|
||||
scored.append((0, overlap, order, title))
|
||||
scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
|
||||
found: list[dict[str, Any]] = []
|
||||
for _whole, _overlap, _order, title in scored[:limit]:
|
||||
try:
|
||||
money = book.resolve(title.code)
|
||||
total: float | None = float(money.material + money.labor + money.expense)
|
||||
except PriceBookError:
|
||||
total = None
|
||||
found.append(
|
||||
{
|
||||
"code": title.code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": _unit(title.unit),
|
||||
"price": total,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
@@ -34,6 +34,8 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
|
||||
FACE_DRESSING_CUT_CLASSES,
|
||||
FACE_DRESSING_FILL_CLASSES,
|
||||
FACE_DRESSING_FILL_SUGGESTED,
|
||||
ROOT_REMOVAL_EXCAVATOR_SIZES,
|
||||
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
|
||||
SummaryInput,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
|
||||
@@ -173,6 +175,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
"basis": FACE_DRESSING_FILL_SUGGESTED[1],
|
||||
},
|
||||
}
|
||||
# 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인).
|
||||
table["root_removal_excavator_choices"] = {
|
||||
"choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES),
|
||||
"suggested": {
|
||||
"value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0],
|
||||
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
|
||||
},
|
||||
}
|
||||
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
|
||||
structures = await _route_structures(project_id)
|
||||
table["preparation"] = build_preparation_table(
|
||||
@@ -478,6 +488,8 @@ class QuantitySettingsBody(BaseModel):
|
||||
stand_volume_class: str | None = None
|
||||
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
|
||||
face_dressing_cut_class: str | None = None
|
||||
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
|
||||
root_removal_excavator_m3: str | None = None
|
||||
face_dressing_fill_class: str | None = None
|
||||
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
|
||||
face_dressing_fill_area_m2: float | None = None
|
||||
@@ -556,6 +568,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
for key, choices in (
|
||||
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
|
||||
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
|
||||
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
|
||||
):
|
||||
if key in values and values[key] not in choices:
|
||||
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
|
||||
|
||||
@@ -497,6 +497,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
|
||||
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
|
||||
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
|
||||
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
|
||||
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
|
||||
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
|
||||
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
|
||||
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
|
||||
)
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
||||
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
||||
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
||||
③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈.
|
||||
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
@@ -110,3 +113,107 @@ async def save_stmate_recipe(
|
||||
"note": item["note"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LibraryCloneRequest(BaseModel):
|
||||
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
tier: Literal["received", "company", "program"]
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
|
||||
async def clone_library_item(
|
||||
project_id: UUID,
|
||||
payload: LibraryCloneRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
find_item,
|
||||
save_item_personal,
|
||||
tier_dirs,
|
||||
)
|
||||
|
||||
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
|
||||
folder = dirs.get("personal")
|
||||
if folder is None:
|
||||
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
|
||||
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
|
||||
if item is None or item.get("type_id") != payload.type_id:
|
||||
return _error(404, "베낄 항목을 찾지 못했습니다.")
|
||||
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
|
||||
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
|
||||
code = await asyncio.to_thread(save_item_personal, folder, body)
|
||||
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
|
||||
|
||||
|
||||
async def _company_members(company_id: int) -> list[dict[str, Any]]:
|
||||
"""같은 회사 구성원 — B01 구성원 저장소를 읽기만(시험이 갈아 끼움)."""
|
||||
from B01_Dashboard.B01_Dashboard_Repository_Members import list_company_members
|
||||
|
||||
return await list_company_members(company_id)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets/library/colleagues")
|
||||
async def list_library_colleagues(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> JSONResponse:
|
||||
"""④ 보낼 동료 — 같은 회사에서 나를 뺀 사람(이름만)."""
|
||||
company_id = session.get("company_id")
|
||||
if company_id is None:
|
||||
return _error(403, "회사에 속한 사용자만 보냅니다.")
|
||||
members = await _company_members(int(company_id))
|
||||
colleagues = [
|
||||
{"id": m["id"], "name": m.get("name") or m.get("email") or ""}
|
||||
for m in members
|
||||
if m["id"] != session.get("user_id")
|
||||
]
|
||||
return JSONResponse(content={"status": "success", "colleagues": colleagues})
|
||||
|
||||
|
||||
class LibraryShareRequest(BaseModel):
|
||||
"""내 개인 단 항목 하나를 같은 회사 동료에게."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
to_user_id: int
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/share")
|
||||
async def share_library_item(
|
||||
project_id: UUID,
|
||||
payload: LibraryShareRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""④ [동료에게 보내기] — 받는 쪽 「받음」 단에 복사(PLAN 4장 공유 · 브레인 판정 ①)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
RECEIVED_SUBDIR,
|
||||
find_item,
|
||||
share_item,
|
||||
tier_dirs,
|
||||
)
|
||||
|
||||
company_id = session.get("company_id")
|
||||
dirs = tier_dirs(company_id, session.get("user_id"))
|
||||
if "personal" not in dirs:
|
||||
return _error(403, "회사에 속한 사용자만 보냅니다.")
|
||||
if payload.to_user_id == session.get("user_id"):
|
||||
return _error(400, "나에게는 보내지 않습니다.")
|
||||
members = {m["id"]: m for m in await _company_members(int(company_id))}
|
||||
target = members.get(payload.to_user_id)
|
||||
if target is None:
|
||||
return _error(404, "같은 회사에서 받는 사람을 찾지 못했습니다.")
|
||||
item = await asyncio.to_thread(find_item, dirs, "personal", payload.code)
|
||||
if item is None or item.get("type_id") != payload.type_id:
|
||||
return _error(404, "보낼 내 항목을 찾지 못했습니다.")
|
||||
me = members.get(session.get("user_id")) or {}
|
||||
sender = {"user_id": session.get("user_id"), "name": me.get("name") or me.get("email") or ""}
|
||||
to_folder = dirs["personal"].parents[1] / str(payload.to_user_id) / RECEIVED_SUBDIR
|
||||
code = await asyncio.to_thread(share_item, item, to_folder, sender)
|
||||
return JSONResponse(content={"status": "success", "code": code, "to": target.get("name") or ""})
|
||||
|
||||
@@ -316,6 +316,7 @@ async def get_structure_library(
|
||||
"status": "success",
|
||||
"items": await asyncio.to_thread(list_items, dirs, type_id),
|
||||
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
|
||||
"can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -326,7 +327,7 @@ class LibraryImportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
tier: Literal["personal", "company", "program"]
|
||||
tier: Literal["personal", "received", "company", "program"]
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@@ -394,15 +395,32 @@ def _no_personal() -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인).
|
||||
PUBLISH = ("company", "program")
|
||||
|
||||
|
||||
def _publish_dir(session: dict[str, Any], tier: str) -> Path | None:
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs
|
||||
|
||||
admin = session.get("role") == "SYSTEM_ADMIN"
|
||||
if tier == "program":
|
||||
return program_library_dir() if admin else None
|
||||
if tier == "company" and (admin or session.get("is_master")):
|
||||
return tier_dirs(session.get("company_id"), None).get("company")
|
||||
return _personal_dir(session) if tier == "personal" else None
|
||||
|
||||
|
||||
class LibrarySaveRequest(BaseModel):
|
||||
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
|
||||
"""[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sheet_key: str
|
||||
tier: Literal["personal", "company", "program"] = "personal"
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
|
||||
async def put_structure_library_personal(
|
||||
project_id: UUID,
|
||||
payload: LibrarySaveRequest,
|
||||
@@ -420,9 +438,13 @@ async def put_structure_library_personal(
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
||||
from common_util.common_util_project_settings import quantity_settings
|
||||
|
||||
folder = _personal_dir(session)
|
||||
folder = _publish_dir(session, payload.tier)
|
||||
if folder is None:
|
||||
return _no_personal()
|
||||
if payload.tier == "personal":
|
||||
return _no_personal()
|
||||
return JSONResponse(
|
||||
status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."}
|
||||
)
|
||||
project_root = await _project_root(project_id)
|
||||
if project_root is None:
|
||||
return _not_found()
|
||||
@@ -439,7 +461,7 @@ async def put_structure_library_personal(
|
||||
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
||||
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
||||
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
||||
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows)
|
||||
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier)
|
||||
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
*
|
||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
||||
* ⚠ 선택지는 **서버가 내려준 목록 그대로**(`face_dressing_choices`) — 화면에 다시 적지 않음(두 벌 금지).
|
||||
* ⚠ 제안값 없음(판정 Ⓒ) — 첫 보기가 「안 정함」이고 비면 내역 줄이 입력 사유로 섬.
|
||||
* ⚠ 스스로 안 고름(판정 Ⓒ) — 첫 보기가 「안 정함」이고 비면 내역 줄이 입력 사유로 섬. 성토면 제안은
|
||||
* 회색 근거 + [제안값 넣기]를 **누른 때만**(메인 976e5fd9 `fill_suggested`).
|
||||
* ⚠ 면적은 화면이 셈하지 않음 — 비우면 서버가 파종 면적(초류종자살포 × 반영률)을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -29,6 +31,8 @@ export interface FaceDressingDraft {
|
||||
export interface FaceDressingChoices {
|
||||
cut: string[];
|
||||
fill: string[];
|
||||
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
|
||||
fill_suggested?: { value: string; basis: string };
|
||||
}
|
||||
|
||||
export function appendFaceDressingFields(
|
||||
@@ -76,11 +80,36 @@ export function appendFaceDressingFields(
|
||||
draft.face_dressing_cut_class = v;
|
||||
}),
|
||||
);
|
||||
panel.append(
|
||||
pick("B08_Quantity_FaceDressing_Fill", draft.face_dressing_fill_class, choices.fill, (v) => {
|
||||
const fillRow = pick(
|
||||
"B08_Quantity_FaceDressing_Fill",
|
||||
draft.face_dressing_fill_class,
|
||||
choices.fill,
|
||||
(v) => {
|
||||
draft.face_dressing_fill_class = v;
|
||||
}),
|
||||
},
|
||||
);
|
||||
panel.append(fillRow);
|
||||
// 성토면 제안 — 칸은 비워 둔 채 회색 근거 + 단추(층따기와 같은 모양). 비우면 「안 정함」 그대로.
|
||||
const suggested = choices.fill_suggested;
|
||||
if (suggested?.value) {
|
||||
panel.append(
|
||||
h.hintRow(
|
||||
`${L("B08_Quantity_FaceDressing_Suggest")} ${suggested.value} — ${suggested.basis}`,
|
||||
),
|
||||
);
|
||||
panel.append(
|
||||
createButton({
|
||||
label: L("B08_Quantity_FaceDressing_FillSuggested"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const select = fillRow.querySelector("select");
|
||||
if (select) select.value = suggested.value;
|
||||
draft.face_dressing_fill_class = suggested.value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
panel.append(
|
||||
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
|
||||
draft.face_dressing_fill_area_m2 = v;
|
||||
|
||||
@@ -86,9 +86,13 @@ export interface StructureSheet extends StandardSheetSpec {
|
||||
name: string;
|
||||
code?: string | null;
|
||||
imported_from?: string | null;
|
||||
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
|
||||
origin_project?: string | null;
|
||||
};
|
||||
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
||||
formula_sheet?: FormulaSheet;
|
||||
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
|
||||
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
|
||||
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
|
||||
var_candidates?: {
|
||||
name: string;
|
||||
@@ -260,6 +264,12 @@ function sheetBody(
|
||||
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
|
||||
el("span", "b08-grid__caption", sheet.unit_label),
|
||||
);
|
||||
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
|
||||
if (sheet.spec_mismatch) {
|
||||
const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch;
|
||||
head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`));
|
||||
main.classList.add("b08-unit__manual");
|
||||
}
|
||||
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
|
||||
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
|
||||
if (!sheet.rows.length) {
|
||||
@@ -414,6 +424,7 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
|
||||
sheetKey: sheet.key,
|
||||
typeId: sheet.library_item.type_id,
|
||||
currentCode: sheet.library_item.code ?? null,
|
||||
originProject: sheet.library_item.origin_project,
|
||||
isDirty: () => dirty,
|
||||
confirmTake: () => {
|
||||
const edited = editedRows(sheet);
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
|
||||
|
||||
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
personal: "개인",
|
||||
received: "받음",
|
||||
company: "회사",
|
||||
program: "기본",
|
||||
};
|
||||
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
|
||||
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
|
||||
|
||||
@@ -52,6 +57,8 @@ export interface LibraryPanelOptions {
|
||||
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
|
||||
isDirty: () => boolean;
|
||||
onImported: (notes: string[]) => Promise<void>;
|
||||
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
|
||||
originProject?: string | null;
|
||||
}
|
||||
|
||||
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
|
||||
@@ -80,13 +87,112 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
take.className = "b08-spec__save";
|
||||
take.textContent = "가져오기";
|
||||
take.hidden = true;
|
||||
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
|
||||
const clone = document.createElement("button");
|
||||
clone.type = "button";
|
||||
clone.className = "b08-quantity__tab";
|
||||
clone.textContent = "복제해서 내 것으로";
|
||||
clone.hidden = true;
|
||||
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
|
||||
let afterLoad = "";
|
||||
// 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음.
|
||||
const send = document.createElement("button");
|
||||
send.type = "button";
|
||||
send.className = "b08-quantity__tab";
|
||||
send.textContent = "동료에게 보내기";
|
||||
send.hidden = true;
|
||||
const who = document.createElement("select");
|
||||
who.className = "b08-spec__input";
|
||||
who.hidden = true;
|
||||
const syncClone = (): void => {
|
||||
const [tier] = list.value.split("|");
|
||||
clone.disabled = tier === "personal";
|
||||
send.disabled = tier !== "personal";
|
||||
};
|
||||
send.addEventListener("click", () => {
|
||||
const [tier, code] = list.value.split("|");
|
||||
if (tier !== "personal" || !code) return;
|
||||
void (async () => {
|
||||
send.disabled = true;
|
||||
try {
|
||||
if (who.hidden) {
|
||||
const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>(
|
||||
await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }),
|
||||
);
|
||||
who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id))));
|
||||
who.hidden = colleagues.length === 0;
|
||||
status.textContent = colleagues.length
|
||||
? "받을 동료를 고르고 한 번 더 누를 것"
|
||||
: "보낼 동료가 없음";
|
||||
return;
|
||||
}
|
||||
const label = list.selectedOptions[0]?.textContent ?? "";
|
||||
const name = who.selectedOptions[0]?.textContent ?? "";
|
||||
if (
|
||||
!window.confirm(
|
||||
`「${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/share"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }),
|
||||
}),
|
||||
);
|
||||
who.hidden = true;
|
||||
status.textContent = `${name}에게 보냄`;
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "보내지 못함";
|
||||
} finally {
|
||||
syncClone();
|
||||
}
|
||||
})();
|
||||
});
|
||||
list.addEventListener("change", syncClone);
|
||||
clone.addEventListener("click", () => {
|
||||
const [tier, code] = list.value.split("|");
|
||||
const label = list.selectedOptions[0]?.textContent ?? "";
|
||||
if (!tier || !code || tier === "personal") return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`「${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
clone.disabled = true;
|
||||
try {
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/clone"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type_id: typeId, tier, code }),
|
||||
}),
|
||||
);
|
||||
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
|
||||
load.click();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "복제 못함";
|
||||
syncClone();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
load.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
load.disabled = true;
|
||||
status.textContent = "목록 받는 중…";
|
||||
try {
|
||||
const { items } = await readJson<{ items: LibraryItem[] }>(
|
||||
const { items, can_publish: canPublish } = await readJson<{
|
||||
items: LibraryItem[];
|
||||
can_publish?: { company: boolean; program: boolean };
|
||||
}>(
|
||||
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
|
||||
credentials: "include",
|
||||
}),
|
||||
@@ -101,8 +207,13 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
list.hidden = take.hidden = items.length === 0;
|
||||
status.textContent = items.length ? "" : "가져올 항목이 없음";
|
||||
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
|
||||
syncClone();
|
||||
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
|
||||
toCompany.hidden = !canPublish?.company;
|
||||
toProgram.hidden = !canPublish?.program;
|
||||
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
||||
afterLoad = "";
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
||||
} finally {
|
||||
@@ -187,11 +298,42 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
);
|
||||
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
|
||||
});
|
||||
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
|
||||
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
|
||||
const button = personal(label, async () => {
|
||||
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
||||
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
|
||||
const masked =
|
||||
tier === "program" && options.originProject
|
||||
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
|
||||
: "";
|
||||
if (
|
||||
!window.confirm(
|
||||
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}`,
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/publish"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet_key: sheetKey, tier }),
|
||||
}),
|
||||
);
|
||||
return `${whom}에 발행함`;
|
||||
});
|
||||
button.hidden = true;
|
||||
return button;
|
||||
};
|
||||
const toCompany = publish("회사 라이브러리에 발행", "company");
|
||||
const toProgram = publish("프로그램 기본으로 발행", "program");
|
||||
const mine = document.createElement("div");
|
||||
mine.className = "b08-sheet__actions";
|
||||
mine.append(save, remove);
|
||||
mine.append(save, remove, toCompany, toProgram);
|
||||
|
||||
panel.append(title, scope, load, list, take, mine, status);
|
||||
panel.append(title, scope, load, list, take, clone, send, who, mine, status);
|
||||
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
||||
panel.append(
|
||||
buildStmatePanel({
|
||||
|
||||
@@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const items = payload.items ?? [];
|
||||
results.replaceChildren(
|
||||
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
|
||||
...(items.length
|
||||
? items.map(choice)
|
||||
: [
|
||||
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
|
||||
el(
|
||||
"span",
|
||||
"b08-grid__caption",
|
||||
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
|
||||
),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
results.replaceChildren(
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
|
||||
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12번 정정 반영).
|
||||
|
||||
⚠ **금액을 안 낸다** — 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` ·
|
||||
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다.
|
||||
⚠ **금액을 안 낸다** — 2026-09-14 요율표 셋 결론(PLAN 7장): 실적형 제비율 `RATE_RA`·`RATE_RA1` 은
|
||||
**임도 미적용**(100억 미만 · 예정가격작성기준 §37②) · 수공 세부 경비 `RateB` 는 **법정 요율 없음**
|
||||
(업체 실측 대상 §34① — 설계자 입력 칸) · 실적 안전표 `An_A` 는 고시 편입(제4조·별표1)이나 실적형
|
||||
자체가 임도 미적용. 줄마다 무엇이 막혔는지만 적는다.
|
||||
**0 원으로 채우지 않는다** — 0 원은 「없다」가 아니라 「공짜」로 읽혀 총액이 조용히 작아진다.
|
||||
⚠ 35번 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재)은 「확인 대기」로 세운다.
|
||||
⚠ 35번 §5 미확인은 「확인 대기」로 세운다 — 지금 남은 것은 **실적형 이윤 밑수** 하나다.
|
||||
실적수공 안전관리비는 2026-09-14 코덱스가 STmate 원문에서 **있음**을 확인해 별도내역으로 내렸다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,12 +19,20 @@ from typing import Any
|
||||
#: 줄 상태 — 화면이 그대로 보인다.
|
||||
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
|
||||
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
|
||||
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)"
|
||||
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)"
|
||||
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)"
|
||||
RATE_B = (
|
||||
"법정 요율 없음 — 값을 넣어 주십시오(업체 실측 대상 · 예정가격작성기준 §34①) · 수공 세부 경비"
|
||||
"(STmate RateB) · 입력 칸은 수공형식 금액 셈과 함께 섬"
|
||||
)
|
||||
RATE_RA = (
|
||||
"임도 미적용 — 100억 미만(예정가격작성기준 §37②) · 표준시장단가(실적공사비) 제비율"
|
||||
"(STmate RATE_RA·RATE_RA1)"
|
||||
)
|
||||
RATE_AN_A = (
|
||||
"임도 미적용 — 실적형 형식 전체가 100억 미만 임도에 안 씀(§37②) · 요율 자체는 고시 편입"
|
||||
"(건설업 산업안전보건관리비 계상 및 사용기준 제4조·별표1)"
|
||||
)
|
||||
TOTAL = "합계 — 위 줄이 다 서야 섬"
|
||||
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
|
||||
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
|
||||
DIRECT = "설계내역서 합계에서 옴"
|
||||
|
||||
_SUGONG_EXPENSES = (
|
||||
@@ -115,7 +126,8 @@ def _actual(sugong: bool) -> list[dict[str, Any]]:
|
||||
_r("6)", "연금보험료", "직접노무비 × 율", COMMON, 1),
|
||||
_r("7)", "퇴직공제부금비", "직접노무비 × 율", COMMON, 1),
|
||||
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) × 율", RATE_AN_A, 1),
|
||||
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1),
|
||||
# TA·SA 둘 다 별도내역 — SA 도 있음이 확인됨(2026-09-14 코덱스, 35번 §5-2 풀림).
|
||||
_r("9)", "안전관리비", "", SEPARATE, 1),
|
||||
_r("10)", "품질관리비", "", SEPARATE, 1),
|
||||
]
|
||||
if sugong:
|
||||
|
||||
@@ -54,6 +54,14 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
||||
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
|
||||
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
|
||||
),
|
||||
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
|
||||
"FP-12-04": (
|
||||
"원문 [주]",
|
||||
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
|
||||
" — [주]① 2회 이상 사용 고재량은 재료비 비율에 기포함. "
|
||||
"ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은"
|
||||
" **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.",
|
||||
),
|
||||
"FP-12-25": (
|
||||
"운반거리 미정",
|
||||
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
|
||||
@@ -76,13 +84,7 @@ CONDITIONAL_INCLUDED: dict[str, str] = {
|
||||
#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다).
|
||||
#: ⚠ 이 구현이 서면 그 줄을 지운다.
|
||||
BLOCKED_BY_DESIGN: dict[str, str] = {
|
||||
# 2026-09-13 브레인 판정 — B09 일위대가 일감(B08 Formwork 머리말 「횟수별 재료 환산은 여기서
|
||||
# 하지 않는다」). 표를 그대로 풀면 1회 사용 값(재료·노무 100 %)으로 조용히 비싸진다.
|
||||
"FP-12-04": (
|
||||
"거푸집 **사용횟수별 비율**(12-4 「1회 100 % · 4회 40 %…」)을 일위대가에 거는 셈이"
|
||||
" 아직 없어"
|
||||
" 풀지 않았습니다 — 풀면 1회 사용 값으로 비싸게 섭니다"
|
||||
),
|
||||
# (비어 있음) 12-4 합판거푸집은 2026-09-14 사용횟수 갈래로 풀려 걷음(`_JudgedTable`).
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@ from dataclasses import dataclass, field, replace
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14).
|
||||
# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다.
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis_Labels import (
|
||||
_RE_RANGE_CELL, # noqa: F401
|
||||
RANGE_DASH_CLASS, # noqa: F401
|
||||
RANGE_DASHES, # noqa: F401
|
||||
_normalize,
|
||||
is_non_resource_label,
|
||||
)
|
||||
|
||||
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
|
||||
USABLE_FORMS = frozenset({"productivity", "requirement"})
|
||||
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
|
||||
@@ -48,45 +58,6 @@ _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
|
||||
#: 보통인부 한 줄만 남았다).
|
||||
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
|
||||
|
||||
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
|
||||
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
|
||||
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
|
||||
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
|
||||
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
|
||||
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
|
||||
_NON_RESOURCE_WORDS = (
|
||||
"구분",
|
||||
"합계",
|
||||
"소계",
|
||||
"계",
|
||||
"단위",
|
||||
"비고",
|
||||
"규격",
|
||||
"명칭",
|
||||
"품명",
|
||||
"종류",
|
||||
"항목",
|
||||
"적용",
|
||||
"기준",
|
||||
"산출",
|
||||
"비율",
|
||||
"할증",
|
||||
"할인",
|
||||
"직접노무비",
|
||||
"재료비",
|
||||
"경비",
|
||||
"위치",
|
||||
"면적",
|
||||
"수량",
|
||||
"공종",
|
||||
"작업",
|
||||
"내역",
|
||||
"총계",
|
||||
"인력",
|
||||
"장비",
|
||||
"기계",
|
||||
)
|
||||
|
||||
|
||||
class ResourceAxisError(ValueError):
|
||||
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
|
||||
@@ -151,71 +122,6 @@ class ResourceCatalog:
|
||||
return narrowed[0] if len(narrowed) == 1 else None
|
||||
|
||||
|
||||
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
|
||||
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
|
||||
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
|
||||
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
|
||||
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
|
||||
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
|
||||
RANGE_DASHES = "∼~〜~-–‐"
|
||||
|
||||
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
|
||||
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
|
||||
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
|
||||
|
||||
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「12~14㎝」(2026-09-08 실측 39줄).
|
||||
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
|
||||
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
|
||||
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
|
||||
_RE_RANGE_CELL = re.compile(
|
||||
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
|
||||
)
|
||||
_RE_HANGUL = re.compile(r"[가-힣]")
|
||||
|
||||
|
||||
def is_non_resource_label(cell: str) -> bool:
|
||||
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
|
||||
|
||||
못 맞춘 목록에 이런 것이 섞이면 목록 자체가 못 쓰게 된다. 여기서 먼저 걷어낸다.
|
||||
"""
|
||||
text = _normalize(cell)
|
||||
if not text:
|
||||
return True
|
||||
if text.startswith(("※", "<", "(", "-", "ㆍ", "·")):
|
||||
return True
|
||||
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
|
||||
return True
|
||||
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
|
||||
if text[0].isdigit():
|
||||
return True
|
||||
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
|
||||
if len(_RE_HANGUL.findall(text)) < 2:
|
||||
return True
|
||||
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
|
||||
if text in _NON_RESOURCE_WORDS:
|
||||
return True
|
||||
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
|
||||
return _is_header_composite(text)
|
||||
|
||||
|
||||
def _is_header_composite(text: str) -> bool:
|
||||
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
|
||||
|
||||
낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
|
||||
반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`).
|
||||
"""
|
||||
rest = text
|
||||
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
|
||||
rest = rest.replace(word, "")
|
||||
rest = rest.replace("및", "").replace("별", "").strip()
|
||||
return rest == ""
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
|
||||
return re.sub(r"\s+", "", str(text or "")).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceRow:
|
||||
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
|
||||
@@ -442,6 +348,7 @@ def match_table(
|
||||
|
||||
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
|
||||
|
||||
if match_judged_table(node, table, catalog, result, basis_quantity, unit):
|
||||
return
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
|
||||
· 「·」 = 그 토질엔 그 자원 없음
|
||||
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
|
||||
F0336 12-4 합판거푸집 기준수량(1회) × 사용횟수별 비율 — 재료 줄은 재료 %, 인력 줄은 노무비 %
|
||||
(원문 L6187 · 2026-09-14 브레인 ㉮ — 비율 셈이 없어 막아 뒀던 표)
|
||||
|
||||
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
|
||||
**적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음.
|
||||
@@ -47,7 +49,17 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
||||
"form_split": {"굴착기": ("무한궤도", "타이어")},
|
||||
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
|
||||
},
|
||||
"F0336": {
|
||||
"code": "FP-12-04",
|
||||
"shape": "use_count",
|
||||
"prefix": "합판거푸집",
|
||||
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
|
||||
},
|
||||
}
|
||||
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
|
||||
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
|
||||
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
|
||||
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
|
||||
|
||||
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
|
||||
_UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$")
|
||||
@@ -103,6 +115,8 @@ def match_judged_table(
|
||||
return block("판정 표에 밑수가 없습니다")
|
||||
if judged["shape"] == "header_row":
|
||||
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||
elif judged["shape"] == "use_count":
|
||||
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
||||
else:
|
||||
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||
if isinstance(staged, str):
|
||||
@@ -176,3 +190,32 @@ def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str
|
||||
named = f"{variant} · {form}" if form else variant
|
||||
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
|
||||
return staged
|
||||
|
||||
|
||||
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
|
||||
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
|
||||
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
|
||||
if ratio_row is None:
|
||||
return "사용횟수 비율 줄"
|
||||
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
|
||||
number = re.compile(r"\d+(?:\.\d+)?")
|
||||
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
|
||||
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
|
||||
if not counts or not (len(counts) == len(material) == len(labor)):
|
||||
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
|
||||
staged: list = []
|
||||
for index, cells in enumerate(rows):
|
||||
name = "".join(cells[0].split()) if cells else ""
|
||||
base = parse_amount(cells[2]) if len(cells) > 2 else None
|
||||
if not name or name in _NOT_RESOURCE_ROWS or base is None:
|
||||
continue
|
||||
entry = _entry(catalog, cells[0], code)
|
||||
if entry is None:
|
||||
reason = unmatched_reason(catalog, cells[0])
|
||||
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
|
||||
continue
|
||||
ratios = labor if entry.kind == "labor" else material
|
||||
for count, ratio in zip(counts, ratios):
|
||||
amount = base * ratio / Decimal(100) / basis
|
||||
staged.append(_row(code, table, entry, amount, unit, index, f"{count}회"))
|
||||
return staged
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""B09 원가계산 — 자원 축 **셀 거르기 · 물결표 목록 · 셀 정규화** (`_ResourceAxis` 보조).
|
||||
|
||||
`B09_Estimation_ResourceAxis` 가 700줄 제한에 걸려 떼어 낸 파일이다(2026-09-14 순수 분리 —
|
||||
내용은 옮기기만 함). 바깥 파일은 여태처럼 `B09_Estimation_ResourceAxis` 에서 가져간다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
|
||||
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
|
||||
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
|
||||
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
|
||||
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
|
||||
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
|
||||
_NON_RESOURCE_WORDS = (
|
||||
"구분",
|
||||
"합계",
|
||||
"소계",
|
||||
"계",
|
||||
"단위",
|
||||
"비고",
|
||||
"규격",
|
||||
"명칭",
|
||||
"품명",
|
||||
"종류",
|
||||
"항목",
|
||||
"적용",
|
||||
"기준",
|
||||
"산출",
|
||||
"비율",
|
||||
"할증",
|
||||
"할인",
|
||||
"직접노무비",
|
||||
"재료비",
|
||||
"경비",
|
||||
"위치",
|
||||
"면적",
|
||||
"수량",
|
||||
"공종",
|
||||
"작업",
|
||||
"내역",
|
||||
"총계",
|
||||
"인력",
|
||||
"장비",
|
||||
"기계",
|
||||
)
|
||||
|
||||
|
||||
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
|
||||
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
|
||||
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
|
||||
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
|
||||
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
|
||||
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
|
||||
RANGE_DASHES = "∼~〜~-–‐"
|
||||
|
||||
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
|
||||
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
|
||||
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
|
||||
|
||||
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「12~14㎝」(2026-09-08 실측 39줄).
|
||||
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
|
||||
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
|
||||
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
|
||||
_RE_RANGE_CELL = re.compile(
|
||||
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
|
||||
)
|
||||
_RE_HANGUL = re.compile(r"[가-힣]")
|
||||
|
||||
|
||||
def is_non_resource_label(cell: str) -> bool:
|
||||
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
|
||||
|
||||
못 맞춘 목록에 이런 것이 섞이면 목록 자체가 못 쓰게 된다. 여기서 먼저 걷어낸다.
|
||||
"""
|
||||
text = _normalize(cell)
|
||||
if not text:
|
||||
return True
|
||||
if text.startswith(("※", "<", "(", "-", "ㆍ", "·")):
|
||||
return True
|
||||
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
|
||||
return True
|
||||
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
|
||||
if text[0].isdigit():
|
||||
return True
|
||||
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
|
||||
if len(_RE_HANGUL.findall(text)) < 2:
|
||||
return True
|
||||
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
|
||||
if text in _NON_RESOURCE_WORDS:
|
||||
return True
|
||||
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
|
||||
return _is_header_composite(text)
|
||||
|
||||
|
||||
def _is_header_composite(text: str) -> bool:
|
||||
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
|
||||
|
||||
낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
|
||||
반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`).
|
||||
"""
|
||||
rest = text
|
||||
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
|
||||
rest = rest.replace(word, "")
|
||||
rest = rest.replace("및", "").replace("별", "").strip()
|
||||
return rest == ""
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
|
||||
return re.sub(r"\s+", "", str(text or "")).strip()
|
||||
@@ -87,8 +87,8 @@ def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]):
|
||||
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
|
||||
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
|
||||
|
||||
`form` — 일반형식만 금액을 냄. 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은
|
||||
막고 사유 · 0 원으로 안 채움).
|
||||
`form` — 일반형식만 금액을 냄. 수공·실적일반·실적수공은 **서식 차례만**(실적형 임도 미적용 ·
|
||||
수공 세부 경비 법정 요율 없음 줄은 막고 사유 · 0 원으로 안 채움).
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_Router import get_bill
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
@@ -194,6 +194,8 @@ def safety_management_cost(
|
||||
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨
|
||||
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
|
||||
|
||||
variable = dataset.variable("rate_safety_pct")
|
||||
brackets = variable["brackets"]
|
||||
|
||||
@@ -248,7 +250,12 @@ def safety_management_cost(
|
||||
)
|
||||
percent = rate_percent(row, label=label)
|
||||
flat = base_amount(row)
|
||||
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
||||
# ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
|
||||
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
|
||||
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
|
||||
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
|
||||
# A(배수 1)는 어느 차례로 해도 같은 값이다.
|
||||
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
||||
|
||||
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
|
||||
raw_b, percent_b, flat_b = evaluate(
|
||||
@@ -257,8 +264,6 @@ def safety_management_cost(
|
||||
|
||||
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
|
||||
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
|
||||
|
||||
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
|
||||
|
||||
amount_a = emit(
|
||||
|
||||
@@ -73,6 +73,12 @@ const FORMS = [
|
||||
];
|
||||
/** DFM 본문 아래 탭 이름 그대로. */
|
||||
const FORM_TABS = ["general", "sugong", "actual_general", "actual_sugong"];
|
||||
/** 탭 말풍선 — 금액이 안 서는 까닭(2026-09-14 요율표 셋 결론 · 줄 사유는 서버 문구). */
|
||||
const FORM_TAB_NOTES: Record<string, string> = {
|
||||
sugong: "서식 차례만 — 세부 경비 법정 요율 없음(값 입력 대기 · 예정가격작성기준 §34①)",
|
||||
actual_general: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
|
||||
actual_sugong: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
|
||||
};
|
||||
/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */
|
||||
const formOf = new Map<string, string>();
|
||||
|
||||
@@ -320,7 +326,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
|
||||
label.append(el("span", "", row.label));
|
||||
if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`));
|
||||
label.title = [row.formula, row.note].filter(Boolean).join(" · ");
|
||||
// 금액 없는 줄 — 「-」 + 막힌 까닭(요율표 미확보 · 확인 대기). 0 원으로 안 보임.
|
||||
// 금액 없는 줄 — 「-」 + 막힌 까닭(임도 미적용 · 법정 요율 없음 · 확인 대기). 0 원으로 안 보임.
|
||||
if (row.amount_krw === null && row.blocked) label.append(el("span", "b09cs__warn", row.note));
|
||||
line.append(
|
||||
label,
|
||||
@@ -334,7 +340,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
|
||||
for (const key of FORM_TABS) {
|
||||
const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key);
|
||||
tab.setAttribute("aria-selected", String(key === sheet.form_key));
|
||||
if (key !== "general") tab.title = "서식 차례만 — 요율표 미확보로 금액 안 섬";
|
||||
if (FORM_TAB_NOTES[key]) tab.title = FORM_TAB_NOTES[key];
|
||||
tab.addEventListener("click", () => {
|
||||
formOf.set(ctx.projectId as string, key);
|
||||
reload();
|
||||
|
||||
@@ -43,6 +43,54 @@
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.2·소림",
|
||||
"to": "굴착기(무한궤도) 0.2 · 소",
|
||||
"scope": "FP-09-21",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.2·중림",
|
||||
"to": "굴착기(무한궤도) 0.2 · 중",
|
||||
"scope": "FP-09-21",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.2·밀림",
|
||||
"to": "굴착기(무한궤도) 0.2 · 밀",
|
||||
"scope": "FP-09-21",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.7·소림",
|
||||
"to": "굴착기(무한궤도) 0.7 · 소",
|
||||
"scope": "FP-09-21",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.7·중림",
|
||||
"to": "굴착기(무한궤도) 0.7 · 중",
|
||||
"scope": "FP-09-21",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.7·밀림",
|
||||
"to": "굴착기(무한궤도) 0.7 · 밀",
|
||||
"scope": "FP-09-21",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "리핑암",
|
||||
|
||||
@@ -173,6 +173,9 @@
|
||||
"basis_source": "⚠ 교차 참조 — 산림품셈 9-21 표에 밑수 표기가 없어 건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」 면적 축을 빌려 씀(사용자 확정 5차 6번). 마스터 밑수는 비어 있음(basis_missing F0294).",
|
||||
"variant_axis": "stand_volume_class",
|
||||
"variant_from": "stand_volume_class",
|
||||
"variant_template": "{root_removal_excavator_m3}·{stand_volume_class}",
|
||||
"variant_missing_reason": "제근 굴착기 크기·임목축적 등급이 아직 다 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-21: 굴착기 0.2·0.7 × 소림·중림·밀림 · 크기 제안 0.7㎥ 산림품셈 10-12-1 [주]①)",
|
||||
"variant_note": "2026-09-14 브레인 판정 — 크기·등급 두 칸을 템플릿으로 한 값(「0.7·소림」)으로 엮고 B09 갈래 표기는 범위 별칭(FP-09-21)이 앎. 둘 중 하나라도 비면 등급만 싣고 입력 사유.",
|
||||
"note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김."
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-14T16:09:37+09:00",
|
||||
"generated_at": "2026-09-14T18:26:57+09:00",
|
||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||
"source": {
|
||||
"dataset_id": "pum_forest",
|
||||
@@ -12,8 +12,8 @@
|
||||
"files": [
|
||||
{
|
||||
"file": "work_item_master_2026-01-01.json",
|
||||
"sha256": "a942f7ad412441ba0c7ad3f8149fd143b10502dae5d20053e5d98029ab31c6b4",
|
||||
"size_bytes": 838243
|
||||
"sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287",
|
||||
"size_bytes": 838421
|
||||
},
|
||||
{
|
||||
"file": "form_undetermined_2026-01-01.json",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
"pum_edition": "2026-01-01",
|
||||
"generated_at": "2026-09-14T16:09:37+09:00",
|
||||
"generated_at": "2026-09-14T18:26:57+09:00",
|
||||
"dataset_version": {
|
||||
"dataset_id": "pum_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
@@ -31615,7 +31615,14 @@
|
||||
"formula_rows": [],
|
||||
"special_glyphs": [],
|
||||
"capacity_formula_here": false,
|
||||
"variant_key": [],
|
||||
"variant_key": [
|
||||
"1회",
|
||||
"2회",
|
||||
"3회",
|
||||
"4회",
|
||||
"5회",
|
||||
"6회"
|
||||
],
|
||||
"condition_note": [
|
||||
"구 분",
|
||||
"단위",
|
||||
@@ -31717,7 +31724,14 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"variant_keys": []
|
||||
"variant_keys": [
|
||||
"1회",
|
||||
"2회",
|
||||
"3회",
|
||||
"4회",
|
||||
"5회",
|
||||
"6회"
|
||||
]
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-12-05",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""관 벽 · 돌쌓기 줄 뒷길이 — 인계가 **B08 이 실제로 쓴 뒷길이 + 출처**를 싣는다(2026-09-14 브레인).
|
||||
|
||||
종전: 인계는 저장 원본 `back_len_cm` 만 실어, 안 고른 벽(관 유입·유출부 기슭막이는 늘 빔)은
|
||||
`variant_value=None` → B09 가 13-04-02/05 뒷길이 갈래(#35·55·75cm이하)를 못 고름. 그런데 수량표·
|
||||
구조물도는 이미 품셈 13-4-4 [주]⑩ 표준표 하한으로 섰음(두께식·고임돌·채움콘크리트가 그 값).
|
||||
⇒ 값이 선 그 뒷길이를 갈래 값으로 · 출처(저장 / [주]⑩ 하한)를 판정 근거 칸에.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402
|
||||
|
||||
|
||||
def _row(type_id: str, options: dict) -> dict:
|
||||
unit = build_unit(
|
||||
[
|
||||
{
|
||||
"structure_id": "x",
|
||||
"type_id": type_id,
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"options": options,
|
||||
}
|
||||
],
|
||||
{type_id: type_id},
|
||||
)
|
||||
handoff = build_handoff(unit_quantity_table=unit)
|
||||
return next(r for r in handoff["work_items"] if r["variant_axis"] == "back_len_cm")
|
||||
|
||||
|
||||
def test_안_고른_뒷길이는_표준표_하한을_출처와_함께_보냄() -> None:
|
||||
row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0})
|
||||
assert row["variant_value"] == "25", row # 찰쌓기 ≤1.5m 하한 25㎝
|
||||
assert "뒷길이 25㎝" in row["spec_class_basis"] and "[주]⑩" in row["spec_class_basis"]
|
||||
|
||||
|
||||
def test_고른_뒷길이는_원본값_그대로_출처는_저장_제원() -> None:
|
||||
row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"})
|
||||
assert row["variant_value"] == "45"
|
||||
assert "[주]⑩" not in row["spec_class_basis"] and "저장 제원" in row["spec_class_basis"]
|
||||
|
||||
|
||||
def test_기슭막이_메쌓기도_하한으로_갈래가_서서_내역_금액이_섬() -> None:
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||
|
||||
options = {"height_m": 2.5, "length_m": 10.0, "form": "돌쌓기(메)"}
|
||||
row = _row("revetment", options)
|
||||
assert row["variant_value"] == "45", row # 메쌓기 ~3m 하한 36 → 규격 45
|
||||
assert row["work_item_code"] == "FP-13-04-02" and not row["blocked_kind"], row
|
||||
unit = build_unit(
|
||||
[{"structure_id": "x", "type_id": "revetment", "start_m": 0.0, "end_m": 10.0,
|
||||
"options": options}],
|
||||
{"revetment": "revetment"},
|
||||
) # fmt: skip
|
||||
bill = build_bill(build_handoff(unit_quantity_table=unit))
|
||||
line = next(r for r in bill.rows if r.code == "FP-13-04-02")
|
||||
assert line.amount_krw and int(line.amount_krw) > 0, line.note
|
||||
assert line.price_code.endswith("#55cm이하"), line.price_code # 45㎝ 를 담는 가장 좁은 구간
|
||||
@@ -24,6 +24,16 @@ def test_저장에_네_키를_싣고_불러온다() -> None:
|
||||
assert f"{key}: (stored.{key}" in PAGE, f"불러오기에 {key} 없음"
|
||||
|
||||
|
||||
def test_성토면_제안은_회색_근거와_단추로만_넣는다() -> None:
|
||||
"""메인 976e5fd9 — `fill_suggested` {value, basis}. 비워 두면 「안 정함」 그대로(스스로 안 고름)."""
|
||||
side = SIDE.read_text(encoding="utf-8")
|
||||
assert "choices.fill_suggested" in side and "suggested.basis" in side
|
||||
button = side.split("createButton(")[1]
|
||||
assert "draft.face_dressing_fill_class = suggested.value" in button # 누른 때만 넣음
|
||||
assert side.count("= suggested.value") == 2 # 단추 안의 칸·초안 둘뿐
|
||||
assert "BenchCut" not in side # 층따기 문구(「KDS 토사 0.5 m」)를 빌려 쓰지 않음
|
||||
|
||||
|
||||
def test_선택지는_서버_목록_그대로() -> None:
|
||||
side = SIDE.read_text(encoding="utf-8")
|
||||
assert "appendFaceDressingFields(" in PAGE and "face_dressing_choices" in PAGE
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""㉰ 고정형 장 사유 · ㉱ 규격 다름 (2026-09-14 브레인 판정).
|
||||
|
||||
㉰ 고정형을 가져온 장에 **전개 사유(뒷길이 표준·돌 종류…)가 그대로 남아** 고정형 줄과 안 맞음 = 거짓 사유 →
|
||||
항목 사유(원문 시점 수량·가산 행)를 띄우고 전개 사유는 **남긴 전개 줄(토공·버림·기초잡석·채집석)에 걸린 것만** 둠.
|
||||
㉱ H=2.0 호표를 H=2.5 벽에 쓰면 벽 수량은 박힌 값(H2.0) · 토공·사토 공제는 장 제원(H2.5) — 밑수가 갈림 →
|
||||
미확정과 같은 급으로 보임(빨간 테두리·「규격 다름」 배지) · 금액은 섬 · 막지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HOPYO = {
|
||||
"no": 6,
|
||||
"source_code": "B00010",
|
||||
"name": "기슭막이(깬잡석,찰쌓기 L3=45m)",
|
||||
"spec": "H=2.0m, 채집",
|
||||
"unit": "M",
|
||||
"basis": [],
|
||||
"contract_rows": 0,
|
||||
"rows": [
|
||||
{
|
||||
"name": "깬잡석찰쌓기",
|
||||
"spec": "L3=45",
|
||||
"amount": 2.09,
|
||||
"unit": "M2",
|
||||
"remark": "",
|
||||
"source_code": "",
|
||||
},
|
||||
{
|
||||
"name": "공구손료",
|
||||
"spec": "노무비의 %",
|
||||
"amount": 2,
|
||||
"unit": "%",
|
||||
"remark": "",
|
||||
"source_code": "",
|
||||
},
|
||||
],
|
||||
}
|
||||
NAMES = {"masonry_wet": "돌쌓기(찰)"}
|
||||
|
||||
|
||||
def _sheet(height: float) -> dict:
|
||||
wall = StructureInstance.model_validate(
|
||||
{
|
||||
"structure_id": "a",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"options": {"height_m": height, "foundation": "기초유"},
|
||||
}
|
||||
).model_dump()
|
||||
templates = {
|
||||
"masonry_wet": {
|
||||
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"),
|
||||
"code": "AX-ST-0000abcd",
|
||||
}
|
||||
}
|
||||
table = build_table([wall], NAMES, {}, {}, None, structure_templates=templates)
|
||||
payload = build_standard_sheets(table, {})
|
||||
apply_templates(payload, {}, templates)
|
||||
return payload["sheets"][0]
|
||||
|
||||
|
||||
def test_고정형_장은_항목_사유를_띄우고_남의_사유를_걷는다() -> None:
|
||||
notes = " / ".join(_sheet(2.0)["notes"])
|
||||
assert "원문 시점 수량" in notes and "가산 행 1줄" in notes
|
||||
assert "돌 종류를 안 골라" not in notes and "뒷채움 폭" not in notes
|
||||
|
||||
|
||||
def test_규격이_다르면_미확정_급으로_밑수_갈림을_알린다() -> None:
|
||||
sheet = _sheet(2.5)
|
||||
mismatch = sheet["spec_mismatch"]
|
||||
assert (mismatch["item_height_m"], mismatch["sheet_height_m"]) == (2.0, 2.5)
|
||||
assert "규격 다름" in sheet["notes"][0]
|
||||
assert "토공·사토 공제는 장 제원(H=2.5m)" in sheet["notes"][0]
|
||||
assert any(row["amount"] for row in sheet["rows"]) # 금액·수량은 막지 않음
|
||||
|
||||
|
||||
def test_규격이_같으면_아무것도_안_뜬다() -> None:
|
||||
sheet = _sheet(2.0)
|
||||
assert not sheet.get("spec_mismatch")
|
||||
assert not any("규격 다름" in note for note in sheet["notes"])
|
||||
|
||||
|
||||
def test_화면이_규격_다름을_빨간_배지로() -> None:
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(encoding="utf-8")
|
||||
assert "spec_mismatch" in ui and "규격 다름" in ui and "b08-unit__badge" in ui
|
||||
@@ -0,0 +1,108 @@
|
||||
"""라이브러리 공유 — 같은 회사 동료에게 내 항목을 복사해 보냄 (PLAN 4장 · 2026-09-14 브레인 판정 ①).
|
||||
|
||||
⚠ 받는 쪽이 모르게 들어가지 않음 — 받은 항목은 그 사람 개인 단을 덮지 않고 **「받음」 단**에 따로 섬
|
||||
(개인 단은 종류당 하나라 그대로 넣으면 동료가 고친 내 것이 조용히 덮임). 받은 뒤 가져오기·복제는 받는 사람 몫.
|
||||
⚠ 같은 회사 안만 · 출처 공사명은 그대로(같은 회사라 그 공사를 앎 · 판정 ③).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
|
||||
import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
|
||||
from common_util.common_util_auth import verify_session # noqa: E402
|
||||
|
||||
LIB = "/api/projects/33333333-3333-3333-3333-333333333333/quantity/structure-sheets/library"
|
||||
MINE = "AX-ST-0000aaaa"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
item = {
|
||||
**load_template("masonry_wet"),
|
||||
"code": MINE,
|
||||
"library_tier": "personal",
|
||||
"name": "내 돌쌓기",
|
||||
"origin": {"project": "봉화 임도"},
|
||||
}
|
||||
folder = tmp_path / "storage" / "7" / "42" / "library"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / f"{MINE}.json").write_text(json.dumps(item), encoding="utf-8")
|
||||
# 받는 사람(43)도 같은 종류 내 것이 있음 — 덮이면 안 됨
|
||||
theirs = tmp_path / "storage" / "7" / "43" / "library"
|
||||
theirs.mkdir(parents=True)
|
||||
(theirs / "AX-ST-0000bbbb.json").write_text(
|
||||
json.dumps({**item, "code": "AX-ST-0000bbbb", "name": "동료 것"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
async def members(company_id: int) -> list[dict]:
|
||||
assert company_id == 7
|
||||
return [
|
||||
{"id": 42, "name": "나", "email": "me@x"},
|
||||
{"id": 43, "name": "동료", "email": "you@x"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(router_module, "_company_members", members)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_동료_목록은_같은_회사에서_나를_뺀다(client: TestClient) -> None:
|
||||
body = client.get(f"{LIB}/colleagues").json()
|
||||
assert body["colleagues"] == [{"id": 43, "name": "동료"}]
|
||||
|
||||
|
||||
def test_보내면_받는_쪽_받음_단에_서고_그_사람_개인_단은_안_덮인다(
|
||||
client: TestClient, tmp_path: Path
|
||||
) -> None:
|
||||
sent = client.put(
|
||||
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 43}
|
||||
)
|
||||
assert sent.status_code == 200, sent.text
|
||||
received = tmp_path / "storage" / "7" / "43" / "library_received" / f"{MINE}.json"
|
||||
saved = json.loads(received.read_text(encoding="utf-8"))
|
||||
assert saved["library_tier"] == "received"
|
||||
assert saved["received_from"]["name"] == "나" and saved["origin"]["project"] == "봉화 임도"
|
||||
theirs = tmp_path / "storage" / "7" / "43" / "library" / "AX-ST-0000bbbb.json"
|
||||
assert json.loads(theirs.read_text(encoding="utf-8"))["name"] == "동료 것"
|
||||
dirs = library_module.tier_dirs(7, 43)
|
||||
listed = library_module.list_items(dirs, "masonry_wet")
|
||||
assert [i["tier"] for i in listed][:2] == ["personal", "received"]
|
||||
|
||||
|
||||
def test_회사_밖이나_내_것_아닌_항목은_못_보낸다(client: TestClient) -> None:
|
||||
stranger = client.put(
|
||||
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 99}
|
||||
)
|
||||
assert stranger.status_code == 404
|
||||
missing = client.put(
|
||||
f"{LIB}/share", json={"type_id": "masonry_wet", "code": "AX-ST-0000cccc", "to_user_id": 43}
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
myself = client.put(
|
||||
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 42}
|
||||
)
|
||||
assert myself.status_code == 400
|
||||
|
||||
|
||||
def test_화면에_보내기_단추와_받음_표시() -> None:
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "/share" in ui and "/colleagues" in ui and "동료에게 보내기" in ui
|
||||
assert 'received: "받음"' in ui
|
||||
@@ -0,0 +1,61 @@
|
||||
"""고르개 후보 — 두 글자 이상 조각이 겹치면 띄움 (2026-09-14 브레인 판정 ①).
|
||||
|
||||
실측(936be972): 고정형 줄 이름 그대로(깬잡석채집·고임돌채집…)로 찾으면 **후보 0** — 단가표 제목
|
||||
(「막돌 채집」·「레디믹스트콘크리트 타설」)과 낱말이 달라 「낱말 전부 포함」 방식으로는 영영 안 맞음.
|
||||
⇒ 겹친 조각(「채집」·「타설」)으로 넓게 띄우고 **겹친 글자 수 순**. 고르는 것은 사람(별칭표로 안 맞춤 · 명세 2장).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
||||
|
||||
|
||||
def _title(code: str, name: str, spec: str, unit: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
code=code, name=name, spec=spec, unit=unit, kind=SimpleNamespace(value="unit_price")
|
||||
)
|
||||
|
||||
|
||||
class Book:
|
||||
titles = {
|
||||
t.code: t
|
||||
for t in (
|
||||
_title("B-1", "막돌 채집", "㎡당", "㎡"),
|
||||
_title("B-2", "레디믹스트콘크리트 타설", "무근구조물", "㎥"),
|
||||
_title("B-3", "고임돌 채집", "기계", "㎥"),
|
||||
_title("B-4", "견치돌 찰쌓기", "뒷길이 35㎝", "㎡"),
|
||||
_title("B-5", "표토 제거", "", "㎡"),
|
||||
)
|
||||
}
|
||||
|
||||
def resolve(self, code: str) -> Money3:
|
||||
return Money3(Decimal(1), Decimal(0), Decimal(0))
|
||||
|
||||
|
||||
def _codes(query: str) -> list[str]:
|
||||
return [item["code"] for item in search_titles(Book(), query, "work")]
|
||||
|
||||
|
||||
def test_이름_그대로도_겹친_조각으로_후보가_뜬다() -> None:
|
||||
assert _codes("깬잡석채집")[:1] and set(_codes("깬잡석채집")) >= {"B-1", "B-3"}
|
||||
assert _codes("레미콘타설(장비)") == ["B-2"]
|
||||
assert _codes("깬잡석찰쌓기") == ["B-4"]
|
||||
|
||||
|
||||
def test_겹친_글자_수_순() -> None:
|
||||
# 「고임돌채집」 — 고임돌 채집(5자 겹침) · 막돌 채집(「돌채집」 3자) 차례
|
||||
assert _codes("고임돌채집")[:2] == ["B-3", "B-1"]
|
||||
|
||||
|
||||
def test_한_글자만_겹치면_안_띄운다() -> None:
|
||||
assert "B-5" not in _codes("깬잡석채집") # 겹침 없음
|
||||
assert "B-4" not in _codes("고임돌채집") # 「돌」 한 글자만 겹침
|
||||
|
||||
|
||||
def test_종전_낱말_검색도_그대로() -> None:
|
||||
assert _codes("찰쌓기 35") == ["B-4"]
|
||||
assert set(_codes("돌")) == {"B-1", "B-3", "B-4"} # 낱말이 제목에 든 것은 종전대로
|
||||
@@ -0,0 +1,70 @@
|
||||
"""9-21 제근 굴착기 크기 칸 — 2026-09-14 브레인 판정(산출 조건 칸 · 제안 0.7㎥ · 비면 사유).
|
||||
|
||||
9-21 품은 굴착기 0.2·0.7 × 임목축적 등급(소·중·밀) 여섯 갈래인데 B08 은 등급만 보내 B09 가 못 고름.
|
||||
⇒ 매핑 `variant_template` 이 크기·등급 두 입력을 한 값(「0.7·소림」)으로 엮고, 범위 별칭(FP-09-21)이
|
||||
그 값을 B09 갈래(「굴착기(무한궤도) 0.7 · 소」)로 잇는다 — 갈래 키 표기는 별칭표 한 곳만 앎.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
||||
ROOT_REMOVAL_EXCAVATOR_SIZES,
|
||||
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Preparation import STAND_VOLUME_CLASSES # noqa: E402
|
||||
|
||||
|
||||
def _row(**inputs) -> dict:
|
||||
summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "㎡",
|
||||
"amount": 1000.0, "application_ratio_pct": 100.0}]} # fmt: skip
|
||||
rows = build_handoff(summary_table=summary, **inputs)["work_items"]
|
||||
return next(r for r in rows if r["work_item_code"] == "FP-09-21")
|
||||
|
||||
|
||||
def test_크기와_등급을_한_갈래_값으로_엮어_내역_금액이_섬() -> None:
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||
|
||||
inputs = {"stand_volume_class": "소림", "root_removal_excavator_m3": "0.7"}
|
||||
row = _row(**inputs)
|
||||
assert row["variant_value"] == "0.7·소림" and not row["blocked_kind"], row
|
||||
summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "㎡",
|
||||
"amount": 1000.0}]} # fmt: skip
|
||||
bill = build_bill(build_handoff(summary_table=summary, **inputs))
|
||||
line = next(r for r in bill.rows if r.code == "FP-09-21")
|
||||
assert line.price_code == "B-FP-09-21#굴착기(무한궤도)0.7·소", line.note
|
||||
assert line.amount_krw and int(line.amount_krw) > 0, line.note
|
||||
|
||||
|
||||
def test_크기가_비면_금액_없이_입력_사유_등급은_그대로_실음() -> None:
|
||||
row = _row(stand_volume_class="중림")
|
||||
assert row["variant_value"] == "중림" # 판정 Ⓑ — 등급 갈래를 잃지 않음
|
||||
assert row["blocked_kind"] == "input_missing" and "굴착기" in row["blocked_reason"], row
|
||||
assert _row()["blocked_kind"] == "input_missing"
|
||||
|
||||
|
||||
def test_선택지는_마스터_갈래_키의_크기_제안은_0_7_근거_10_12_1() -> None:
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
||||
|
||||
node = next(
|
||||
n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-21"
|
||||
)
|
||||
sizes = {key.split(")")[1].split("·")[0].strip() for key in node["variant_keys"]}
|
||||
assert sizes == set(ROOT_REMOVAL_EXCAVATOR_SIZES)
|
||||
value, basis = ROOT_REMOVAL_EXCAVATOR_SUGGESTED
|
||||
assert value == "0.7" and "10-12-1" in basis
|
||||
# 크기 × 등급 여섯이 모두 별칭으로 B09 갈래에 닿음
|
||||
from common_util.common_util_aliases import alias_target, load_aliases
|
||||
|
||||
rows = load_aliases("variant")
|
||||
for size in ROOT_REMOVAL_EXCAVATOR_SIZES:
|
||||
for grade in STAND_VOLUME_CLASSES:
|
||||
target = alias_target(rows, f"{size}·{grade}", "FP-09-21", "2026-01-01")
|
||||
assert target in node["variant_keys"], (size, grade, target)
|
||||
@@ -105,6 +105,8 @@ def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None:
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui
|
||||
# 후보 0 이면 다음 길(갈래 바꾸기 · 수동 단가)을 적음 — 조용히 「없음」으로 막히지 않게(2026-09-14 브레인)
|
||||
assert "수동 단가를 넣을 것" in ui
|
||||
|
||||
|
||||
def test_사용자가_코드를_고르면_금액이_선다() -> None:
|
||||
|
||||
@@ -200,6 +200,102 @@ def test_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_
|
||||
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount)
|
||||
|
||||
|
||||
def test_프로그램_기본은_storage_작업본이_씨앗을_이긴다(storage: Path) -> None:
|
||||
"""관리자 UI(2026-09-14 브레인) — 프로그램 기본도 storage 작업본 · git 파일은 씨앗(없는 코드만)."""
|
||||
seed = load_template("masonry_wet")
|
||||
assert library_module.program_items()[0]["code"] == seed["code"] # 작업본 없으면 씨앗
|
||||
edited = {**seed, "name": "돌쌓기(찰) 관리자 고침"}
|
||||
folder = library_module.program_library_dir()
|
||||
folder.mkdir(parents=True)
|
||||
(folder / f"{seed['code']}.json").write_text(json.dumps(edited), encoding="utf-8")
|
||||
items = library_module.program_items()
|
||||
assert [i["name"] for i in items if i["type_id"] == "masonry_wet"] == ["돌쌓기(찰) 관리자 고침"]
|
||||
assert load_template("masonry_wet")["name"] == "돌쌓기(찰) 관리자 고침"
|
||||
assert str(folder).startswith(str(storage)) # 씨앗(git) 파일은 안 건드림
|
||||
|
||||
|
||||
def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client: TestClient, storage: Path) -> None:
|
||||
sheet = _sheet(client)
|
||||
denied = client.put(
|
||||
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
|
||||
)
|
||||
assert denied.status_code == 403 # 시스템 관리자만
|
||||
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()[
|
||||
"can_publish"
|
||||
] == {"company": False, "program": False}
|
||||
|
||||
client.app.dependency_overrides[verify_session] = lambda: {
|
||||
"company_id": 7,
|
||||
"user_id": 42,
|
||||
"is_master": True,
|
||||
"role": "ADMIN",
|
||||
}
|
||||
company = client.put(
|
||||
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "company"}
|
||||
)
|
||||
assert company.status_code == 200, company.text
|
||||
saved = json.loads(
|
||||
(storage / "7" / "library" / f"{company.json()['code']}.json").read_text("utf-8")
|
||||
)
|
||||
assert saved["library_tier"] == "company"
|
||||
assert (
|
||||
client.put(
|
||||
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
client.app.dependency_overrides[verify_session] = lambda: {
|
||||
"company_id": None,
|
||||
"user_id": 1,
|
||||
"role": "SYSTEM_ADMIN",
|
||||
}
|
||||
program = client.put(
|
||||
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
|
||||
)
|
||||
assert program.status_code == 200, program.text
|
||||
assert program.json()["code"] == load_template("masonry_wet")["code"] # 씨앗 코드로 덮어씀
|
||||
assert (library_module.program_library_dir() / f"{program.json()['code']}.json").is_file()
|
||||
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["can_publish"][
|
||||
"program"
|
||||
]
|
||||
|
||||
|
||||
def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None:
|
||||
"""2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③)."""
|
||||
origin = {
|
||||
"kind": "stmate_xlsx",
|
||||
"file": "봉화.xlsx",
|
||||
"project": "2024년 봉화 임도",
|
||||
"hopyo_no": 6,
|
||||
}
|
||||
template = {**load_template("masonry_wet"), "origin": origin}
|
||||
program = library_module.save_personal(tmp_path / "program", template, None, None, "program")
|
||||
company = library_module.save_personal(tmp_path / "company", template, None, None, "company")
|
||||
published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8"))
|
||||
kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8"))
|
||||
assert "project" not in published["origin"] and "file" not in published["origin"]
|
||||
assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림"
|
||||
assert published["origin"]["kind"] == "stmate_xlsx"
|
||||
assert kept["origin"] == origin
|
||||
assert template["origin"] == origin # 원본은 안 건드림
|
||||
|
||||
|
||||
def test_발행_확인창이_가릴_공사명을_보인다() -> None:
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui
|
||||
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "originProject: sheet.library_item.origin_project" in sheet_ui
|
||||
template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '"origin_project"' in template_py
|
||||
|
||||
|
||||
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
|
||||
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
|
||||
sheet = _sheet(client)
|
||||
@@ -216,6 +312,41 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None:
|
||||
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
|
||||
|
||||
|
||||
def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다(
|
||||
client: TestClient, project: Path, storage: Path
|
||||
) -> None:
|
||||
"""PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌."""
|
||||
import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router
|
||||
|
||||
client.app.include_router(extra_router.router)
|
||||
base = load_template("masonry_wet")
|
||||
folder = storage / "7" / "42" / "library"
|
||||
for path in folder.glob("*.json"):
|
||||
path.unlink() # 개인 단을 비워 새로 생기는지 봄
|
||||
response = client.put(
|
||||
f"{SHEETS}/library/clone",
|
||||
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
code = response.json()["code"]
|
||||
saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
|
||||
assert saved["library_tier"] == "personal" and saved["code"] != base["code"]
|
||||
assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]}
|
||||
assert saved["rows"] == base["rows"]
|
||||
assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로
|
||||
missing = client.put(
|
||||
f"{SHEETS}/library/clone",
|
||||
json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"},
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
|
||||
denied = client.put(
|
||||
f"{SHEETS}/library/clone",
|
||||
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
|
||||
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
|
||||
folder = tmp_path / "personal"
|
||||
@@ -236,6 +367,17 @@ def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) ->
|
||||
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
|
||||
|
||||
|
||||
def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "/clone" in ui and "복제해서 내 것으로" in ui
|
||||
assert 'clone.disabled = tier === "personal"' in ui
|
||||
# 발행 단추 — 서버 권한(can_publish)대로만 보임
|
||||
assert "/publish" in ui and "toCompany.hidden = !canPublish?.company" in ui
|
||||
assert "toProgram.hidden = !canPublish?.program" in ui
|
||||
|
||||
|
||||
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""㉯ 양식이 안 품은 토공·버림 성분은 전개 값을 남김 (2026-09-14 브레인 판정 · 목록으로 못박음).
|
||||
|
||||
실측(936be972): STmate 호표(고정형)를 가져오니 전개 성분이 통째로 갈음돼 구조물터파기 85.25→25㎥
|
||||
(−6,037,712원) · 되메우기·잔토·버림 타설도 줄었음. STmate 호표는 원래 토공을 안 품음(토공 따로 셈).
|
||||
규칙 — `KEEP_ENGINE_COMPONENTS` 목록에 있는 이름은 **양식에 그 이름 줄이 없을 때** 전개 값을 보존,
|
||||
목록에 없는 성분은 양식으로 갈음. 양식형(돌쌓기 찰)은 그 줄을 스스로 품어 겹치지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
|
||||
KEEP_ENGINE_COMPONENTS,
|
||||
apply_templates,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
|
||||
|
||||
HOPYO = {
|
||||
"no": 6,
|
||||
"source_code": "B00010",
|
||||
"name": "기슭막이",
|
||||
"spec": "H=2.0m",
|
||||
"unit": "M",
|
||||
"basis": [],
|
||||
"contract_rows": 0,
|
||||
"rows": [
|
||||
{
|
||||
"name": "깬잡석찰쌓기",
|
||||
"spec": "L3=45",
|
||||
"amount": 2.09,
|
||||
"unit": "M2",
|
||||
"remark": "",
|
||||
"source_code": "",
|
||||
},
|
||||
],
|
||||
}
|
||||
WALL = StructureInstance.model_validate(
|
||||
{
|
||||
"structure_id": "a",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"options": {"height_m": 2.5, "back_len_cm": 45, "foundation": "기초유"},
|
||||
}
|
||||
).model_dump()
|
||||
NAMES = {"masonry_wet": "돌쌓기(찰)"}
|
||||
|
||||
|
||||
def _fixed() -> dict:
|
||||
return {
|
||||
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"),
|
||||
"code": "AX-ST-0000abcd",
|
||||
}
|
||||
|
||||
|
||||
def test_목록이_코드에_못박혀_있다() -> None:
|
||||
assert set(KEEP_ENGINE_COMPONENTS) >= {"터파기", "되메우기", "잔토처리", "버림콘크리트"}
|
||||
# ② 원문 빈도(벽·돌쌓기 호표 57) — 기초잡석 0/57 · 채집석(사토 공제 부피)은 호표 채집 줄(품)과 다른 것
|
||||
assert {"기초잡석", "채집석"} <= set(KEEP_ENGINE_COMPONENTS)
|
||||
|
||||
|
||||
def test_고정형을_가져와도_토공_버림_성분은_남는다() -> None:
|
||||
engine = build_table([WALL], NAMES, {}, {}, None, use_templates=False)["structures"][0]
|
||||
fixed = build_table([WALL], NAMES, {}, {}, None, structure_templates={"masonry_wet": _fixed()})
|
||||
components = {c["name"]: c for c in fixed["structures"][0]["components"]}
|
||||
engine_names = {c["name"] for c in engine["components"]}
|
||||
assert {"기초잡석", "채집석"} <= engine_names # 시험 벽에서 둘 다 서야 보존을 잼
|
||||
for name in ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석"):
|
||||
before = next(c for c in engine["components"] if c["name"] == name)
|
||||
assert components[name]["amount"] == before["amount"], name
|
||||
assert "깬잡석찰쌓기" in components and "돌" not in components # 목록 밖은 양식으로 갈음
|
||||
|
||||
|
||||
def test_양식형은_그_줄을_스스로_품어_겹치지_않는다() -> None:
|
||||
table = build_table([WALL], NAMES, {}, {}, None)
|
||||
counts = Counter(c["name"] for c in table["structures"][0]["components"])
|
||||
assert all(count == 1 for count in counts.values()), counts
|
||||
|
||||
|
||||
def test_구조물도_장에도_남긴_줄이_보인다() -> None:
|
||||
templates = {"masonry_wet": _fixed()}
|
||||
table = build_table([WALL], NAMES, {}, {}, None, structure_templates=templates)
|
||||
payload = build_standard_sheets(table, {})
|
||||
apply_templates(payload, {}, templates)
|
||||
names = [row["name"] for row in payload["sheets"][0]["rows"]]
|
||||
assert names[0] == "깬잡석찰쌓기" and "터파기" in names and "버림콘크리트" in names
|
||||
numbers = [row["no"] for row in payload["sheets"][0]["rows"]]
|
||||
assert len(numbers) == len(set(numbers)) # 남긴 줄 차례가 양식 줄과 안 겹침
|
||||
@@ -158,11 +158,14 @@ def test_uljin_current_rates_safety_18_586_091():
|
||||
"""현행 요율로 돌리면 안전관리비가 18,586,091 이 된다 (PLAN 8-10).
|
||||
|
||||
A 대상액 604,193,353 × 2.53 % + 3,300,000 = 18,586,091
|
||||
B 대상액 541,034,971 × 3.15 % × 1.2 = 20,385,821 → A 채택
|
||||
B 버림(대상액 541,034,971 × 3.15 %) × 1.2 = 20,385,820 → A 채택
|
||||
|
||||
⚠ 2026-09-14 B 가 1원 내려감 — 1.2배의 대상을 **호별 산정이 끝난 금액**으로 바꿨음
|
||||
(고시 제4조① 단서 문언 · STmate 영월 실증). 여기 값은 현행 요율이라 실무 대조본이 없음.
|
||||
"""
|
||||
r = calculate_cost(_uljin())
|
||||
assert r.amount("safety_management_cost_a") == _won(18_586_091)
|
||||
assert r.amount("safety_management_cost_b") == _won(20_385_821)
|
||||
assert r.amount("safety_management_cost_b") == _won(20_385_820)
|
||||
assert r.amount("safety_management_cost") == _won(18_586_091)
|
||||
|
||||
|
||||
@@ -193,7 +196,15 @@ def test_current_rates_add_items_absent_in_2024():
|
||||
|
||||
|
||||
def test_geochang_adopts_b():
|
||||
"""거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」."""
|
||||
"""거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」.
|
||||
|
||||
⚠⚠ **두 실증본이 갈림**(2026-09-14) — 1.2배를 언제 곱하나.
|
||||
STmate(영월 2024) `버림(밑수 × 율) × 1.2` 뒤 버림 → 영월 B 20,330,638 = 원본
|
||||
거창 원본(다른 프로그램) `버림(밑수 × 율 × 1.2)` → 거창 B 12,240,137
|
||||
거창 원가계산서는 시트 이름이 「원가계산서」로 **STmate 출력이 아님**(줄 차례도 다름).
|
||||
우리 목표가 STmate 골든셋 재현이라 **STmate 쪽으로 맞춤** — 그래서 거창 B 가 1원 내려감.
|
||||
⇒ 발주처가 거창 쪽 셈을 요구하면 갈래를 둬야 함. **판정 대기(브레인)**.
|
||||
"""
|
||||
r = calculate_cost(
|
||||
CostInput(
|
||||
direct_material_krw=_won(80_165_010),
|
||||
@@ -204,8 +215,8 @@ def test_geochang_adopts_b():
|
||||
)
|
||||
)
|
||||
assert r.amount("safety_management_cost_a") == _won(12_337_367)
|
||||
assert r.amount("safety_management_cost_b") == _won(12_240_137)
|
||||
assert r.amount("safety_management_cost") == _won(12_240_137)
|
||||
assert r.amount("safety_management_cost_b") == _won(12_240_136) # 거창 원본은 12,240,137
|
||||
assert r.amount("safety_management_cost") == _won(12_240_136)
|
||||
|
||||
|
||||
def test_safety_formula_text_is_per_row():
|
||||
|
||||
@@ -15,9 +15,9 @@ sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_CostSheet_Forms import ( # noqa: E402
|
||||
PENDING_PROFIT,
|
||||
PENDING_SAFETY,
|
||||
RATE_B,
|
||||
RATE_RA,
|
||||
SEPARATE,
|
||||
form_rows,
|
||||
)
|
||||
|
||||
@@ -37,11 +37,16 @@ def test_수공은_세부_경비_여덟이_율표_미확보() -> None:
|
||||
assert "기타경비" not in [row["label"] for row in rows]
|
||||
|
||||
|
||||
def test_실적형은_이윤이_확인_대기_실적수공은_안전관리비도() -> None:
|
||||
def test_실적형은_이윤만_확인_대기_안전관리비는_둘_다_별도내역() -> None:
|
||||
"""SA 안전관리비 「확인 대기」를 **별도내역**으로 내림 —
|
||||
코덱스가 STmate 원문에서 확인(2026-09-14).
|
||||
|
||||
TA 는 처음부터 별도내역이었음. 요율이 아니라 따로 산출한 금액이라 **금액엔 안 닿음**.
|
||||
"""
|
||||
general = {row["label"]: row["note"] for row in form_rows("actual_general")}
|
||||
sugong = {row["label"]: row["note"] for row in form_rows("actual_sugong")}
|
||||
assert general["이 윤"] == PENDING_PROFIT and sugong["이 윤"] == PENDING_PROFIT
|
||||
assert sugong["안전관리비"] == PENDING_SAFETY
|
||||
assert general["안전관리비"] == SEPARATE and sugong["안전관리비"] == SEPARATE
|
||||
assert general["간접노무비"] == RATE_RA and general["기타경비"] == RATE_RA
|
||||
|
||||
|
||||
@@ -55,3 +60,13 @@ def test_실적일반_보증_밑수는_직접공사비_환경은_재료직노산
|
||||
def test_모르는_형식은_멈춘다() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
form_rows("general")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("form", ["sugong", "actual_general", "actual_sugong"])
|
||||
def test_사유는_요율표_미확보가_아니라_실제_결론(form) -> None:
|
||||
"""2026-09-14 요율표 셋 결론(PLAN 7장) — 「자료를 못 구함」 이 아니라 실적형은 **임도 미적용**
|
||||
(예정가격작성기준 §37② 100억 미만) · 수공 세부 경비는 **법정 요율 없음**(§34① 업체 실측)."""
|
||||
notes = [row["note"] for row in form_rows(form)]
|
||||
assert not any("요율표 미확보" in note for note in notes), notes
|
||||
assert "임도 미적용" in RATE_RA and "§37②" in RATE_RA
|
||||
assert "법정 요율 없음" in RATE_B and "값을 넣어 주십시오" in RATE_B and "§34①" in RATE_B
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""합판거푸집 12-4 사용횟수별 비율 — 2026-09-14 브레인 ㉮ 승인.
|
||||
|
||||
원문 L6187~6211: 기준수량(1회사용) × 「사용횟수별 기준수량 비율(%)」 — 재료별
|
||||
100·57.0·46.1·40.1·37.1·34.7 · 노무비 100·60.0·47.1·40.0·34.2·32.0 (1~6회).
|
||||
종전엔 비율 셈이 없어 풀면 1회 값으로 비싸게 서므로 일부러 막아 뒀음(BLOCKED_BY_DESIGN).
|
||||
⇒ 사람 판정표로 1~6회 갈래를 세우고 재료 줄엔 재료 비율 · 인력 줄엔 노무비 비율.
|
||||
「사용고재 평가기준 23%」 는 원문이 셈을 안 줘([주]① 2회 이상 비율에 기포함) 값으로 안 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
|
||||
|
||||
|
||||
def _labor(code: str) -> dict[str, float]:
|
||||
rows = detail_of(cached_build(), code)["rows"]
|
||||
return {r["ref_code"]: float(r["quantity"]) for r in rows if r.get("kind") == "labor"}
|
||||
|
||||
|
||||
def test_사용횟수_여섯_갈래가_서고_인력은_노무비_비율() -> None:
|
||||
titles = cached_build().book.titles
|
||||
variants = sorted(c for c in titles if c.startswith("B-FP-12-04#"))
|
||||
assert variants == [f"B-FP-12-04#{n}회" for n in range(1, 7)], variants
|
||||
assert _labor("B-FP-12-04#1회") == {"1007": 0.22, "1002": 0.12}
|
||||
assert _labor("B-FP-12-04#4회") == {"1007": 0.088, "1002": 0.048} # 40.0 %
|
||||
assert _labor("B-FP-12-04#6회") == {"1007": 0.0704, "1002": 0.0384} # 32.0 %
|
||||
|
||||
|
||||
def test_재료는_재료_비율로_읽고_못_붙은_재료는_사유() -> None:
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import (
|
||||
build_resource_axis,
|
||||
load_combined_catalog,
|
||||
load_work_item_master,
|
||||
)
|
||||
|
||||
axis = build_resource_axis(load_work_item_master(), load_combined_catalog())
|
||||
left = {u.cell for u in axis.unmatched if u.work_item_code == "FP-12-04"}
|
||||
# 합판·못 은 카탈로그 없음 · 각재·철선·박리제는 규격 미정 — 값을 짓지 않고 못 붙은 줄로
|
||||
assert {"합판", "못", "각재", "철선", "박리제"} <= {c.replace(" ", "") for c in left}, left
|
||||
build = cached_build()
|
||||
assert "FP-12-04" not in build.component_gaps and "FP-12-04" not in build.partial_ratio
|
||||
note = known_gap_note("FP-12-04")
|
||||
assert "사용고재" in note and "23" in note, note
|
||||
|
||||
|
||||
def test_거푸집_갈래가_내역에서_금액이_섬() -> None:
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||
|
||||
item = {"work_item_code": "FP-12-04", "name": "합판거푸집", "spec": "", "unit": "㎡",
|
||||
"quantity": "10", "in_bill": True, "variant_axis": "use_count",
|
||||
"variant_value": "4회"} # fmt: skip
|
||||
line = next(r for r in build_bill({"work_items": [item], "materials": []}).rows
|
||||
if r.code == "FP-12-04") # fmt: skip
|
||||
assert line.price_code == "B-FP-12-04#4회" and line.amount_krw, line.note
|
||||
@@ -264,14 +264,13 @@ def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -
|
||||
· 요율 데이터가 다름 → 요율을 원본에서 뽑아 쓰므로 **안 생김**
|
||||
· 원본이 식을 안 보임 → 비고 빈 줄(건강·노인장기·연금·산업안전). 앞 차수 값을 그대로
|
||||
물려 적은 자리라 되풀 수 없음 — 울진 신설·영덕·소광 각 4줄, 세어만 두고 대조 밖.
|
||||
· 절사 자리가 다름 → 안전관리비 **B 줄** 1원(영월). 아래 `rounding` 에 따로 담음.
|
||||
· 절사 자리가 다름 → 아래 `rounding` 에 따로 담음. **지금은 비어 있어야 함**.
|
||||
|
||||
⭐ **고칠 자리 하나 — 안전관리비 B 의 절사 자리**(2026-09-14 실측, 브레인 보고 대상).
|
||||
우리 엔진 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639
|
||||
STmate `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638
|
||||
여섯 원본 중 영월에서만 갈림(봉화·울진은 두 셈법이 같은 값).
|
||||
**미채택 줄이라 총액엔 안 닿음** —
|
||||
영월도 A 를 채택함. 이 벌은 재기만 하므로 엔진은 그대로 두고 차를 세어만 둠.
|
||||
⭐ **안전관리비 B 의 절사 자리 — 2026-09-14 실증본 쪽으로 맞춤**(브레인 배정).
|
||||
고침 전 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639
|
||||
고침 뒤 `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 = 원본
|
||||
고시 제4조① 단서 문언도 이쪽임 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임.
|
||||
여섯 원본 중 영월에서만 갈렸음(봉화·울진은 두 셈법이 같은 값).
|
||||
"""
|
||||
sheets = _cost_sheets()
|
||||
if not sheets:
|
||||
@@ -308,8 +307,8 @@ def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -
|
||||
assert checked >= 54, checked
|
||||
assert blank == 12, blank # 원본이 식을 안 보인 줄 — 3건 × 4줄
|
||||
assert not misses, misses
|
||||
# 절사 자리 차 — 지금 아는 것은 영월 안전관리비 B 한 줄뿐. 늘면 여기서 드러남.
|
||||
assert len(rounding) == 1 and "영월" in rounding[0][0], rounding
|
||||
# 절사 자리 차 — 실증본 쪽으로 맞춘 뒤로는 **하나도 없어야** 함(영월 B 1원이 마지막이었음).
|
||||
assert not rounding, rounding
|
||||
|
||||
|
||||
def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None:
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""산업안전보건관리비 — **고시 원문과 직접 대조**(2026-09-14 편입).
|
||||
|
||||
원문 `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 및 사용기준/`
|
||||
`현행_20250212.md`(제3조·제4조) · `별표/별표1_…계상기준표.md`
|
||||
|
||||
⚠ 값을 여기서 짓지 않음 — **별표1 표를 원문에서 읽어** 우리 요율 데이터와 견줌.
|
||||
요율표가 개정되면 원문과 데이터가 함께 바뀌어야 이 벌이 초록으로 남음.
|
||||
|
||||
아직 안 한 것(2026-09-14 실측 · 배정 대기) — 여기서 **안 재는 자리**임을 밝혀 둠
|
||||
· 제3조 「총공사금액 2천만원 이상」 적용 하한을 엔진이 안 읽음
|
||||
(요율 데이터엔 `minimum_total_construction_amount_krw` 가 있음 · 임도에선 안 걸림)
|
||||
· 제4조①3호 「대상액이 명확하지 않은 경우 = 총공사금액의 10분의 7」 미구현
|
||||
· 관급 ÷1.1 은 고시에 없는 **실무 해석**(실무 원본 6건이 「관급재/1.1」로 적음)
|
||||
· 보건관리자 선임 문턱(800억·토목 1,000억)은 고시 밖(산업안전보건법 시행령 별표5, 원문 미보유)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
from decimal import ROUND_FLOOR, Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
NOTICE = (
|
||||
ROOT
|
||||
/ "resources"
|
||||
/ "knowledge"
|
||||
/ "original"
|
||||
/ "행정규칙"
|
||||
/ "건설업 산업안전보건관리비 계상 및 사용기준"
|
||||
)
|
||||
RATES = ROOT / "resources" / "data_cost_input_value" / "rates_2026.json"
|
||||
|
||||
#: 별표1 행 이름 → 요율 데이터 공종 키.
|
||||
_WORK_TYPES = {
|
||||
"건 축 공 사": "building",
|
||||
"토 목 공 사": "civil",
|
||||
"중 건 설 공 사": "heavy_construction",
|
||||
"특 수 건 설 공 사": "special_construction",
|
||||
}
|
||||
#: 별표1 칸 차례 → 요율 데이터 구간 이름. 기초액은 **가운데 구간에만** 붙음(제4조①1·2호).
|
||||
_BRACKETS = (
|
||||
"lt_500_million",
|
||||
"500_million_to_5_billion",
|
||||
"gte_5_billion_below_manager_threshold",
|
||||
"gte_5_billion_at_or_above_manager_threshold",
|
||||
)
|
||||
|
||||
|
||||
def _table() -> dict[str, tuple[list[Decimal], Decimal]]:
|
||||
"""별표1 표 → `{공종: ([네 구간 율], 기초액)}`. 원문이 없으면 빈 표."""
|
||||
path = NOTICE / "별표" / "별표1_공사종류 및 규모별 산업안전보건관리비 계상기준표.md"
|
||||
if not path.exists():
|
||||
return {}
|
||||
rows: dict[str, tuple[list[Decimal], Decimal]] = {}
|
||||
for line in io.open(path, encoding="utf-8").read().splitlines():
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if cells and cells[0] in _WORK_TYPES:
|
||||
percents = [Decimal(cells[i].replace("%", "")) for i in (1, 2, 4, 5)]
|
||||
rows[_WORK_TYPES[cells[0]]] = (
|
||||
percents,
|
||||
Decimal(cells[3].replace("원", "").replace(",", "")),
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _floor(value: Decimal) -> Decimal:
|
||||
return value.quantize(Decimal(1), rounding=ROUND_FLOOR)
|
||||
|
||||
|
||||
def _run(material: Decimal, labor: Decimal, **over) -> object:
|
||||
"""안전관리비 한 줄만 세워 돌림 — 규모 구간은 못 박아 이 벌 밖으로 뺌."""
|
||||
fields = dict(
|
||||
direct_material_krw=material,
|
||||
direct_labor_krw=labor,
|
||||
direct_expense_krw=Decimal(0),
|
||||
owner_supplied_includes_vat=False,
|
||||
enabled_items=("safety_management_cost",),
|
||||
estimated_price_krw=Decimal(1_000_000_000),
|
||||
cut_basis="none",
|
||||
)
|
||||
fields.update(over)
|
||||
return calculate_cost(CostInput(**fields))
|
||||
|
||||
|
||||
def test_별표1_요율표가_고시_원문과_전수_같다() -> None:
|
||||
"""별표1 **16칸**(4 공종 × 4 열) — 율과 기초액이 원문과 한 자도 안 갈림."""
|
||||
table = _table()
|
||||
if not table:
|
||||
pytest.skip("고시 원문이 없음")
|
||||
ours = {
|
||||
(row["work_type"], row["target_amount_bracket"]): (
|
||||
Decimal(str(row["rate_percent"])),
|
||||
Decimal(str(row.get("base_amount_krw", 0))),
|
||||
)
|
||||
for row in json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"][
|
||||
"brackets"
|
||||
]
|
||||
}
|
||||
misses = []
|
||||
for work_type, (percents, flat) in table.items():
|
||||
for index, bracket in enumerate(_BRACKETS):
|
||||
# 제4조①1호 — 5억 미만·50억 이상은 **기초액 없이** 율만 곱함.
|
||||
want = (percents[index], flat if bracket == "500_million_to_5_billion" else Decimal(0))
|
||||
if ours.get((work_type, bracket)) != want:
|
||||
misses.append((work_type, bracket, want, ours.get((work_type, bracket))))
|
||||
assert len(table) == 4 and not misses, misses
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("label", "target", "percent", "flat"),
|
||||
[
|
||||
("5억 바로 아래", Decimal(499_999_999), Decimal("3.15"), Decimal(0)),
|
||||
("정확히 5억", Decimal(500_000_000), Decimal("2.53"), Decimal(3_300_000)),
|
||||
("50억 바로 아래", Decimal(4_999_999_999), Decimal("2.53"), Decimal(3_300_000)),
|
||||
("정확히 50억", Decimal(5_000_000_000), Decimal("2.60"), Decimal(0)),
|
||||
],
|
||||
)
|
||||
def test_제4조_구간_경계가_고시대로다(label, target, percent, flat) -> None:
|
||||
"""제4조①1·2호 — 「5억 미만 **또는** 50억 이상」은 기초액이 없고, 그 사이만 기초액을 더함."""
|
||||
result = _run(target / 2, target / 2)
|
||||
line = result.line("safety_management_cost_a")
|
||||
assert (line.rate_percent, line.flat_amount_krw) == (percent, flat), label
|
||||
assert result.amount("safety_management_cost") == _floor(target * percent / 100 + flat), label
|
||||
|
||||
|
||||
def test_제4조_단서_A와_B의_1점2배_중_작은_쪽() -> None:
|
||||
"""단서 — 관급 포함(A)과 관급 제외 × 1.2(B) 를 견줘 **작은 쪽**.
|
||||
|
||||
A·B 가 **서로 다른 구간**에 떨어지는 자리로 잼 — A 대상액 5억(2호) · B 대상액 4억(1호).
|
||||
"""
|
||||
result = _run(
|
||||
Decimal(200_000_000), Decimal(200_000_000), owner_supplied_material_krw=Decimal(100_000_000)
|
||||
)
|
||||
want_a = _floor(Decimal(500_000_000) * Decimal("2.53") / 100 + 3_300_000)
|
||||
want_b = _floor(_floor(Decimal(400_000_000) * Decimal("3.15") / 100) * Decimal("1.2"))
|
||||
got = (result.amount("safety_management_cost_a"), result.amount("safety_management_cost_b"))
|
||||
assert got == (want_a, want_b)
|
||||
assert result.amount("safety_management_cost") == min(got)
|
||||
|
||||
|
||||
def test_1점2배는_호별_산정액을_먼저_원_단위로_맺은_뒤_곱한다() -> None:
|
||||
"""⭐ 단서 문언 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임(2026-09-14 고침).
|
||||
|
||||
골든셋 실증 — 영월 2024 B 줄이 종전 20,330,639(먼저 곱하고 한 번 버림) ·
|
||||
원본·고침 뒤 20,330,638. 끝자리가 살아 있는 밑수로 두 셈법이 갈리는 것을 여기서 잼.
|
||||
"""
|
||||
base = Decimal(123_456_789) # × 3.15 % = 3,888,888.85… — 버림 자리가 살아 있음
|
||||
result = _run(base / 2, base / 2)
|
||||
raw = base * Decimal("3.15") / 100
|
||||
assert _floor(raw) * Decimal("1.2") != raw * Decimal("1.2") # 두 셈법이 실제로 갈리는 밑수
|
||||
assert result.amount("safety_management_cost_b") == _floor(_floor(raw) * Decimal("1.2"))
|
||||
|
||||
|
||||
def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다() -> None:
|
||||
"""제2조2호 — 대상액 = 직접재료비 + 간접재료비 + 직접노무비(발주자 제공 재료 포함)."""
|
||||
plain = _run(Decimal(100_000_000), Decimal(100_000_000))
|
||||
indirect = _run(
|
||||
Decimal(100_000_000), Decimal(100_000_000), indirect_material_krw=Decimal(50_000_000)
|
||||
)
|
||||
owner = _run(
|
||||
Decimal(100_000_000), Decimal(100_000_000), owner_supplied_material_krw=Decimal(30_000_000)
|
||||
)
|
||||
base = plain.line("safety_management_cost_a").base_amount_krw
|
||||
assert base == Decimal(200_000_000)
|
||||
assert indirect.line("safety_management_cost_a").base_amount_krw == base + 50_000_000
|
||||
assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000
|
||||
# 관급 제외 밑수(B)는 관급이 들어도 안 움직임.
|
||||
assert owner.line("safety_management_cost_b").base_amount_krw == base
|
||||
@@ -100,8 +100,8 @@ def test_단목베기_5m_미만이_실무값_근처로_선다():
|
||||
|
||||
build = build_unit_prices()
|
||||
assert "FP-04-02-02" not in build.partial_ratio
|
||||
# 합판거푸집 — 일부러 안 푼 표는 **그 까닭**이 사유로 뜬다(사용횟수 비율 미구현)
|
||||
assert "사용횟수" in (build.component_gaps.get("FP-12-04") or "")
|
||||
# 합판거푸집 — 2026-09-14 사용횟수 갈래로 풀림(`test_b09_formwork_use_count`) · 막힘 사유 없음
|
||||
assert "FP-12-04" not in build.component_gaps
|
||||
total = build.book.resolve("B-FP-04-02-02#5m미만").total
|
||||
# 영월 설계내역 1.9.2 「잡관목제거 벌목(5m미만)」 @882 — 노임 연도 차이로 ±5 % 안이면 같은 읽기
|
||||
assert Decimal("838") <= total <= Decimal("926"), total
|
||||
|
||||
@@ -753,6 +753,8 @@ export const ui_locales_b2 = {
|
||||
B08_Quantity_FaceDressing_FillArea: ["성토면 면적(㎡)", "Fill face area (㎡)"],
|
||||
B08_Quantity_FaceDressing_CutArea: ["절토면 면적(㎡)", "Cut face area (㎡)"],
|
||||
B08_Quantity_FaceDressing_Area_Placeholder: ["파종 면적 그대로", "Same as seeding area"],
|
||||
B08_Quantity_FaceDressing_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"],
|
||||
B08_Quantity_FaceDressing_FillSuggested: ["제안값 넣기", "Fill suggested"],
|
||||
B08_Quantity_Side_FaceDressing_Hint: [
|
||||
"품셈 9-19-1 원문 표 두 벌(절토면 토질 6 · 성토면 시공·토질) — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다. 면적은 비우면 초류종자살포 면적(× 반영률) 그대로 · 넣으면 그 값으로 덮어씀",
|
||||
"Standard estimate 9-19-1 tables (cut face soil · fill face method/soil) — no suggested value; unset leaves the bill row with an input reason. Area left blank uses the seeding area (× ratio); a value overrides it",
|
||||
|
||||
Reference in New Issue
Block a user