Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_7
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/* =============================================================================
|
||||
* calc_sheet.ts
|
||||
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것.
|
||||
* 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거
|
||||
* 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
|
||||
* 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
|
||||
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
export interface SheetLine {
|
||||
이름: string;
|
||||
/** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */
|
||||
글: string;
|
||||
금액: number | null;
|
||||
}
|
||||
export interface SheetGroup {
|
||||
비목: string;
|
||||
소계: number | null;
|
||||
끝수?: string;
|
||||
줄: SheetLine[];
|
||||
}
|
||||
export interface SheetInput {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
고르기?: (string | number)[];
|
||||
}
|
||||
export interface SheetOptions {
|
||||
묶음: SheetGroup[];
|
||||
계: number | null;
|
||||
/** 맨 아래 계 줄 이름(「계 (원/㎡)」) */
|
||||
sumLabel: string;
|
||||
/** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */
|
||||
inputs?: SheetInput[];
|
||||
values?: Record<string, string>;
|
||||
uses?: (group: SheetGroup, index: number) => string[];
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
const won = (v: number | null, digits = 0): string =>
|
||||
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||||
|
||||
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
|
||||
|
||||
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
|
||||
export const sheetFormula = (text: string): string =>
|
||||
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
|
||||
|
||||
/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */
|
||||
function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
let control: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
control = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: onInput,
|
||||
}).root;
|
||||
} else {
|
||||
const box = createInputField({ value, compact: true });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("change", () => {
|
||||
if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim());
|
||||
});
|
||||
control = box.root;
|
||||
}
|
||||
const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control);
|
||||
row.dataset.sheetVar = spec.이름;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
|
||||
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
|
||||
const rows: HTMLElement[] = [];
|
||||
const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s]));
|
||||
o.묶음.forEach((g, gi) => {
|
||||
const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n));
|
||||
if (vars.length && o.onInput) {
|
||||
const onInput = o.onInput;
|
||||
const head = createText(g.비목, "muted", 14);
|
||||
head.dataset.sheetHead = g.비목;
|
||||
rows.push(head);
|
||||
for (const n of vars)
|
||||
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
|
||||
}
|
||||
for (const line of g.줄)
|
||||
rows.push(
|
||||
createCalcFormulaRow(
|
||||
line.이름,
|
||||
createCalcFormula(sheetFormula(line.글)),
|
||||
out(line.금액, false, 1),
|
||||
),
|
||||
);
|
||||
rows.push(
|
||||
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)),
|
||||
);
|
||||
});
|
||||
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
|
||||
return rows;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 · 지운 줄의 사본 뺌)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.후 || "—"}`,
|
||||
];
|
||||
|
||||
@@ -83,11 +83,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);
|
||||
@@ -102,15 +103,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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
|
||||
import {
|
||||
condText,
|
||||
openMaterialModal,
|
||||
@@ -38,7 +39,6 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
@@ -525,7 +525,10 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"),
|
||||
block(
|
||||
section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]),
|
||||
"est",
|
||||
),
|
||||
...(ctx.flowHost
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Est.ts
|
||||
* 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로.
|
||||
* 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import type { LogicInput } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export interface EstContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
unit: string;
|
||||
inputs: LogicInput[];
|
||||
values: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function buildEst(ctx: EstContext): HTMLElement {
|
||||
const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } });
|
||||
const text = ctx.text;
|
||||
if (!text || (text.ok && text.계산됨 === false)) {
|
||||
host.append(createText(tl("Formula_Slot"), "muted"));
|
||||
return host;
|
||||
}
|
||||
if (!text.ok) {
|
||||
host.append(createText(safeReason(text.reason), "error"));
|
||||
return host;
|
||||
}
|
||||
const names = ctx.inputs.map((x) => x.이름);
|
||||
host.append(
|
||||
...calcSheetRows({
|
||||
묶음: text.groups,
|
||||
계: text.계,
|
||||
sumLabel: `${tl("Total")} (${ctx.unit || "원"})`,
|
||||
inputs: ctx.inputs,
|
||||
values: ctx.values,
|
||||
onInput: ctx.onInput,
|
||||
uses: (_, gi) => [
|
||||
...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return host;
|
||||
}
|
||||
@@ -225,3 +225,11 @@
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */
|
||||
.m01lab-est {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -30,10 +30,6 @@ const TEXT = {
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Est: ["인력·재료·경비", "Labor·material·expense"],
|
||||
View_Label: ["보기", "View"],
|
||||
View_Soon: [
|
||||
"준비 중 — 인력 · 재료 · 경비 묶음 보기",
|
||||
"Coming soon — labor · material · expense view",
|
||||
],
|
||||
Now_Inputs: ["지금 입력", "Current inputs"],
|
||||
Now_None: ["없음", "none"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
|
||||
@@ -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,23 +2,17 @@
|
||||
* M02_MasterTemplete_Labor_Sheet.ts
|
||||
* 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다
|
||||
* 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 ·
|
||||
* [담기] = 부르는 쪽 담기 동작 그대로(`put`).
|
||||
* [담기] = 부르는 쪽 담기 동작 그대로(`put`). 줄 · 소계 · 계 = 공용 계산서(`A00_Common/calc_sheet.ts` · 714-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
const won = (v: number | null, digits = 0): string =>
|
||||
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||||
|
||||
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
|
||||
|
||||
export function openStandardSheet(row: StandardRow, put: () => void): void {
|
||||
const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } });
|
||||
sheet.append(createText(tc("Loading")));
|
||||
@@ -51,19 +45,12 @@ export function openStandardSheet(row: StandardRow, put: () => void): void {
|
||||
createText(head, "muted", 14),
|
||||
createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"),
|
||||
];
|
||||
for (const g of got.묶음) {
|
||||
for (const line of g.줄) {
|
||||
const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
|
||||
lines.push(
|
||||
createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)),
|
||||
...calcSheetRows({
|
||||
묶음: got.묶음,
|
||||
계: got.계,
|
||||
sumLabel: `${tc("Std_Sum")} (원/${got.단위 || "식"})`,
|
||||
}),
|
||||
);
|
||||
sheet.replaceChildren(...lines);
|
||||
source.show(got.출처);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None:
|
||||
assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick
|
||||
assert "(!work.select.value || x.공종 === work.select.value)" in pick
|
||||
sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8")
|
||||
assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet
|
||||
# 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀)
|
||||
assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet
|
||||
for name in ("Labor_Pick", "Labor_Sheet"):
|
||||
text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8")
|
||||
assert len(text.splitlines()) <= 700
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
|
||||
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SHEET = ROOT / "A00_Common" / "calc_sheet.ts"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_한_벌을_씀():
|
||||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
|
||||
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
|
||||
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
|
||||
assert "calcSheetRows(" in est and "buildEst(" in detail
|
||||
|
||||
|
||||
def test_부품_상자만():
|
||||
s = _text(SHEET)
|
||||
assert "<button" not in s and "document.createElement" not in s and "<table" not in s
|
||||
assert "@ui/ui_template_calc_row" in s and "@ui/ui_template_input" in s
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_식_글():
|
||||
s = _text(SHEET)
|
||||
pure = s[s.index("export const sheetFormula") : s.index("/** 변수 칸 하나")]
|
||||
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user