Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5

This commit is contained in:
2026-11-01 16:12:55 +09:00
15 changed files with 637 additions and 60 deletions
+42 -6
View File
@@ -6,6 +6,9 @@
도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값)
precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} ·
`단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌)
sangeun 단가산출(SK · 내 자료집 · 719-2) = precombo 와 한 꼴(참조사본 = 풀어 굳힌 SK)
[업데이트](719-2) = 원본판이 다른 사본만 같은 폴더에 다시 굳힘(`changed` · `refresh_copy` ·
값 그대로 · 원본판 없는 옛 사본 = 그대로).
사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) ·
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
@@ -58,11 +61,11 @@ from M02_MasterTemplete.M02_Item_Source import (
)
LIBRARY = "library"
KINDS = ("combo", "precombo")
KINDS = ("combo", "precombo", "sangeun")
#: 폴더 이름 — 종류 · 층(project = 불러온 공사 일위대가 · 707-7) · 주인 ·
#: 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음)
NAME = re.compile(
r"^(combo|precombo)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
r"^(combo|precombo|sangeun)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
)
@@ -87,11 +90,11 @@ def folder_name(
return name
def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
def _precombo_files(layer: str, owner: str | None, key: str, kind: str = "precombo") -> dict:
ref = {"층": layer, "주인": owner, "키": key}
doc = refs.read_ref(ref)
if doc is None or doc.get("종류") != refs.TITLE["precombo"]:
raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음")
if doc is None or doc.get("종류") != refs.TITLE[kind]:
raise LookupError(f"{refs.TITLE[kind]} {key} 이 {layer} 층에 없음")
line = {"참조": ref, "수량": {"손": 1}}
labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]})
return {
@@ -116,10 +119,43 @@ def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, di
item = {"layer": layer, "owner": owner, "key": key}
item["name"] = item_name(item)
return build_snapshot(item), origin_of(item)
files = _precombo_files(layer, owner, key)
files = _precombo_files(layer, owner, key, kind)
return files, files.pop("_원본")
def _source_of(manifest: dict[str, Any]) -> tuple[str, str, str | None, str]:
"""manifest → (종류, 층, 주인, 키)."""
kind, origin = str(manifest.get("종류")), manifest.get("원본") or {}
if kind == "combo":
return kind, origin.get("layer"), origin.get("owner"), origin.get("key")
ref = origin.get("참조") or {}
return kind, ref.get("층"), ref.get("주인"), ref.get("키")
def changed(project_root: str | Path) -> list[tuple[Path, dict[str, Any], dict, dict]]:
"""원본판이 지금 원본과 다른 사본 `(폴더, manifest, 새 파일들, 새 원본 표지)` — 원본판 없는 옛
사본 · 원본 없음 = 뺌(그대로)."""
out = []
for folder, manifest in copies(project_root):
if not manifest.get("원본판"):
continue
try:
files, origin = _build(*_source_of(manifest))
except (LookupError, ValueError, KeyError):
continue
if source_version(files) != manifest["원본판"]:
out.append((folder, manifest, files, origin))
return out
def refresh_copy(project_root: str | Path, one: tuple) -> None:
"""`changed` 한 줄 → 같은 폴더에 다시 굳힘(값 · 가져온이 그대로 · 원본판 새로)."""
folder, manifest, files, origin = one
keep = {k: v for k, v in manifest.items() if k not in ("값", "원값")}
fresh = {**keep, "원본": origin, "원본판": source_version(files), "가져온날": now_text()}
_write(project_root, folder.name, files, fresh, manifest.get("값") or {})
# ── 운반 값 사본(263-2) ────────────────────────────────────────────────
def _logic(code: str) -> dict[str, Any]:
try:
@@ -9,6 +9,11 @@
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
품셈 블록(`P:` · 굳힌 SK 포함 · 719-2) = 개인 층 단가산출(SK · `sangeun`) — 바탕 = 그 M01 로직 ·
원본 SK 문서(바탕 · 덮개 · 더한줄 · 뺀줄) · 넣을 값 · 고른 자재(cid) · B09 품 · 단가 덮개 =
SK 덮개(같은 cid · 굳힌 SK = `정체` 로 원본 cid · 더한줄 줄 = 그 더한줄 수량 · 단가) ·
못 옮긴 칸 = 안담음 수 ·
내 자료집 SK 불러오기 = B09 서고 사본 `sangeun`(`Bill_Library`).
불러온 공사 일위대가(707-7 · 참조 층 `project`) = 하위 호표 굳힌 문서를 문서 `_하위` 에 실어 보내고
쓸 때 지금 층에 같이 씀(새 키 · `본뜬.복사원본` = 그 참조 · 같은 하위는 한 번만 ·
이미 있으면 그 키).
@@ -16,11 +21,13 @@
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies
from B09_Estimation import B09_Estimation_Sangeun as sg
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, COVER_KEYS, with_copies
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Item_Prices as item_prices
@@ -35,6 +42,8 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
KIND = "precombo"
SK_KIND = "sangeun"
SK_KEEP = ("바탕", "입력값", "열린입력", "켜쌓기율", "고름", "자재표시", "덮개", "더한줄", "뺀줄")
SUBS = "_하위"
PRICE_KEYS = ("labor", "material", "expense")
@@ -113,8 +122,10 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None =
base = cover_key
while base.startswith("K:"): # 복사본 → 원본 cover
base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "")
lines, mats = source(root, blocks.get(base, block), base)
mine = cover.get(cover_key) or {}
if block["kind"] == "품셈":
return _sangeun(root, blocks.get(base, block), mine, name)
lines, mats = source(root, blocks.get(base, block), base)
kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)}
kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines)
skip = {"품": 0, "단가": 0}
@@ -155,6 +166,70 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None =
return {"문서": doc, "담음": kept, "안담음": skip}
def _sangeun(root: Path, block: dict, mine: dict, name: str | None) -> dict[str, Any]:
"""품셈 블록 → 개인 층 SK 문서 `{문서, 담음, 안담음}` — 블록 줄 차례 = 엔진 줄 차례 =
cid 차례."""
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Sangeun_Doc as sd
origin = block.get("origin") or {}
rid = origin.get("단가산출")
if rid: # 굳힌 SK = 원본 문서 위에 · cid = 정체(굳힌 줄 차례 → 원본 cid)
frozen = _frozen(root, rid)
if frozen is None or not isinstance(frozen.get("원본"), dict):
raise LookupError(f"단가산출 원천을 못 찾음 「{rid}」")
doc = {k: copy.deepcopy(frozen["원본"][k]) for k in SK_KEEP if k in frozen["원본"]}
ids = list(frozen.get("정체") or [])
unit = str(frozen["원본"].get("단위") or "")
else: # M01 로직 한 줄 = 바탕 · 고름 차례 → cid
try:
row = m01.loaded()[1].logic(str(origin.get("로직")))
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
raise LookupError(f"로직을 못 찾음 「{origin.get('로직')}」") from e
ids, unit = sd.line_ids(row), sg._unit(row)[0]
chosen = (origin.get("고름") or {}).items()
picks = {ids[int(n)]: mt for n, mt in chosen if int(n) < len(ids)}
doc = {"바탕": {"로직": origin.get("로직")}, "입력값": dict(origin.get("입력값") or {})}
doc |= {"고름": picks} if picks else {}
if origin.get("켜쌓기율") is not None:
doc["켜쌓기율"] = origin["켜쌓기율"]
covers, added = doc.setdefault("덮개", {}), doc.setdefault("더한줄", [])
kept, skip = {"품": 0, "단가": 0}, {"품": 0, "단가": 0}
for n, line in enumerate(block["lines"]):
cell = {k: v for k, v in (mine.get(line.get("cid")) or {}).items() if k in COVER_KEYS}
cid = ids[n] if n < len(ids) else ""
if not cell:
continue
if cid.startswith("add:#"): # 더한줄 = 그 줄 수량 · 단가(한 비목 줄만)
one = added[int(cid[5:])]
if "qty" in cell:
one["수량"], kept["품"] = cell["qty"], kept["품"] + 1
prices = [cell[k] for k in PRICE_KEYS if k in cell]
ok = len(prices) == 1 and "참조" not in one and "고정" not in one
if ok:
one["단가"], kept["단가"] = prices[0], kept["단가"] + 1
skip["단가"] += len(prices) - (1 if ok else 0)
continue
if not cid or ("qty" in cell and doc.get("열린입력")): # 열어 둔 SK = 품 배율만
skip["품"] += "qty" in cell
cell.pop("qty", None)
if not cell:
continue
if not cid:
skip["단가"] += len(cell)
continue
was = {k: v for k, v in (covers.get(cid) or {}).items() if k != "원값"}
if "qty" in cell:
was.pop("배율", None)
covers[cid] = {**was, **cell}
kept["품"] += "qty" in cell
kept["단가"] += sum(k in cell for k in PRICE_KEYS)
if not covers:
doc.pop("덮개")
head = {"종류": sd.TITLE, "이름": (name or block.get("name") or "").strip(), "단위": unit}
return {"문서": {**head, **doc}, "담음": kept, "안담음": skip}
def _own(lines: Any) -> list[dict[str, Any]]:
"""불러온 공사 하위 호표 참조(층 `project` — 층에 없음 · 굳힌 문서뿐)."""
return [
@@ -211,9 +286,10 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
subs = doc.get(SUBS) or {}
doc = {k: v for k, v in doc.items() if k != SUBS}
kind = SK_KIND if doc.get("종류") == refs.TITLE[SK_KIND] else KIND
same = [
path.stem
for path in sorted((store.root() / KIND).glob("SC-*.json"))
for path in sorted((store.root() / kind).glob(f"{refs.PREFIX[kind]}-*.json"))
if (read_json(path) or {}).get("이름") == doc["이름"]
]
if overwrite and overwrite not in same:
@@ -223,6 +299,6 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
if subs:
doc = {**doc, "담은로직": _carry(doc["담은로직"], subs, {})}
if overwrite:
old = store.read(KIND, overwrite)
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
return refs.create(KIND, doc)
old = store.read(kind, overwrite)
return store.write(kind, overwrite, old["판"], {**doc, "키": overwrite})
return refs.create(kind, doc)
+93 -15
View File
@@ -115,6 +115,14 @@ class Builder:
# ── 품셈(로직 하나) ─────────────────────────────────────────────────
def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None:
got = self._poomsem(whole, one, title)
if got is None:
return None
got.pop("_engine"), got.pop("_items")
return self.put(got)
def _poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> dict | None:
"""품셈 블록 꼴(넣기 전) — 엔진 줄 · 줄마다 `_n`(엔진 차례 · 단가산출 블록이 씀)."""
code = str(one.get("로직") or "")
given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름")
try:
@@ -142,18 +150,73 @@ class Builder:
f"{k} {st._word(whole, k, v) if isinstance(v, str) else v}"
for k, v in sorted(given.items())
)
return self.put(
{
"kind": "품셈",
"name": title or logic.get("이름") or code,
"spec": shown,
"spec_raw": spec,
"unit": unit,
"cite": logic.get("출처") or "",
"origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks},
"lines": lines,
}
)
return {
"kind": "품셈",
"name": title or logic.get("이름") or code,
"spec": shown,
"spec_raw": spec,
"unit": unit,
"cite": logic.get("출처") or "",
"origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks},
"lines": lines,
"_engine": engine,
"_items": items,
}
# ── 단가산출(SK · 719-2) ────────────────────────────────────────────
def sangeun(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str | None:
"""굳힌 SK(사본 `단가.json` 로직 줄 · 719-1) = 품셈 블록 꼴 대가 — 셈 = 굳힌 줄 그대로 ·
단가산출 속 일위대가 · SK 줄(참조) = 그 대가 블록을 부르는 줄(펼침 · B09 차례 그대로) ·
고정 줄 = `fixed` · 단가 고친 줄(덮개 · 더한줄 단가 · 값묶음) = `fixed`(줄 단가 = M02 셈) ·
cover = `P:` + SK 이름표(SK 를 고쳐 [업데이트] 해도 B09 덮개 그대로) ·
줄 `sk` · `poom`(M02 에서 고친 줄의 품셈 값 = 원본 덮개 `원값`)."""
from M02_MasterTemplete import M02_Item_Sangeun as item_sk
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete.M02_Labor_Expand import Expand
rid = refs.ref_id(ref)
key = str((doc.get("바탕") or {}).get("로직") or "")
if item_sk.ROW not in doc or not key:
return None # 못 푼 SK(없는 바탕 · 순환) — 부르는 줄이 까닭
def read(r: dict) -> dict | None:
return copies.get(refs.ref_id(r))
try:
got = calc.resolve(doc, whole, read, (rid,))
row = {**got["줄"], "키": key, "호표": [dict(x) for x in got["줄"]["호표"]]}
if calc._bundles(row, whole, Expand(whole, read), (rid,)):
return None
except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return None
one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]}
block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]})
if block is None:
return None
ids = list(doc.get("정체") or [])
was = {c: v.get("원값") for c, v in ((doc.get("원본") or {}).get("덮개") or {}).items()}
lines = []
for n, (line, x) in enumerate(zip(block["lines"], block.pop("_engine"))):
item = block["_items"][n] if n < len(block["_items"]) else {}
qty = _f(x.get("수량"))
head = {"name": line.get("name") or "", "spec": "", "unit": line.get("unit") or ""}
if isinstance(item.get("참조"), dict) and depth < refs.DEPTH:
line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head}
elif isinstance(item.get("고정"), dict):
line = {**fixed_line(item["고정"], qty), **head}
elif qty and ("단가" in item or item.get("값묶음")):
own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART}
line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}}
poom = was.get(ids[n]) if n < len(ids) else None
lines.append({**line, "sk": True, **({"poom": poom} if poom else {})})
block.pop("_items")
name = doc.get("이름") or block["name"]
origin = {**block["origin"], "단가산출": rid}
cover = f"P:{_digest({'단가산출': rid})}"
return self.put({**block, "name": name,
"cite": f"단가산출 {ref.get('키')}", "origin": origin,
"lines": lines}, cover) # fmt: skip
def _engine_line(
self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None
@@ -209,6 +272,17 @@ class Builder:
"qty": each,
"why": f"없는 참조 「{ref.get('키')}」",
}
if refs.kind_of(ref.get("키")) == "sangeun": # 단가산출(719-2) = 굳힌 줄 대가
bid = self.sangeun(whole, ref, doc, copies, depth + 1)
if bid is None:
why = "단가산출을 못 셈(바탕 없음 · 순환)"
return {"kind": "blank", "name": name, "qty": each, "why": why}
from M02_MasterTemplete.M02_Sangeun_Calc import times_of
block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위
per = float(times_of(whole.logic(doc["바탕"]["로직"])))
return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"],
"qty": None if each is None else each / per} # fmt: skip
if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가
first = (doc.get("담은로직") or [{}])[0]
bid = self.poomsem(whole, first, doc.get("이름") or name)
@@ -225,7 +299,7 @@ class Builder:
return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"}
key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
return {"kind": "sangeun", "key": key, "name": name or code,
"unit": sg.unit_of(code), "qty": q} # fmt: skip
"unit": sg.unit_of(whole, code), "qty": q} # fmt: skip
bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name)
if bid is None:
return {
@@ -243,9 +317,11 @@ class Builder:
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈
pills = {
code: pill
for pill in combo_store.combo_pills({"담은로직": inner})["물량"]
for pill in (kept if isinstance(kept, list) else None)
or combo_store.combo_pills({"담은로직": inner})["물량"]
for code in pill["로직"]
}
lines = []
@@ -318,7 +394,7 @@ class Builder:
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
if manifest.get("종류") != "precombo":
if manifest.get("종류") not in ("precombo", "sangeun"):
return self.combo(folder, f"L:{folder.name}")
labor = read_json(folder / LABOR_FILE)
ref = (manifest.get("원본") or {}).get("참조")
@@ -327,6 +403,8 @@ class Builder:
if not isinstance(doc, dict):
return None
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2)
return self.sangeun(whole, ref, doc, copies, 1)
return self.precombo(whole, ref, doc, copies, 1)
+22 -1
View File
@@ -5,6 +5,7 @@
실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) ·
사본 일위대가 자재 · 굳힌 사전조합 자재 코드 · 고정 줄 이음 키도(183-1 · 705-1 · 로직을 안 돌아 `get` 에 안 걸림).
굳힌 SK(719-2) = 굳힌 키 로직 줄로 돌림(사본 `단가.json` · M01 안 읽음).
고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`).
구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음.
"""
@@ -54,7 +55,9 @@ def used_keys(folder: Path) -> list[str]:
# 굳힌 참조사본 안 로직까지
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
inner = [d for d in (labor.get("참조사본") or {}).values() if isinstance(d, dict)]
for one in [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]:
sks = [_sk_line(d) for d in inner if d.get("종류") == "단가산출" and "굳힌줄" in d]
lines = [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]
for one in [*lines, *sks]:
# 못 돈 로직 — 부른 데까지만(까닭은 안 봄)
if isinstance(one, dict) and one.get("로직"):
labor_doc.run_line(
@@ -63,12 +66,30 @@ def used_keys(folder: Path) -> list[str]:
mats = [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]
codes = [str(mat.get("코드") or "") if isinstance(mat, dict) else "" for mat in mats]
codes += [k for d in [doc, *inner] for k in fixed.link_keys(d)] # 705-1 고정 줄 이음
codes += [ # 굳힌 SK 고정 더한줄 이음(719-2)
k
for d in inner
for x in (d.get("굳힌줄") or {}).get("호표") or []
if isinstance(x.get("고정"), dict)
for k in fixed.link_keys({"담은로직": [{"고정": x["고정"]}]})
]
for code in codes:
if code[:2] in RESOURCE_TABLES and code not in seen:
seen.append(code)
return seen
def _sk_line(doc: dict[str, Any]) -> dict[str, Any]:
"""굳힌 SK(719-1) → 로직 줄 꼴 — 굳힌 키(사본 `단가.json` 로직) · 고름 cid → 차례."""
from M02_MasterTemplete.M02_Sangeun_Doc import line_ids
chosen = doc.get("고름") or {}
ids = line_ids(doc["굳힌줄"])
picks = {str(n): chosen[cid] for n, cid in enumerate(ids) if cid in chosen}
one = {"로직": (doc.get("바탕") or {}).get("로직"), "입력값": doc.get("입력값")}
return {**one, "켜쌓기율": doc.get("켜쌓기율"), "고름": picks or None}
def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]:
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌)."""
+55 -3
View File
@@ -5,6 +5,9 @@
재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날)
적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) ·
덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림).
M02 단가산출(SK · 719-2) — 구조물 · 서고 사본의 굳힌 SK 를 원본 층에서 다시 풀어 굳힘(같은 굳힌 키 ·
같은 cover → B09 덮개 · 입력값 · 고른 자재 그대로) · 서고 사본도 원본판이 다르면 다시 굳힘 ·
미리 보기 `단가산출` = 바뀌는 SK 줄 [{사본, 이름, 키, 판(새 · 바뀜 · 빠짐)}].
"""
from __future__ import annotations
@@ -12,10 +15,13 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation.B09_Estimation_Calc_Labor import COPY
from M02_MasterTemplete import M02_Item_Copy as item_copy
from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots
from M02_MasterTemplete import M02_Item_Sangeun as item_sk
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, linked_snapshots, structures_dir
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, build_source, read_json
def changed_copies(root: str | Path) -> list[dict[str, Any]]:
@@ -28,6 +34,41 @@ def changed_copies(root: str | Path) -> list[dict[str, Any]]:
]
def _sks(labor: Any) -> dict[str, dict]:
copies = (labor or {}).get("참조사본") or {}
return {rid: doc for rid, doc in copies.items() if item_sk.is_sangeun(doc)}
def sk_lines(old: Any, new: Any, where: str) -> list[dict[str, str]]:
"""사본 하나 전후 일위대가.json → 바뀌는 SK 줄(굳힌 줄 · 원본 SK 문서가 다름)."""
was, now = _sks(old), _sks(new)
out = []
for rid in sorted(set(was) | set(now)):
a, b = was.get(rid) or {}, now.get(rid) or {}
if all(a.get(k) == b.get(k) for k in (item_sk.ROW, "원본")):
continue
mark = "새" if not a else "빠짐" if not b else "바뀜"
out.append({"사본": where, "이름": (b or a).get("이름") or "", "키": rid.rsplit("|")[-1],
"판": mark}) # fmt: skip
return out
def sk_changes(root: str | Path, copies: list[dict], libs: list[tuple]) -> list[dict[str, str]]:
"""다시 가져올 구조물 사본(`changed_copies`) · 서고 사본(`library.changed`)의 바뀌는 SK 줄."""
out = []
for one in copies:
folder = structures_dir(root) / one["폴더"]
try:
fresh = build_source((read_json(folder / MANIFEST_NAME) or {})["원본"])
except (LookupError, ValueError, KeyError):
continue
out += sk_lines(read_json(folder / LABOR_FILE), fresh.get(LABOR_FILE), one["이름"])
for folder, manifest, files, origin in libs:
name = str(origin.get("name") or folder.name)
out += sk_lines(read_json(folder / LABOR_FILE), files.get(LABOR_FILE), name)
return out
def cover_cells(cover: dict[str, Any] | None) -> int:
"""고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌."""
return sum(
@@ -43,6 +84,8 @@ def preview(
root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None
) -> dict[str, Any]:
diff = prices.book_diff(book, prices.refresh_book(keys, book))
copies, libs = changed_copies(root), library.changed(root)
lib_names = [{"폴더": f.name, "이름": str(o.get("name") or f.name)} for f, _m, _f, o in libs]
before = (book or {}).get("labor_edition")
made = str((book or {}).get("made") or "")[:10]
return {
@@ -51,7 +94,8 @@ def preview(
"바뀐줄": diff["changed"],
"계수": diff["factor"],
"환율": diff["fx"],
"사본": changed_copies(root),
"사본": copies + lib_names,
"단가산출": sk_changes(root, copies, libs),
"고친칸": cover_cells(cover),
"노임판": {
"전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"),
@@ -61,8 +105,16 @@ def preview(
def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]:
"""원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`."""
"""원본이 바뀐 연결 사본 재적용 · 서고 사본 다시 굳힘 —
`{다시: [이름], 막힘: [이름 — 까닭]}`."""
done, stuck = [], []
for one in library.changed(root):
name = str(one[3].get("name") or one[0].name)
try:
library.refresh_copy(root, one)
done.append(name)
except (LookupError, ValueError, KeyError, OSError) as error:
stuck.append(f"{name} — {error}")
for one in changed_copies(root):
try:
item_copy.reapply(root, one["폴더"], who)
+27 -8
View File
@@ -129,6 +129,17 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]:
doc = copies.get(refs.ref_id(ref))
if not isinstance(doc, dict) or depth >= refs.DEPTH:
continue
if refs.kind_of(ref.get("키")) == "sangeun": # 굳힌 SK(719-2) = 대가 · 하위 = 산근
base = str((doc.get("바탕") or {}).get("로직") or "")
if base and "굳힌줄" in doc:
logic_line(base, doc.get("입력값"), doc.get("켜쌓기율"), None, True)
inner_refs = [
{"참조": x["참조"]}
for x in doc["굳힌줄"].get("호표") or []
if isinstance(x.get("참조"), dict)
]
walk(inner_refs, True, depth + 1) # 단가산출 속 일위대가 · SK
continue
if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화
first = (doc.get("담은로직") or [None])[0]
if isinstance(first, dict) and first.get("로직"):
@@ -194,15 +205,23 @@ class _Prices:
)
def _unit(code: str) -> tuple[str, Decimal]:
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10)."""
pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0]
return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1))
def _unit(row: dict) -> tuple[str, Decimal]:
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10) · 알약 규칙(`combo_pills`) 그대로 ·
줄 = 사본 단가 묶음 것(M01 다시 안 읽음 · 719-2)."""
per = combo_store._PER.findall(str(row.get("결과단위") or ""))
if not per:
return "", Decimal(1)
times = per[0][0].replace(",", "")
unit = " · ".join(dict.fromkeys(u for _, u in per))
return unit, Decimal(times) if len(per) == 1 and times else Decimal(1)
def unit_of(code: str) -> str:
"""산근 1 단위 이름(「원/10㎡」 = ㎡)."""
return _unit(code)[0]
def unit_of(whole: Any, code: str) -> str:
"""산근 1 단위 이름(「원/10㎡」 = ㎡) — `whole` = 사본 단가 묶음."""
try:
return _unit(whole.logic(code))[0]
except mf.FormulaError:
return ""
def _env(whole: Any, row: dict, given: dict) -> dict:
@@ -343,7 +362,7 @@ class Solver:
name = str(row.get("이름") or code)
if isinstance(row.get("흐름"), dict) or "결과" in row:
return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음")
unit, times = _unit(code)
unit, times = _unit(row)
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
lines: list[dict[str, Any]] = []
try:
+5 -1
View File
@@ -73,11 +73,15 @@ export function refreshTip(payload: Row | null): string {
/** [업데이트] 미리 보기 글 줄(703-3) */
export function previewLines(got: Row): string[] {
const copies: Row[] = got.사본 || [];
const sks: Row[] = got.단가산출 || [];
const edition = got.노임판 || {};
return [
`바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` +
`${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
`다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
`다시 가져올 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
...(sks.length
? [`M02 단가산출 바뀜 ${sks.length} — ${sks.map((s) => `${s.이름}(${s.판})`).join(" · ")}`]
: []),
`고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`,
`노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`,
];
+10 -5
View File
@@ -80,11 +80,12 @@ function hereItems(ctx: TabContext): Item[] {
return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)];
}
/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */
/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) + 단가산출(SK · 719-2) */
async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
const owner = ownOwner(layer, await fetchSessionUser().catch(() => null));
const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer);
const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : [];
const sks = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "sangeun") : [];
return [
...combos.map((r) => {
const name = itemName(r);
@@ -99,15 +100,19 @@ async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
};
}),
...pres.map((r) => {
...[
...pres.map((r) => ["precombo", r] as const),
...sks.map((r) => ["sangeun", r] as const),
].map(([kind, r]) => {
const name = r.제목 || r.이름;
const pick = { kind: "precombo" as const, layer, owner, key: r.이름 };
const tag = kind === "precombo" ? "일위대가" : "단가산출";
const pick = { kind, layer, owner, key: r.이름 };
return {
tag: "일위대가",
tag,
name,
sub: [r.구분, r.상세구분].filter(Boolean).join(" "),
unit: String(r.단위 || ""),
hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`),
hit: has(`${tag} ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`),
go: (ctx: TabContext, apply: ApplyRef) =>
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
};
@@ -8,6 +8,7 @@
* {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`)
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
* 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 ·
@@ -59,11 +60,20 @@ let open: string | null = null;
const plain = (v: unknown): string =>
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */
/** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */
const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값");
/** 단가산출에서 고친 줄의 품셈 값 곁 글 */
const poomText = (line: Row): string =>
line.poom ? `품셈 품 ${plain(line.poom.qty)} · 단가 ${plain(line.poom.단가)}` : "";
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 · 단가산출 줄 = 품셈 값 곁 */
function baseText(line: Row): string {
const covered: string[] = line.covered || [];
if (!covered.length) return "같음";
return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
const poom = poomText(line);
if (!covered.length) return poom ? `단가산출 고침 — ${poom}` : "같음";
const base = covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
return line.sk ? `단가산출 ${base}${poom ? ` (${poom})` : ""}` : base;
}
function linesOf(block: Row, blocks: Map<string, Row>, level: number, trail: string[]): FoldLine[] {
@@ -184,7 +194,9 @@ function foldCard(
const at = lines[Number(cell.parentElement?.dataset.i)];
const field = FIELD[key];
if (!at || !field || !covered(at, field)) return null;
return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`;
const poom = poomText(at.line);
const back = backName(at.line);
return `고친 칸 — ${back} ${plain(at.line.base?.[field])}${poom ? ` · ${poom}` : ""} · 우클릭 [${back}으로]`;
},
"tr[data-i] td",
);
@@ -192,7 +204,7 @@ function foldCard(
const at = lines[i];
const undo: MapContextMenuItem[] =
at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
? [[`${backName(at.line)}으로`, () => clearCovers(ctx, [at])], "-"]
: [];
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
});
@@ -200,7 +212,7 @@ function foldCard(
...(edited.length
? [
createButton({
label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`,
label: `고친 칸 ${edited.length}줄 모두 ${backName((block.lines || []).find((l: Row) => l.sk))}으로`,
variant: "ghost",
size: "sm",
onClick: () => clearCovers(ctx, edited),
@@ -87,11 +87,15 @@ async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; g
return { ok: response.ok, status: response.status, got };
}
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 품셈 블록 = 단가산출(SK ·
* 품 · 단가 고친 칸 = SK 덮개 · 719-2) · 같은 이름 물음 */
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
const sk = block.kind === "품셈";
let name = await askText({
title: "내 자료집에 저장",
message: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지",
message: sk
? "내 자료집 단가산출로 — 옵션 값 · 고른 자재 · 품 · 단가 고친 칸까지"
: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지",
value: block.name || "",
okLabel: "저장",
});
@@ -104,9 +108,9 @@ async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
const skip: Row = got.안담음 || {};
const left = (skip.품 || 0) + (skip.단가 || 0);
const tail = left
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가})${sk ? "" : " — 품은 고정 줄(PLAN 705) 뒤 담김"}`
: "";
showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000);
showToast(`내 자료집${sk ? " 단가산출" : ""}에 저장 「${got.이름}」${tail}`, "success", 6000);
return;
}
if (status !== 409 || !same) {
@@ -66,7 +66,7 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) ·
* lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */
export type LibPick = {
kind: "combo" | "precombo";
kind: "combo" | "precombo" | "sangeun";
layer: string;
owner: string | null;
key: string;
@@ -85,7 +85,7 @@ export interface LibLine {
/** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */
export function parseLib(lib: string): LibPick | null {
const [kind, layer, owner, key] = lib.split("~")[0].split("@");
if ((kind !== "combo" && kind !== "precombo") || !key) return null;
if ((kind !== "combo" && kind !== "precombo" && kind !== "sangeun") || !key) return null;
return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib };
}
+3 -1
View File
@@ -10,7 +10,8 @@
고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만.
사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합),
열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [],
굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}.
굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 정체(굳힌 줄 차례 → 원본
cid · 더한줄 = `add:#k`), 바탕판, 짝잃음}.
단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침.
"""
@@ -78,6 +79,7 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"뺀줄": [],
ROW: row,
"원본": doc,
"정체": list(got["정체"]), # 굳힌 줄 차례 → 원본 cid(B09 덮개 · 내 자료집 · 719-2)
"바탕판": got["바탕판"],
"짝잃음": got["짝잃음"],
}
@@ -342,7 +342,7 @@ export interface LibraryRow {
/** 서고 목록 — 그 층(주인) 사전조합 `precombo` 문서 */
export const fetchLibrary = async (
ref: Omit<LaborRef, "키">,
kind: "precombo",
kind: "precombo" | "sangeun",
): Promise<LibraryRow[]> => {
const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : "";
const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>(
+2 -1
View File
@@ -111,7 +111,8 @@ def test_글_속_금액은_산근_풀이_값과_같음():
whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code])))
sheets, price_of = _solver_prices(whole, code, given)
block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1")
times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
# 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
times = sg._unit(whole.logic(code))[1]
got = st.render_sangeun_text(whole, code, given, price_of, times=times)
parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS)
assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code
+267
View File
@@ -0,0 +1,267 @@
"""719-2 공사 쪽 M02 단가산출(SK) — B09 대가 블록 · 산근 · [업데이트] · 내 자료집 SK(임시 storage ·
실제 M01 읽기만).
- 굳힌 SK = 품셈 꼴 대가 블록(`Labor_Source.sangeun`) · 금액 = M02 SK 셈 · 단가산출 속 일위대가 줄 =
사전조합 블록을 부르는 줄 · 로직 더한줄의 하위 로직 = 산근 목록(`Sangeun.rows_of`)
- [업데이트] = SK 고친 뒤 다시 굳힘 — 굳힌 키 · cover · cid · 입력값 · 고른자재 · B09 덮개 그대로 ·
덮개 밑 값(base) = 새 단가산출 값 · 서고 사본(`sangeun`)도 원본판 다르면 다시 굳힘 ·
미리 보기 SK 줄
- 내 자료집 = SK 블록 · 품셈 블록 → 개인 층 SK(B09 덮개 = SK 덮개) → 서고로 다시 불러와 같은 금액
"""
# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀
import json
import pytest
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Refresh as refresh
from B09_Estimation import B09_Estimation_Sangeun as sangeun
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from M02_MasterTemplete import M02_Item_Copy as item_copy
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete import M02_Item_Source as source
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_145_2_b09_labor_calc import STONE, _item, _project, _snap
from resources.tester.test_715_sangeun_store import PCT, _calc, _sk
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
TWO_MATS = "GC000565" # 재료 줄 둘(고른자재 열쇠)
COVER = {"labor:LB000036#0": {"qty": 0.5}}
PRICE = {"labor:LB000002#0": {"labor": 200000}}
OWNER = "7/42"
def _json(value):
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
def _sc(world, lines):
with store.at(world["personal"]):
doc = {"이름": "속 일위대가", "단위": "㎡", "담은로직": lines}
return refs.create("precombo", doc)["이름"]
def _mine(world, full=False):
"""SK = 품 덮개 · 인력 더한줄 · 로직 더한줄(하위 산근) · full = 단가 덮개 + SC 더한줄."""
added = [
{"종류": "인력", "요소": "LB000002", "수량": 0.1},
{"종류": "로직", "로직": PCT, "입력값": {}, "수량": 0.5},
]
cover = COVER
if full:
sc = _sc(world, [{"로직": PCT, "수량": {"손": 1}}])
added.append({"참조": {**MINE, "키": sc}, "수량": 2}) # 단가산출 속 일위대가
cover = {**COVER, **PRICE}
return _sk(world["personal"], 바탕={"로직": PCT}, 덮개=cover, 더한줄=added)
def _engine_ready(world):
"""엔진 입력 덧칸(715-1b `단가` · `값묶음`)이 붙었나 — 아니면 M02 도 못 셈."""
key = _sk(
world["personal"], 이름="엔진", 바탕={"로직": PCT}, 덮개={"labor:LB000002#0": {"labor": 1}}
)
got = _calc(world["personal"], key)
return not got.get("까닭") and got["줄"][1]["단가"] == 1
def _b09(root, cover=None):
keys = linked_resource_keys(root)
inputs = prices.build_inputs(None, keys)
inputs["daega"] = daega_inputs(root)
base = compute_base(inputs, {})
sg = sangeun.build(root, base)
rows = {r["key"]: r for r in sg["sg_list"]}
out = compute_labor(inputs["daega"], base, rows, cover)
return inputs["daega"], {b["id"]: b for b in out["daega_blocks"]}, rows
def _lib(world, tmp_path, key, kind="sangeun"):
root = _project(tmp_path / "proj", [])
made = library.import_copy(root, kind, "personal", OWNER, key)
return root, made["폴더"]
def _set_cover(world, key, cover):
with store.at(world["personal"]):
now = store.read("sangeun", key)
store.write("sangeun", key, now["판"], {**now["문서"], "덮개": cover})
def _same_money(block, live):
assert block["sum"] == pytest.approx(float(live["1단위"]["계"]), abs=5) # 끝수(내림)만 다름
for part, name in (("labor", "노무비"), ("material", "재료비"), ("expense", "경비")):
assert block[part] == pytest.approx(float(live["1단위"][name]), abs=3)
def test_SK_대가_블록_금액_M02_같음_하위_산근(world, tmp_path):
key = _mine(world)
live = _calc(world["personal"], key)
assert not live.get("까닭") and live["1단위"]["계"] > 0
root, folder = _lib(world, tmp_path, key)
daega, done, sg_rows = _b09(root)
block = done[daega["libs"][folder]]
assert block["cover"].startswith("P:") and block["kind"] == "품셈"
lines = block["lines"]
assert all(ln.get("sk") for ln in lines)
assert lines[0]["kind"] == "labor" and lines[0]["qty"] == 0.5
assert lines[0]["poom"] == {"qty": 0.018, "단가": 233250} # M02 에서 고친 줄 = 품셈 값 곁
assert "poom" not in lines[1]
sub = next(ln for ln in lines if ln["kind"] == "sangeun")
assert sub["key"] in sg_rows and sub["key"].startswith(f"SG:{PCT}") and sub["qty"] == 0.5
_same_money(block, live)
def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path):
if not _engine_ready(world):
pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함")
key = _mine(world, full=True)
live = _calc(world["personal"], key)
root, folder = _lib(world, tmp_path, key)
daega, done, _ = _b09(root)
block = done[daega["libs"][folder]]
lines = block["lines"]
assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000)
sc_line = next(ln for ln in lines if ln["kind"] == "daega")
assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2
_same_money(block, live)
def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로(
world, tmp_path, monkeypatch
):
key = _mine(world)
ref = {**MINE, "키": key}
with store.at(world["personal"]):
from M02_MasterTemplete import M02_Labor_Doc as labor
doc = labor.clean(
{
"열": "rv",
"담은로직": [
{"참조": ref, "수량": {"손": 2}},
{"로직": "GF000219", "입력값": {}, "수량": {"손": 1}},
{"로직": TWO_MATS, "수량": {"손": 1}},
],
}
)
def build(_item_): # 원본 층 → 사본 파일(구조물 원본 대신 이 일위대가 문서)
rows = source.labor_doc_rows(doc)
return _json(
{
source.LABOR_FILE: rows,
source.PRICE_FILE: source.price_bundle(rows, world["personal"]),
snapshots.VALUES_FILE: {},
}
)
monkeypatch.setattr(item_copy, "build_snapshot", build)
root = _project(tmp_path / "proj", [(100, "SS000901")])
folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0")
folder.mkdir(parents=True)
values = {"GF000219": {k: {"값": v} for k, v in STONE.items()}}
picks = {TWO_MATS: {"1": "MT000001"}}
for name, data in {
**build(None),
snapshots.VALUES_FILE: values,
snapshots.PICKS_FILE: picks,
snapshots.MANIFEST_NAME: {"원본": {**_item("SS000901"), "경로": ["x"]}},
}.items():
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
rid = refs.ref_id(ref)
frozen_key = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid]
frozen_key = frozen_key["바탕"]["로직"]
daega, _, _ = _b09(root)
combo = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"])
sk_block = next(b for b in daega["blocks"] if b["id"] == combo["lines"][0]["key"])
cid = sk_block["lines"][0]["cid"]
cover = {sk_block["cover"]: {cid: {"qty": 0.9}}}
# SK 고침(품 0.5 → 0.7) → [재적용](업데이트) = 다시 풀어 굳힘
_set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}})
item_copy.reapply(root, folder.name)
after = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid]
assert after["바탕"]["로직"] == frozen_key # 굳힌 키 그대로
assert (
json.loads((folder / snapshots.VALUES_FILE).read_text("utf-8"))["GF000219"]
== (values["GF000219"])
)
assert json.loads((folder / snapshots.PICKS_FILE).read_text("utf-8")) == picks
daega2, done, _ = _b09(root, cover)
block = next(b for b in done.values() if b["cover"] == sk_block["cover"])
line = block["lines"][0]
assert line["cid"] == cid and line["covered"] == ["qty"] and line["qty"] == 0.9
assert line["base"]["qty"] == 0.7 # 덮개 밑 = 새 단가산출 값(「단가산출 값으로」 되돌림 값)
assert daega2["owners"]["pipe@100.0"] in done
def test_서고_SK_업데이트_미리보기_다시_굳힘(world, tmp_path):
key = _mine(world)
root, folder = _lib(world, tmp_path, key)
assert library.changed(root) == []
before = daega_inputs(root)
cover_key = next(b for b in before["blocks"] if b["id"] == before["libs"][folder])["cover"]
_set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}})
got = refresh.preview(root, None, [], None)
assert [(x["키"], x["판"]) for x in got["단가산출"]] == [(key, "바뀜")]
assert [c["폴더"] for c in got["사본"]] == [folder]
assert refresh.reapply_changed(root)["다시"] == ["내 단가산출"]
assert library.changed(root) == [] and [f.name for f, _ in library.copies(root)] == [folder]
after = daega_inputs(root)
block = next(b for b in after["blocks"] if b["id"] == after["libs"][folder])
assert block["cover"] == cover_key and block["lines"][0]["qty"] == 0.7
def test_내_자료집_SK_왕복_같은_금액(world, tmp_path):
key = _mine(world)
root, folder = _lib(world, tmp_path, key)
daega, _, _ = _b09(root)
block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder])
first, second = block["lines"][0]["cid"], block["lines"][1]["cid"]
priced = {block["cover"]: {first: {"qty": 0.9}, second: {"labor": 210000}}}
made = personal.build(root, block["cover"], priced, "내 SK 2")
doc = made["문서"]
assert doc["종류"] == "단가산출" and doc["바탕"] == {"로직": PCT}
assert doc["덮개"]["labor:LB000036#0"] == {"qty": 0.9}
assert doc["덮개"]["labor:LB000002#0"] == {"labor": 210000}
assert doc["더한줄"][0]["요소"] == "LB000002"
assert made["담음"] == {"품": 1, "단가": 1} and made["안담음"] == {"품": 0, "단가": 0}
third = block["lines"][2]["cid"] # 더한줄 인력 = 그 더한줄 수량
cover = {block["cover"]: {first: {"qty": 0.9}, third: {"qty": 0.3}}}
doc = personal.build(root, block["cover"], cover, "내 SK 2")["문서"]
assert doc["더한줄"][0]["수량"] == 0.3 and doc["덮개"] == {"labor:LB000036#0": {"qty": 0.9}}
_, done, _ = _b09(root, cover)
want = done[daega["libs"][folder]]["sum"]
with store.at(world["personal"]):
new = personal.write(doc)["이름"]
with pytest.raises(store.StoreError) as same:
personal.write(doc)
assert same.value.status == 409 and same.value.detail["같은이름"] == new
assert new.startswith("SK-") and new != key
root2, folder2 = _lib(world, tmp_path / "b", new)
daega2, done2, _ = _b09(root2)
assert done2[daega2["libs"][folder2]]["sum"] == pytest.approx(want, abs=2)
def test_내_자료집_품셈_블록_SK(world, tmp_path):
root = _project(tmp_path / "proj", [(100, "SS000901")])
_snap(root, "pipe@100.0", "SS000901", [{"로직": PCT, "승격": True, "수량": {"손": 1}}], {})
daega = daega_inputs(root)
top = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"])
block = next(b for b in daega["blocks"] if b["id"] == top["lines"][0]["key"])
assert block["kind"] == "품셈" and block["origin"]["로직"] == PCT
cover = {block["cover"]: {block["lines"][0]["cid"]: {"qty": 0.5}}}
doc = personal.build(root, block["cover"], cover, "내 해체")["문서"]
assert doc["바탕"] == {"로직": PCT} and doc["단위"] == "㎡"
assert doc["덮개"] == {"labor:LB000036#0": {"qty": 0.5}}
with store.at(world["personal"]):
new = personal.write(doc)["이름"]
plain = _sk(world["personal"], 이름="견줌", 바탕={"로직": PCT}, 덮개=doc["덮개"])
a, b = _calc(world["personal"], new), _calc(world["personal"], plain)
assert a["1단위"]["계"] == b["1단위"]["계"] > 0