Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-10-31 14:00:59 +09:00
55 changed files with 2663 additions and 208 deletions
@@ -341,12 +341,18 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
def used_folders(user: dict[str, Any]) -> set[str]:
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더."""
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 +
일위대가 복사본(703-2 `@copy`)의 원본 사본."""
copies = [
str(lines["@copy"].get("from") or "")
for lines in (user.get("daega_cover") or {}).values()
if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict)
]
return {
str(ref["lib"])
for row in ref_rows(user)
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
}
} | {src[2:] for src in copies if src.startswith("L:")}
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
+7 -6
View File
@@ -48,7 +48,7 @@
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) ·
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 직접공사 = 아는 금액까지 + `blank`(701-3) · 관급 = 빈 줄이 있으면 None) ·
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
"""
@@ -502,14 +502,15 @@ def compute_bill(
"direct": {**direct, "blank": blank},
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
}
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
known = blank == 0
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 ·
# 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 None(그대로)
owner_known = all(row["sum"] is not None for row in owner)
sums_out = {
"material": direct["material"] if known else None,
"labor": direct["labor"] if known else None,
"expense": direct["expense"] if known else None,
"material": direct["material"],
"labor": direct["labor"],
"expense": direct["expense"],
"supply_net": net if owner_known else None,
"blank": blank,
}
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
+7 -6
View File
@@ -9,7 +9,7 @@
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null)
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
@@ -472,14 +472,15 @@ export function computeBill(
direct: { ...direct, blank },
owner: { net_material: net, fee, total: net + (fee || 0) },
};
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
const known = blank === 0;
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 ·
// 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 null(그대로)
const ownerKnown = owner.every((row) => row.sum !== null);
const sumsOut = {
material: known ? direct.material : null,
labor: known ? direct.labor : null,
expense: known ? direct.expense : null,
material: direct.material,
labor: direct.labor,
expense: direct.expense,
supply_net: ownerKnown ? net : null,
blank,
};
return { rows, totals, sums: sumsOut, gaps };
}
+20 -1
View File
@@ -19,6 +19,8 @@
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
"""
from __future__ import annotations
@@ -30,6 +32,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown,
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
TAB = "dg"
COPY = "@copy"
WHY = {
"labor": "노임 단가 없음",
"material": "자재 적용 단가 없음",
@@ -142,6 +145,21 @@ def _block(
}
def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
"""복사본(703-2) — 덮개 차례대로 `@copy` 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨)."""
out = list(blocks)
for key, lines in cover.items():
meta = (lines or {}).get(COPY)
if not isinstance(meta, dict):
continue
src = next((b for b in out if b.get("cover") == meta.get("from")), None)
if src is not None:
spec = meta["spec"] if "spec" in meta else src["spec"]
name = meta.get("name") or src["name"]
out.append({**src, "id": f"DG:{key}", "cover": key, "name": name, "spec": spec})
return out
def compute_labor(
daega: dict[str, Any] | None,
sheets: dict[str, Any],
@@ -161,6 +179,7 @@ def compute_labor(
"sangeun": sangeun or {},
}
cover = cover or {}
blocks = with_copies(blocks, cover)
by_id = {b["id"]: b for b in blocks}
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
done: dict[str, dict[str, Any]] = {}
@@ -211,7 +230,7 @@ def compute_labor(
{"cover": key, "cid": cid}
for key, lines in cover.items()
for cid in lines
if cid not in cids.get(key, ())
if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id)
]
return {
"daega_blocks": out_blocks,
+20 -2
View File
@@ -15,6 +15,8 @@
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
* ========================================================================== */
import {
@@ -31,6 +33,7 @@ import {
const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg";
export const COPY = "@copy";
const WHY: Record<string, string> = {
labor: "노임 단가 없음",
material: "자재 적용 단가 없음",
@@ -146,6 +149,20 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
};
}
/** 복사본(703-2) — 덮개 차례대로 @copy 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨) */
function withCopies(blocks: Row[], covers: Row): Row[] {
const out = [...blocks];
for (const [key, lines] of Object.entries(covers)) {
const meta: Row | undefined = (lines || {})[COPY];
if (!meta || typeof meta !== "object") continue;
const src = out.find((b) => b.cover === meta.from);
if (!src) continue;
const spec = "spec" in meta ? meta.spec : src.spec;
out.push({ ...src, id: `DG:${key}`, cover: key, name: meta.name || src.name, spec });
}
return out;
}
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
export function computeLabor(
daega: Row | null | undefined,
@@ -153,8 +170,8 @@ export function computeLabor(
sangeun: Row = {},
covers: Row = {},
): Row {
const blocks: Row[] = (daega || {}).blocks || [];
covers = covers || {};
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
const index = (rows: Row[] | undefined): Row =>
Object.fromEntries((rows || []).map((r) => [r.key, r]));
const books: Row = {
@@ -207,7 +224,8 @@ export function computeLabor(
const orphans: Row[] = [];
for (const [key, lines] of Object.entries(covers))
for (const cid of Object.keys(lines || {}))
if (!cids.get(key)?.has(cid)) orphans.push({ cover: key, cid });
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId))
orphans.push({ cover: key, cid });
return {
daega_blocks: outBlocks,
daega: listing,
@@ -0,0 +1,165 @@
"""B09 「내 자료집에 저장」(PLAN 703-2) — 펼친 일위대가(대가 블록) → 개인 층 사전조합(SC).
담는 것 = 담은로직(넣을 값 · 고른 자재 얹음) · 자재 · 줄 수량(= 수량산출 수기 `손`) —
맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림)
안 담는 것 = 품 덮개(부른 블록 안 줄 · 품셈 블록 줄 — 로직 통째 배수로 바꾸면 다른 줄까지 바뀜) ·
단가 덮개(사전조합 꼴에 단가 칸 없음) → 수만 알림(품은 PLAN 705 고정 줄이 생기면 담김)
원천 = 블록 cover — 조합 `S:`/`L:` = 사본 폴더(일위대가 · 넣을 값 · 고른 자재 · 셈 수량) ·
사전조합 `C:`/`L:` = 굳힌 참조사본 문서 · 품셈 `P:` = 로직 한 줄 ·
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
"""
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.B09_Estimation_Calc_Labor import COPY, with_copies
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Snapshot import (
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
KIND = "precombo"
PRICE_KEYS = ("labor", "material", "expense")
def _combo(folder: Path) -> tuple[list[dict], list[dict]]:
"""구조물조합 사본 → (담은로직 · 자재) — 넣을 값 · 고른 자재 얹고 수량 = 셈 수량(손)."""
labor = read_json(folder / LABOR_FILE) or {}
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
got = item_prices.calc(
labor, read_json(folder / PRICE_FILE) or {}, values, picks, read_json(folder / AREA_FILE)
)
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
lines = [
{**one, "수량": {"손": done.get("수량")}}
for one, done in zip(doc.get("담은로직") or [], got.get("줄") or [])
]
mats = [
{**one, "수량": {"손": done.get("수량")}}
for one, done in zip(doc.get("자재") or [], got.get("자재") or [])
]
return lines, mats
def _frozen(root: Path, rid: str) -> dict[str, Any] | None:
"""굳힌 사전조합 문서 — 구조물 사본 · 서고 사본의 `참조사본` 에서 찾음."""
folders = [p for p in sorted(structures_dir(root).glob("*")) if p.is_dir()]
folders += [p for p, _m in library.copies(root)]
for folder in folders:
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(rid)
if isinstance(doc, dict):
return doc
return None
def _precombo(doc: dict[str, Any]) -> tuple[list[dict], list[dict]]:
lines = [
{**one, "수량": {"손": (one.get("수량") or {}).get("손", 1)}}
for one in doc.get("담은로직") or []
if isinstance(one, dict)
]
return lines, [m for m in doc.get("자재") or [] if isinstance(m, dict)]
def source(root: Path, block: dict[str, Any], base: str) -> tuple[list[dict], list[dict]]:
"""블록 원천(`base` = 복사본이면 원본 cover) → (담은로직 · 자재) · 못 찾으면 LookupError."""
head, _, rest = base.partition(":")
if block["kind"] == "품셈":
origin = block.get("origin") or {}
one = {"로직": origin.get("로직"), "입력값": origin.get("입력값") or {}}
one.update({k: origin[k] for k in ("켜쌓기율", "고름") if origin.get(k) is not None})
return [{**one, "수량": {"손": 1}}], []
if head == "S":
return _combo(structures_dir(root) / rest)
if head == "L":
folder = library.library_dir(root) / rest
manifest = read_json(folder / MANIFEST_NAME) or {}
if manifest.get("종류") != "precombo":
return _combo(folder)
ref = (manifest.get("원본") or {}).get("참조")
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(refs.ref_id(ref))
if isinstance(doc, dict):
return _precombo(doc)
if head == "C" and (doc := _frozen(root, rest)) is not None:
return _precombo(doc)
raise LookupError(f"일위대가 원천을 못 찾음 「{base}」")
def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = None) -> dict:
"""블록 cover → `{문서, 담음, 안담음}` — 문서 = 사전조합 꼴(키 · 층 쓰기 전)."""
raw = daega_inputs(root).get("blocks") or []
blocks = {b["cover"]: b for b in with_copies(raw, cover)}
block = blocks.get(cover_key)
if block is None:
raise LookupError(f"없는 일위대가 「{cover_key}」")
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 {}
kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)}
kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines)
skip = {"품": 0, "단가": 0}
top = block["lines"] if block["kind"] != "품셈" else []
for line, one in zip(top, [*lines, *mats]):
cell = mine.get(line.get("cid")) or {}
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
if "qty" not in cell:
continue
hand, each = (one.get("수량") or {}).get("손"), line.get("qty")
if not isinstance(hand, (int, float)) or not each:
skip["품"] += 1
continue
one["수량"] = {"손": round(hand * cell["qty"] / each, 9)}
kept["수량"] += 1
# 맨 위 밖 덮개 = 품셈 블록 줄 · 부른 블록 안 줄(품) · 단가
by_id = {b["id"]: b for b in blocks.values()}
todo = [] if top else [block["id"]]
todo += [ln.get("key") for ln in top if ln.get("kind") == "daega"]
seen: set[str] = set()
while todo:
sub = by_id.get(todo.pop())
if sub is None or sub["id"] in seen:
continue
seen.add(sub["id"])
for cell in (cover.get(sub["cover"]) or {}).values():
skip["품"] += "qty" in cell
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
todo += [ln.get("key") for ln in sub["lines"] if ln.get("kind") == "daega"]
doc = {
"이름": (name or block.get("name") or "").strip(),
"단위": block.get("unit") or "",
"담은로직": lines,
"자재": mats,
}
return {"문서": doc, "담음": kept, "안담음": skip}
def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
same = [
path.stem
for path in sorted((store.root() / KIND).glob("SC-*.json"))
if (read_json(path) or {}).get("이름") == doc["이름"]
]
if overwrite:
if overwrite not in same:
raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름")
old = store.read(KIND, overwrite)
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
if same:
raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]})
return refs.create(KIND, doc)
+30 -8
View File
@@ -198,6 +198,31 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st
"rows": rows,
"driver_factor": driver_factor(master),
"fx": fx_inputs(master),
"labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」
}
def labor_editions(keys: list[str]) -> dict[str, str]:
"""노임 판(703-3) — 수요 노무 줄 구분(건설업 · 산림 …)마다 M01 `인력.json` 그 구분 `판`."""
files, master = m01.loaded()
groups = (files.get("인력.json") or {}).get("구분") or {}
out: dict[str, str] = {}
for key in demand(master, keys)["labor"]:
kind = str((_row(master, key) or {}).get("구분") or "")
if (groups.get(kind) or {}).get("판"):
out[kind] = str(groups[kind]["판"])
return out
def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]:
"""두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3)."""
new = json.loads(json.dumps(new, ensure_ascii=False))
rows = (old or {}).get("rows") or {}
return {
"new": sum(key not in rows for key in new["rows"]),
"changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()),
"factor": (old or {}).get("driver_factor") != new["driver_factor"],
"fx": (old or {}).get("fx") != new["fx"],
}
@@ -206,15 +231,12 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
if book is None:
return "단가 아직 안 굳힘"
# 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296)
fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False))
rows = book.get("rows") or {}
new = sum(key not in rows for key in fresh["rows"])
changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items())
diff = book_diff(book, refresh_book(keys, book))
parts = [
f"새 단가 줄 {new}" if new else "",
f"M01 에서 바뀐 단가 {changed}" if changed else "",
"운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "",
"환율 바뀜" if book.get("fx") != fresh["fx"] else "",
f"새 단가 줄 {diff['new']}" if diff["new"] else "",
f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "",
"운전사 계수 바뀜" if diff["factor"] else "",
"환율 바뀜" if diff["fx"] else "",
]
return " · ".join(p for p in parts if p) or None
+72
View File
@@ -0,0 +1,72 @@
"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께.
미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본
(연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 —
재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날)
적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) ·
덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
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
def changed_copies(root: str | Path) -> list[dict[str, Any]]:
"""연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`."""
linked = {one["폴더"] for one in linked_snapshots(root).values()}
return [
{"폴더": it["폴더"], "이름": it["이름"] or it["폴더"]}
for it in item_copy.list_items(root)
if it["폴더"] in linked and it["마스터바뀜"]
]
def cover_cells(cover: dict[str, Any] | None) -> int:
"""고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌."""
return sum(
len(cell) for lines in (cover or {}).values() for cid, cell in lines.items() if cid != COPY
)
def edition_text(editions: dict[str, str] | None) -> str:
return " · ".join(f"{kind} {day[:7]}" for kind, day in sorted((editions or {}).items()))
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))
before = (book or {}).get("labor_edition")
made = str((book or {}).get("made") or "")[:10]
return {
"단가줄": diff["new"] + diff["changed"],
"새줄": diff["new"],
"바뀐줄": diff["changed"],
"계수": diff["factor"],
"환율": diff["fx"],
"사본": changed_copies(root),
"고친칸": cover_cells(cover),
"노임판": {
"전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"),
"후": edition_text(prices.labor_editions(keys)),
},
}
def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]:
"""원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`."""
done, stuck = [], []
for one in changed_copies(root):
try:
item_copy.reapply(root, one["폴더"], who)
done.append(one["이름"])
except (LookupError, ValueError, KeyError, OSError) as error:
stuck.append(f"{one['이름']} — {error}")
return {"다시": done, "막힘": stuck}
+82 -6
View File
@@ -11,8 +11,13 @@
원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 →
답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음
POST /api/projects/{id}/estimation/prices {user?}
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 ·
지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로
[업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) →
수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은
수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 ·
답 notes 첫 줄 = 바뀐 수
POST /api/projects/{id}/estimation/prices/preview {user?}
[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 ·
노임 판(쓰지 않음)
POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 ·
저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2)
@@ -24,6 +29,10 @@
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?}
「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합
(`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 ·
같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
@@ -53,7 +62,9 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
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 import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
@@ -71,6 +82,7 @@ from common_util.common_util_project_settings import reset_section
from common_util.common_util_workflow_state import mark_stage
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete.M02_Item_Snapshot import importer
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
@@ -236,8 +248,12 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) ->
@router.post("/{project_id}/estimation/complete")
async def estimation_complete(project_id: UUID) -> dict[str, Any]:
"""B09 [>] — 단계 6 완료(요구 300 B1)."""
await _project_root(project_id) # 없는 프로젝트는 404
"""B09 [>] — 단계 6 완료(요구 300 B1) · 빈 줄 있으면 409(701-3 확정 = 빈 줄 0 · 서버 정본으로 셈)."""
root = await _project_root(project_id) # 없는 프로젝트는 404
sheets = (await payload(project_id, root))["sheets"]
blank = (((sheets.get("bill") or {}).get("totals") or {}).get("direct") or {}).get("blank")
if blank:
raise HTTPException(409, f"빈 줄 {blank} · 미완성 — 확정은 빈 줄 0 일 때만")
await mark_stage(str(project_id), 6, "next")
return {"status": "success", "project_id": str(project_id)}
@@ -260,16 +276,44 @@ async def estimation_reset(project_id: UUID) -> dict[str, Any]:
@router.post("/{project_id}/estimation/prices")
async def refresh_prices(
project_id: UUID, body: dict[str, Any] | None = Body(None)
project_id: UUID,
body: dict[str, Any] | None = Body(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 ·
덮개 그대로."""
root = await _project_root(project_id)
raw = (body or {}).get("user")
draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함)
again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session))
keys = await _demand(root, draft)
old = await asyncio.to_thread(store.read_book, root)
book = await asyncio.to_thread(prices.refresh_book, keys, old)
await asyncio.to_thread(store.write_book, root, book)
return await payload(project_id, root, draft)
out = await payload(project_id, root, draft)
diff = prices.book_diff(old, book)
cells = refresh.cover_cells((draft or out.get("user") or {}).get("daega_cover"))
out["notes"] = [
f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 "
f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로",
*(f"재적용 못 함 {why}" for why in again["막힘"]),
*(out.get("notes") or []),
]
return out
@router.post("/{project_id}/estimation/prices/preview")
async def refresh_preview(
project_id: UUID, body: dict[str, Any] | None = Body(None)
) -> dict[str, Any]:
"""[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판."""
root = await _project_root(project_id)
raw = (body or {}).get("user")
draft = store.clean_user(raw) if isinstance(raw, dict) else None
keys = await _demand(root, draft)
book = await asyncio.to_thread(store.read_book, root)
user = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover"))
class PreviewBody(BaseModel):
@@ -381,6 +425,38 @@ async def library_inputs(
return {"lines": lines}
class PersonalBody(BaseModel):
cover: str = Field(min_length=1, max_length=200)
name: str | None = Field(None, max_length=200)
overwrite: str | None = Field(None, max_length=20)
user: dict[str, Any] | None = None
@router.post("/{project_id}/estimation/daega/personal")
async def save_personal(
project_id: UUID, body: PersonalBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root = await _project_root(project_id)
raw = (
body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root)
)
cover = store.clean_user(raw).get("daega_cover") or {}
try:
made = await asyncio.to_thread(personal.build, root, body.cover, cover, body.name)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
if not made["문서"]["이름"]:
raise HTTPException(status_code=400, detail="이름이 빔")
folder = await asyncio.to_thread(_root, session, "personal", None, True)
got = await asyncio.to_thread(_call, folder, personal.write, made["문서"], body.overwrite)
return {
"키": got["이름"],
"이름": made["문서"]["이름"],
"담음": made["담음"],
"안담음": made["안담음"],
}
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
+7 -1
View File
@@ -570,11 +570,17 @@ def collect(
def build(
project_root: str | Path, sheets: dict[str, Any], user: dict[str, Any] | None = None
) -> dict[str, Any]:
"""연결된 사본 전부(구조물 id 차례)의 산근 + 설계내역서 손 줄 로직(166-2 · 뒤 번호)."""
"""연결된 사본 전부(구조물 id 차례 → 설계내역서 서고 사본 · 702-3)의 산근 +
설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs`."""
from B09_Estimation.B09_Estimation_Bill_Library import copies
base = structures_dir(project_root)
entries = [
(owner, base / linked["폴더"])
for owner, linked in sorted(linked_snapshots(project_root).items())
if "일위대가" in linked.get("부분", [])
]
entries += [
(f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root)
]
return collect(entries, sheets, hand_sources(user))
+7 -1
View File
@@ -12,6 +12,7 @@
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
@@ -227,12 +228,17 @@ def clean_bill(raw: Any) -> dict[str, Any]:
def clean_cover(raw: Any) -> dict[str, Any]:
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림."""
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림 ·
복사본 표지 `@copy`(703-2) = from · name · spec 글만(from 없으면 버림)."""
out: dict[str, Any] = {}
for block, lines in (raw if isinstance(raw, dict) else {}).items():
kept = {}
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
cell = cell if isinstance(cell, dict) else {}
if cid == "@copy":
if _text(cell.get("from")):
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell}
continue
got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None}
if got:
kept[str(cid)[:200]] = got
+126 -27
View File
@@ -4,15 +4,24 @@
*
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
* 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
* 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) →
* 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] =
* 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함
* 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 +
* 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, showToast } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { openModal } from "@ui/ui_template_modal";
import { createBadge } from "@ui/ui_template_badge";
import { createText } from "@ui/ui_template_text";
import type { Row } from "./B09_Estimation_Calc_Base";
import { dateTimeText } from "../common_util/common_util_time";
import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
import { readState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
export interface CheckItem {
link: Link;
@@ -21,19 +30,36 @@ export interface CheckItem {
why: string;
}
/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */
export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } {
const gaps: Row[] = sheets.gaps || [];
const items = gaps.map((g) => ({
link: { tab: g.tab as TabId, key: g.key },
tab: tabName(g.tab),
name: String(g.name ?? ""),
why: String(g.why ?? ""),
}));
const BLANK_WHY = "금액 모름 — 단가연결 칸 램프 · 이은 호표의 빈 줄을 확인";
/** 빈 줄(설계내역서 줄마다 하나 · 까닭 모음) · 그 밖 확인 줄(탭 · 키마다 하나) · 알림 글 */
export function checkItems(
sheets: Row,
payload: Row,
): { items: CheckItem[]; others: CheckItem[]; notes: string[] } {
const why = new Map<string, string[]>();
const order: string[] = [];
const named = new Map<string, Row>();
for (const g of (sheets.gaps || []) as Row[]) {
const id = `${g.tab}|${g.key}`;
if (!why.has(id)) (why.set(id, []), order.push(id), named.set(id, g));
const list = why.get(id)!;
if (g.why && !list.includes(String(g.why))) list.push(String(g.why));
}
const item = (tab: string, key: string, name: unknown, reasons?: string[]): CheckItem => ({
link: { tab: tab as TabId, key },
tab: tabName(tab),
name: String(name ?? ""),
why: reasons?.length ? reasons.join(" · ") : BLANK_WHY,
});
const items = blankRows(sheets).map((r) => item("bill", r.key, r.name, why.get(`bill|${r.key}`)));
const others = order
.filter((id) => !id.startsWith("bill|"))
.map((id) => item(named.get(id)!.tab, named.get(id)!.key, named.get(id)!.name, why.get(id)));
const notes: string[] = [...(payload.notes || [])];
const missing: string[] = payload.inputs?.missing || [];
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
return { items, notes };
return { items, others, notes };
}
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
@@ -41,7 +67,65 @@ export function refreshTip(payload: Row | null): string {
const made = payload?.book?.made;
const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘";
const stale = payload?.stale;
return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
}
/** [업데이트] 미리 보기 글 줄(703-3) */
export function previewLines(got: Row): string[] {
const copies: Row[] = got.사본 || [];
const edition = got.노임판 || {};
return [
`바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` +
`${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
`다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
`고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`,
`노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`,
];
}
/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */
function attachPreview(refresh: HTMLElement, payload: () => Row | null): void {
let pass = false;
refresh.addEventListener(
"click",
(event) => {
if (pass) return void (pass = false);
event.stopImmediatePropagation();
const projectId = currentProjectId();
const user = readState<Row>("b09-estimation", projectId) ?? payload()?.user ?? {};
void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user }),
})
.then(async (response) => {
const got = (await response.json()) as Row;
if (!response.ok) throw new Error(String(got?.detail ?? response.status));
openModal({
title: "업데이트 미리 보기",
closeLabel: "닫기",
width: "40rem",
mount: (body, close, head) => {
const apply = createButton({
label: "적용",
variant: "filled",
onClick: () => {
close();
pass = true;
refresh.click();
},
});
apply.dataset.b09RefreshApply = "";
head.append(apply);
body.append(...previewLines(got).map((line) => createText(line)));
},
});
})
.catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error"));
},
true,
);
}
export interface CheckPanel {
@@ -54,25 +138,25 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
let payload: Row | null = null;
let items: CheckItem[] = [];
attachHoverInfo(refresh, () => refreshTip(payload));
const list = document.createElement("div");
list.className = "b09-check";
attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
attachPreview(refresh, () => payload);
const box = document.createElement("div");
box.className = "b09-check";
attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
const draw = (next: Row, sheets: Row): void => {
payload = next;
refresh.classList.toggle("is-pending", Boolean(next.stale));
const made = checkItems(sheets, next);
items = made.items;
const head = createText(`연결 없음 ${items.length}곳`, "muted");
head.classList.add("b09-check__head");
const head = (text: string): HTMLElement => {
const p = createText(text, "muted");
p.classList.add("b09-check__head");
return p;
};
const list = (group: CheckItem[], offset: number): HTMLElement => {
const rows = document.createElement("div");
rows.className = "ui-sidebar-list";
rows.append(
...items.map((item, i) => {
...group.map((item, n) => {
const row = document.createElement("button");
row.type = "button";
row.className = "ui-sidebar-row";
row.dataset.i = String(i);
row.dataset.i = String(offset + n);
const name = document.createElement("span");
name.textContent = item.name;
const tab = createBadge(item.tab, "neutral", { size: 18 });
@@ -82,12 +166,27 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
return row;
}),
);
return rows;
};
const draw = (next: Row, sheets: Row): void => {
payload = next;
refresh.classList.toggle("is-pending", Boolean(next.stale));
const made = checkItems(sheets, next);
items = [...made.items, ...made.others];
const blank = [
head(`빈 줄 ${made.items.length}`),
...(made.items.length ? [list(made.items, 0)] : []),
];
const others = made.others.length
? [head(`그 밖 확인 ${made.others.length}곳`), list(made.others, made.items.length)]
: [];
const notes = made.notes.map((text) => {
const p = createText(text, "muted");
p.classList.add("b09-check__note");
return p;
});
list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes);
box.replaceChildren(...blank, ...others, ...notes);
};
return { list, draw };
return { list: box, draw };
}
+9 -3
View File
@@ -15,6 +15,7 @@ import "./B09_Estimation_UI_Page.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createText } from "@ui/ui_template_text";
import { createBanner } from "@ui/ui_template_banner";
import {
createButton,
hideLoadingOverlay,
@@ -41,7 +42,7 @@ import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
import {
DRAFT,
rowIndex,
TABS,
unfinished,
type Link,
type TabContext,
type TabId,
@@ -127,7 +128,7 @@ export function renderB09Estimation(root: HTMLElement): void {
let payload: Row | null = null;
let user: Row = {};
let sheets: Row = {};
let current: TabId = TABS[0][0];
let current: TabId = "bill"; // 701-2 첫 화면 = 설계내역서(빈 원가계산서 아님)
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
const bars = createTabBars((tab) => show(tab));
@@ -266,7 +267,9 @@ export function renderB09Estimation(root: HTMLElement): void {
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
const foot = hint ?? level?.guide;
const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래
panel.replaceChildren(table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []));
// 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위
const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : [];
panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
};
@@ -311,6 +314,7 @@ export function renderB09Estimation(root: HTMLElement): void {
? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user })
: Promise.resolve(true);
DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3)
DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0
const refresh = createButton({
label: "업데이트",
variant: "ghost",
@@ -370,6 +374,8 @@ export function renderB09Estimation(root: HTMLElement): void {
left.addEventListener("ui-step-nav", (event) => {
if ((event as CustomEvent<number>).detail !== 1 || !goNext || !projectId) return;
event.stopPropagation();
const why = DRAFT.blocked?.();
if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만
void moveStage(projectId, goNext, [
{ name: "원가 저장", run: saveChanged },
{
+16
View File
@@ -56,6 +56,18 @@ export const TABS: Array<[TabId, string]> = [
export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id;
/** 빈 줄(701-2) = 설계내역서 금액 모르는 직접공사 항목 줄 — 안내 · 데이터 점검 · 집계표 · 원가계산서가 같은 수 */
export const blankRows = (sheets: Row): Row[] =>
((sheets?.bill?.rows || []) as Row[]).filter(
(r) => r.kind === "item" && r.cost === "direct" && r.sum === null,
);
/** 「빈 줄 n · 미완성」 띠 글(701-3) — 빈 줄 0 = null */
export const unfinished = (sheets: Row): string | null => {
const n = blankRows(sheets).length;
return n ? `빈 줄 ${n} · 미완성 — 금액은 아는 줄까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : null;
};
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
export const TAB_GROUPS: Array<[string, TabId[]]> = [
["공사원가계산서", ["cost"]],
@@ -95,6 +107,8 @@ export interface TabTable {
frozen?: number;
/** 표 위 안내 글(근거 · 끝수) */
hint?: string;
/** 표 위 띠 알림(공용 띠 · 경고) — 예 원가계산서 「빈 줄 n · 미완성」(701-3) */
banner?: string;
/** 칸 누름 → 갈 줄(호표 칸) */
link?: (index: number, key: string) => Link | null;
editable?: (index: number, key: string) => boolean;
@@ -134,6 +148,8 @@ export const DRAFT: {
next?: () => void;
/** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */
save?: () => Promise<boolean>;
/** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */
blocked?: () => string | null;
} = { builders: {}, extend: [], panels: [], actions: [] };
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
@@ -69,7 +69,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
hint:
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" +
(direct.blank
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만`
: ""),
editOnDouble: true,
editable: (i, key) => {
@@ -9,6 +9,7 @@
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -16,9 +17,11 @@ import { createCard } from "@ui/ui_template_card";
import { createText } from "@ui/ui_template_text";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
/** 고칠 칸 → 덮개 칸 */
const FIELD: Record<string, string> = {
@@ -91,7 +94,14 @@ function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
const covered = (at: FoldLine | undefined, field: string): boolean =>
!!at && (at.line.covered || []).includes(field);
function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTMLElement {
function foldCard(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
close: () => void,
go: (key: string) => void,
): HTMLElement {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
);
@@ -129,6 +139,7 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
frozen: 1,
empty: "",
editOnDouble: true,
onCellClick: () => undefined, // 줄 번호 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
editable: (i, key) => {
const at = lines[i];
if (!at?.cover || !(key in FIELD)) return false;
@@ -167,9 +178,11 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
);
attachRowMenu(table.root, (i) => {
const at = lines[i];
return at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
: [];
const undo: MapContextMenuItem[] =
at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
: [];
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
});
const actions = [
...(edited.length
@@ -203,6 +216,13 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
const host = document.createElement("div");
/** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */
const go = (key: string): void => {
const i = raw.findIndex((r) => String(r.key) === key);
table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" });
open = key;
draw();
};
const draw = (): void => {
const row = raw.find((r) => r.key === open) as Row | undefined;
const ref: Row | undefined = row?.ref;
@@ -213,10 +233,17 @@ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLEleme
host.replaceChildren(
...(row && block
? [
foldCard(ctx, row, block, () => {
open = null;
draw();
}),
foldCard(
ctx,
raw,
row,
block,
() => {
open = null;
draw();
},
go,
),
]
: []),
);
@@ -0,0 +1,208 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts
* B09 설계내역서 펼친 일위대가 우클릭(PLAN 703-2) — 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나.
*
* 복사 = 이름 · 규격 묻기 → 덮개 `K:…` 에 `@copy` {from · name · spec} + 원본 덮개째(캐시 · 셈 짝이 새 호표 ·
* 번호 끝) → 「이 줄 단가연결을 새 호표로」 물음 · 복사본 지우기 = 그 덮개 통째(쓰는 줄 있으면 안 지움)
* 내 자료집 = POST …/estimation/daega/personal(화면 캐시 덮개째 · 서버가 개인 층 사전조합) ·
* 같은 이름 409 → 「덮어쓰기 · 새 이름」 · 담음 · 안 담음(품 · 단가 덮개) 수 알림
* 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 그 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { askFields } from "@ui/ui_template_item_picker_ask";
import { createListRow } from "@ui/ui_template_list_row";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
import type { DataRow } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import { COPY } from "./B09_Estimation_Calc_Labor";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit";
/** 어디 쓰였나 — 같은 품목으로 찾는 줄 */
const GOODS = ["labor", "material", "machine"];
/** 부른 대가 따라가는 깊이 */
const DEPTH = 6;
const covers = (ctx: TabContext): Row => JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
const newKey = (): string => `K:${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`;
/** 복사해 새 일위대가 — 덮개째 · 번호 끝 · 이 줄 연결 바꿀지 물음 */
async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): Promise<void> {
const got = await askFields({
title: "복사해 새 일위대가",
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
fields: [
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
{ key: "spec", label: "규격", value: block.spec || "" },
],
okLabel: "만들기",
closeLabel: "닫기",
emptyText: "{label} 이 빔",
});
if (!got) return;
const key = newKey();
const all = covers(ctx);
all[key] = { ...(all[block.cover] || {}), [COPY]: { from: block.cover, ...got } }; // 덮개째
const relink = await askConfirm({
title: "단가연결",
message: `「${row.name || ""}」 줄의 단가연결을 새 호표 「${got.name}」 로 바꿀지`,
okLabel: "바꿈",
cancelLabel: "그대로",
});
const next = { ...ctx, user: { ...ctx.user, daega_cover: all } };
const i = raw.findIndex((r) => r.key === row.key);
if (relink && i >= 0) editCell(next, raw, i, "ref", { tab: "dg", key: `DG:${key}` });
else ctx.setUser(next.user);
showToast(`새 호표 「${got.name}」 — 일위대가목록표 끝 · [저장] 때 서버가 다시 셈`, "success");
}
/** 복사본 지우기 — 그 호표를 잇는 내역 줄이 있으면 막음 */
function removeCopy(ctx: TabContext, raw: DataRow[], block: Row): void {
const used = raw.filter((r) => (r.ref as Row | undefined)?.key === block.id);
if (used.length) {
showToast(`잇는 내역 줄 ${used.length} — 단가연결을 먼저 바꿈`, "error");
return;
}
const all = covers(ctx);
delete all[block.cover];
ctx.setUser({ ...ctx.user, daega_cover: all });
}
async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; got: Row }> {
const response = await fetch(
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/daega/personal`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
return { ok: response.ok, status: response.status, got };
}
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
let name = await askText({
title: "내 자료집에 저장",
message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지",
value: block.name || "",
okLabel: "저장",
});
let overwrite: string | null = null;
while (name !== null && name.trim()) {
const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user };
const { ok, status, got } = await postPersonal(body);
const same = got.detail?.같은이름;
if (ok) {
const skip: Row = got.안담음 || {};
const left = (skip.품 || 0) + (skip.단가 || 0);
const tail = left
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
: "";
showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000);
return;
}
if (status !== 409 || !same) {
const detail = got.detail;
showToast(`저장 못 함 — ${typeof detail === "string" ? detail : status}`, "error");
return;
}
const over = await askConfirm({
title: "같은 이름",
message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
okLabel: "덮어쓰기",
cancelLabel: "새 이름",
});
if (over) overwrite = same;
else {
overwrite = null;
name = await askText({ title: "새 이름", message: "내 자료집 이름", value: `${name} 2` });
}
}
}
/** 블록 나무에 그 품목(종류 · 키) · 그 호표(id)가 있나 */
function holds(blocks: Map<string, Row>, bid: string, hit: (ln: Row) => boolean): boolean {
const seen = new Set<string>();
const walk = (id: string, depth: number): boolean => {
const b = blocks.get(id);
if (!b || seen.has(id) || depth > DEPTH) return false;
seen.add(id);
return (b.lines || []).some(
(ln: Row) => hit(ln) || (ln.kind === "daega" && walk(String(ln.key), depth + 1)),
);
};
return walk(bid, 0);
}
/** 어디 쓰였나 — 그 품목 · 호표를 쓰는 내역 줄 → 누르면 그 줄로 */
function whereUsed(
ctx: TabContext,
raw: DataRow[],
block: Row,
line: Row,
go: (key: string) => void,
): void {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.id), b]),
);
const goods = GOODS.includes(line.kind);
const id = line.kind === "daega" ? String(line.key) : String(block.id);
const label = goods ? `${line.name || line.key}` : `${blocks.get(id)?.name || ""}`;
const hit = goods
? (ln: Row) => ln.kind === line.kind && ln.key === line.key
: (ln: Row) => ln.kind === "daega" && ln.key === id;
const used = raw.filter((r) => {
const ref = r.ref as Row | undefined;
if (ref?.tab !== "dg") return false;
return (!goods && String(ref.key) === id) || holds(blocks, String(ref.key), hit);
});
openModal({
title: `어디 쓰였나 — 「${label}」`,
closeLabel: "닫기",
width: "40rem",
mount: (body, close) => {
body.append(
createText(`쓰는 내역 줄 ${used.length} · 누르면 그 줄로`, "muted"),
...used.map((r) =>
createListRow({
label: String(r.name || ""),
sub: [r.spec, r.qty != null ? `${r.qty} ${r.unit || ""}` : ""]
.filter(Boolean)
.join(" · "),
onClick: () => {
close();
go(String(r.key));
},
}),
),
);
},
});
}
/** 펼친 줄 우클릭 덧메뉴 — 복사 · 내 자료집 · 어디 쓰였나(+ 복사본 지우기) */
export function foldMenu(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
line: Row | undefined,
go: (key: string) => void,
): MapContextMenuItem[] {
return [
["복사해 새 일위대가", () => void copyBlock(ctx, raw, row, block)],
["내 자료집에 저장", () => void savePersonal(ctx, block)],
["어디 쓰였나", () => whereUsed(ctx, raw, block, line || {}, go)],
...(String(block.cover).startsWith("K:")
? [["이 복사본 지우기", () => removeCopy(ctx, raw, block)] as MapContextMenuItem]
: []),
];
}
+29 -64
View File
@@ -3,7 +3,8 @@
* B09 대가 탭 둘(PLAN 145-2) — 일위대가목록표 · 일위대가표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Labor`).
*
* 목록표 = 호표 한 줄(합계 · 노무비 · 재료비 · 경비) · 줄 누름 = 그 블록으로
* 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록)
* 일위대가표 = 공용 일위대가 격자(704-3 · `ui_template_hopyo_grid` · 머리 · [품셈 인용] · 줄 · 합계 · 줄 꼬리표) ·
* 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록)
* 줄 꼴(155-2) = 머리 · 합계 굵게(`is-title`) · 인용 · 줄 = 들여쓰기 「ㄴ」(2단 · 겹 대가는 안 펼침)
* 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」
* 빈 줄(166-6) = 합계 = 아는 줄 합 · 옆 「빈 줄 n」 · 풍선 = 빈 줄 이름 · 내역 단가엔 안 넘김
@@ -14,7 +15,6 @@ import type { Row } from "./B09_Estimation_Calc_Base";
import {
blockLevel,
money,
qty,
text,
type Link,
type TabBuild,
@@ -22,11 +22,10 @@ import {
type TabTable,
} from "./B09_Estimation_UI_Tabs";
import { raw } from "./B09_Estimation_UI_Tabs_Sangeun";
import { HOPYO_TAG, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid";
const EMPTY =
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)";
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : "");
@@ -78,82 +77,48 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => {
export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
const blocks: Row[] = sheets.daega_blocks || [];
if (!blocks.length) return EMPTY;
const rows: DataRow[] = [];
const links: Array<Link | null> = [];
const titles = new Set<number>();
for (const b of blocks) {
titles.add(rows.length);
rows.push({
// 704-3 — 공용 일위대가 격자(M02 카드 · 상세와 같은 열 · 줄 꼴) · 값 = 서버 `Calc_Labor` 그대로
const grid = hopyoGrid(
blocks.map((b) => ({
key: b.id,
name: `제 ${b.no} 호표 ${b.name}`,
no: b.no,
title: b.name, // 171-3 묶음 카드 머리
name: b.name,
spec: b.spec,
spec_raw: b.spec_raw,
unit: b.unit,
note: b.kind,
});
links.push(null);
if (b.cite) {
rows.push({ key: `${b.id}|cite`, name: `${CHILD}${b.cite}`, note: "품셈 인용" });
links.push(null);
}
b.lines.forEach((ln: Row, n: number) => {
rows.push({
key: `${b.id}|${n}`,
name: `${CHILD}${ln.name || ""}`,
kind: b.kind,
cite: b.cite,
total: { sum: b.sum, labor: b.labor, material: b.material, expense: b.expense },
blank: b.blank ?? 0,
blank_names: b.blank_names ?? [],
lines: b.lines.map((ln: Row) => ({
name: ln.name || "",
spec: ln.spec || "",
spec_raw: ln.spec_raw,
qty: ln.qty,
unit: ln.unit || "",
sum_price: ln.price,
sum: ln.amount,
labor_price: ln.prices.labor,
labor: ln.amounts.labor,
material_price: ln.prices.material,
material: ln.amounts.material,
expense_price: ln.prices.expense,
expense: ln.amounts.expense,
prices: { sum: ln.price, ...ln.prices },
amounts: { sum: ln.amount, ...ln.amounts },
note: ln.ref_no || ln.why || "",
});
const tab = LINE_TAB[ln.kind];
links.push(tab && ln.key ? { tab, key: String(ln.key) } : null);
});
titles.add(rows.length);
rows.push({
key: `${b.id}|합계`,
name: "합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
note: blankNote(b),
blank: b.blank,
blank_names: b.blank_names,
});
links.push(null);
}
const pair = (key: string, label: string) => [
money(`${key}_price`, "단가", [label], 1),
money(key, "금액", [label], 1),
];
tag: HOPYO_TAG.pum,
})),
})),
);
const { rows, titles } = grid;
const links: Array<Link | null> = rows.map((_, i) => {
const at = grid.at.get(i);
const ln: Row | undefined = at && blocks[at[0]].lines[at[1]];
const tab = ln && LINE_TAB[ln.kind];
return tab && ln.key ? { tab, key: String(ln.key) } : null;
});
return {
columns: [
text("name", "명칭", 240),
text("spec", "규격", 160),
qty("qty", "수량"),
text("unit", "단위", 56),
...pair("sum", "합계"),
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("note", "비고", 120),
],
columns: grid.columns,
rows,
frozen: 1,
hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`,
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
cellClass: (i: number, key: string) => hopyoCellClass(rows, i, key),
level: blockLevel("name", (i) => titles.has(i)),
tip: (i: number, key: string) =>
key === "spec"
@@ -10,6 +10,7 @@
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
blankRows,
money,
qty,
text,
@@ -20,17 +21,17 @@ import {
const HINT = "줄을 누르면 목록표로 이동";
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
const hint = (payload: Row, extra = ""): string => {
const n = (payload.summary || {}).unlinked || 0;
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`;
/** 안내 + 「빈 줄 n」(설계내역서와 같은 수 · 701-2 — 금액 모르는 줄은 집계에서 빠짐) */
const hint = (sheets: Row, extra = ""): string => {
const n = blankRows(sheets).length;
return `${HINT}${extra}${n ? ` · 빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)` : ""}`;
};
/** 줄이 없을 때 표 위 맨 앞 안내 */
const EXPENSE_NONE = "경비 자원 없음";
function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild {
return ({ payload }): TabTable => {
return ({ payload, sheets }): TabTable => {
const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r }));
const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0);
return {
@@ -47,7 +48,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab
rows,
total: rows.length ? { name: "계", amount: sum } : null,
frozen: 2,
hint: !rows.length && none ? `${none} · ${hint(payload)}` : hint(payload),
hint: !rows.length && none ? `${none} · ${hint(sheets)}` : hint(sheets),
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
};
};
@@ -57,7 +58,7 @@ export const buildSumMaterial = resourceTab("sum_material", "material", "단가"
export const buildSumLabor = resourceTab("sum_labor", "labor", "단가");
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가", EXPENSE_NONE);
export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
export const buildSumMachine: TabBuild = ({ payload, sheets }): TabTable => {
const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r }));
const add = (key: string) =>
rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0);
@@ -85,7 +86,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
}
: null,
frozen: 2,
hint: hint(payload),
hint: hint(sheets),
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
};
};
@@ -25,6 +25,7 @@ import {
type TabBuild,
type TabContext,
type TabTable,
unfinished,
} from "./B09_Estimation_UI_Tabs";
type Num = number | null;
@@ -251,6 +252,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["part"],
banner: unfinished(ctx.sheets) ?? undefined,
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
editable: (i, key) =>
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
@@ -336,6 +338,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["group", "part"],
banner: unfinished(ctx.sheets) ?? undefined,
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
+7 -4
View File
@@ -2,13 +2,13 @@
* B09_Draft_Print_UI.ts
* 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때
* 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`)
* · 단계 그대로 · 저장 실패면 머묾 ·
* · 단계 그대로 · 저장 실패면 머묾 · 빈 줄 있으면 막음(`DRAFT.blocked` · 701-3) ·
* [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2)
* draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐
* ========================================================================== */
import type { RoutePath } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { createButton, showToast } from "@ui/ui_template_elements";
import { navigateTo } from "../../A00_Common/router";
import { DRAFT } from "../B09_Estimation_UI_Tabs";
import { ROUTE } from "./B09_Draft_Print_Route";
@@ -19,10 +19,13 @@ DRAFT.actions.push(() => {
const button = createButton({
label: "인쇄 미리보기",
variant: "ghost",
onClick: () =>
onClick: () => {
const why = DRAFT.blocked?.(); // 701-3 인쇄 = 빈 줄 0 일 때만
if (why) return void showToast(why, "warning");
void (DRAFT.save?.() ?? Promise.resolve(true)).then(
(ok) => ok && navigateTo(ROUTE.path as RoutePath),
),
);
},
});
button.dataset.b09Print = "";
return button;
+4 -2
View File
@@ -1,7 +1,8 @@
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」).
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」) ·
복사 생성(대상 `복사이름`) = 새 이름 「원래 이름 복사」(701-4 · 층이 달라도 같은 이름 둘 안 생김).
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
"""
@@ -73,7 +74,8 @@ def paste(
if found is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
used = [*taken, *(p for _, p in plan)]
path = tree.unique_path([*dest, found[-1]], lambda p: p in used)
name = f"{found[-1]} 복사" if one.get("복사이름") else found[-1]
path = tree.unique_path([*dest, name], lambda p: p in used)
renamed += [path[-1]] if path[-1] != found[-1] else []
plan.append((key, path))
continue
+350
View File
@@ -0,0 +1,350 @@
"""M02 기준 목록 · 계산서 보기(PLAN 704-1 · 704-2 · 요구서 4장 8) — 로직 고르기 창이 읽음.
기준 목록 = 단가산출 로직 하나를 대표 규격으로 펼친 줄(품명 · 규격 · 단위 · 금액 · 출처 · 공종).
대표 규격 = 고르기 입력 값 전부 · 표 찾기 조건으로 쓰이는 수 입력은 그 표 줄마다 값 하나(범위 줄은
위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균).
펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 ·
바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌).
금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판).
목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본
(storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 ·
셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸).
실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음.
계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값).
M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인).
"""
from __future__ import annotations
import hashlib
import os
import sys
import threading
from concurrent.futures import ProcessPoolExecutor
from decimal import Decimal
from typing import Any
from common_util.common_util_derived_store import derived_key, read_derived, write_derived
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Template_Layers as layers
SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀
KIND = "m02_logic_standard"
REGION = "지역" # 지역 = 자동값 한 벌(정함 8)
QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값
#: 실무 낱말 → 로직 이름에 든 품셈 낱말(띄어쓰기 무시) — 맞으면 별칭으로 붙음
WORDS: dict[str, tuple[str, ...]] = {
"석축": ("돌쌓기", "돌붙임", "찰쌓기", "메쌓기"),
"기슭막이": ("돌쌓기", "돌붙임"),
"골막이": ("돌쌓기",),
"흄관": ("원심력",),
"배수관": ("흄관", "파형강관", "원심력", "관부설"),
"땅파기": ("터파기", "굴착"),
"흙메우기": ("되메",),
"콘크리트치기": ("타설",),
"레미콘": ("타설",),
"비탈녹화": ("녹화", "파종", "식생"),
}
#: 공종 — 차례 = 거름 차례(임도 · 사방 자주 쓰는 것 먼저) · (이름 낱말, 장 낱말) · 이름이 먼저
KINDS: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = (
("돌공", ("돌쌓기", "돌붙임", "석축", "찰쌓기", "메쌓기", "전석", "사석", "돌망태", "개비온"),
("돌공사",)),
("관", ("흄관", "강관", "파형", "관부설", "배수관", "PE관", "관접합"), ("관부설",)),
("콘크리트", ("콘크리트", "거푸집", "철근", "타설", "레미콘", "모르타르"), ("철근콘크리트",)),
("토공", ("터파기", "되메", "흙", "성토", "절토", "굴착", "땅깎기", "사토", "다짐", "잔토"),
("토공",)),
("운반", ("운반", "집재", "운재"), ("운반", "집재")),
("식재 · 녹화", ("식재", "녹화", "파종", "식생", "묘목"), ("식재", "생육")),
("벌채", ("벌채", "벌목"), ("나무베기",)),
("가설", (), ("가설",)),
) # fmt: skip
OTHER = "기타"
WORKERS = max(1, min(4, (os.cpu_count() or 2) - 1))
_LOCK = threading.Lock()
_MEMO: dict[str, Any] = {} # whole(색인) → 열쇠 · 목록 · 셈 중
def _sq(text: Any) -> str:
return "".join(str(text or "").split()).lower()
def kind_of(row: dict) -> str:
name, chapter = _sq(row.get("이름")), _sq(row.get("상세구분"))
for kind, words, _ in KINDS:
if any(_sq(w) in name for w in words):
return kind
for kind, _, chapters in KINDS:
if any(_sq(w) in chapter for w in chapters):
return kind
return OTHER
def words_of(row: dict) -> list[str]:
name = _sq(row.get("이름"))
return [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)]
def _range_label(table: dict, low: Any, high: Any, unit: str) -> str:
f = m01.mtx.fmt
if low is None:
return f"{f(high)}{unit} 이하"
if high is None:
rule = str(table.get("범위규칙") or "초과")
return f"{f(low)}{unit} {'이상' if rule.startswith('이상') else '초과'}"
return f"{f(low)}~{f(high)}{unit}"
def _table_reps(whole: Any, ref: str, cond: str, unit: str) -> list[tuple[Any, str]]:
"""표 `ref` 조건 칸 `cond` 줄마다 (값, 글) — 범위 줄은 위 끝(없으면 아래 끝 · 드는 값)."""
try:
table = whole.get(ref)
except m01.cm.mf.FormulaError:
return []
ranged = table.get("조건", {}).get(cond) == "범위"
out: dict[Any, str] = {}
for r in table.get("줄", []):
have = r.get(cond)
if have is None:
continue
if not ranged:
if isinstance(have, Decimal):
out.setdefault(have, f"{m01.mtx.fmt(have)}{unit}")
continue
low, high = have
tries = [v for v in (high, low, (low or 0) + 1) if isinstance(v, Decimal)]
got = next((v for v in tries if m01.cm.mf._match(table, r, cond, v)), None)
if got is not None:
out.setdefault(got, _range_label(table, low, high, unit))
return sorted(out.items())
def axes(whole: Any, logic: dict) -> tuple[dict, list[tuple[str, list[tuple[Any, str]]]]]:
"""(바탕 입력, [(입력 이름, [(값, 글) …]) …]) — 규격 축 = 지역 뺀 고르기 · 표 찾기 수 입력."""
base = m01.mau.values(whole, logic)
finds, _ = m01.mau._uses(logic)
out = []
for spec in logic.get("입력") or []:
name = spec["이름"]
if name == REGION:
continue
unit = str(spec.get("단위") or "")
if spec.get("고르기"):
labels = labor_inputs._labels(whole, spec)
reps = [(v, str(labels.get(v, m01.mtx.fmt(v) if isinstance(v, Decimal) else v)))
for v in spec["고르기"]] # fmt: skip
elif name in finds:
seen: dict[Any, str] = {}
for ref, cond in finds[name]:
for v, text in _table_reps(whole, ref, cond, unit):
seen.setdefault(v, text)
reps = sorted(seen.items())
else:
continue
if not reps:
continue
if base.get(name) not in [v for v, _ in reps]:
base[name] = reps[0][0]
out.append((name, reps))
return base, out
def _spec(given: dict, ax: list) -> str:
parts = []
for name, reps in ax:
text = dict(reps).get(given.get(name), "")
if text and text not in QUIET:
parts.append(text if text.startswith(name) else f"{name} {text}")
return " · ".join(parts)
def _unit(logic: dict) -> str:
unit = str(logic.get("결과단위") or "")
return unit[2:] if unit.startswith("원/") else ("" if unit == "원" else unit)
def _money(whole: Any, logic: dict, given: dict) -> Decimal | None:
if not str(logic.get("결과단위") or "").startswith("원"):
return None
try:
got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given))
except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return None
return got.get("계", got.get("결과"))
def _rows_of(whole: Any, logic: dict, blocked: bool) -> list[dict]:
base, ax = axes(whole, logic)
changes: list[dict] = [{}]
for name, reps in ax:
changes += [{name: v} for v, _ in reps if v != base.get(name)]
head = {
"키": logic.get("키"),
"이름": logic.get("이름") or "",
"단위": _unit(logic),
"출처": logic.get("출처") or "",
"공종": kind_of(logic),
"별칭": ", ".join(x for x in [logic.get("별칭") or "", *words_of(logic)] if x),
"구분": logic.get("구분") or "",
"상세구분": logic.get("상세구분") or "",
"막힘": blocked,
}
out = []
for change in changes:
given = {**base, **change}
money = _money(whole, logic, given)
if change and money == 0: # 표에 없는 규격 조합(견치돌 · 뒷길이 25 따위) — 0 원 줄은 뺌
continue
out.append(
{
**head,
"규격": _spec(given, ax),
"금액": None if money is None else round(float(money), 2),
"바꿈": labor_inputs._num(change),
}
)
return out
def edition(files: dict) -> str:
"""노임 판 글 — 「2026 상반기 노임」(인력 머리 첫 판) · 없으면 빈 글."""
for head in ((files.get("인력.json") or {}).get("구분") or {}).values():
day = str(head.get("판") or "")
if len(day) >= 7:
return f"{day[:4]} {'상반기' if int(day[5:7]) < 7 else '하반기'} 노임"
return ""
def _stamp() -> str:
"""마스터 파일 내용 지문 — 워크트리마다 시각이 달라도 같은 내용이면 같은 열쇠."""
sha = hashlib.sha1()
for path in sorted(m01.FOLDER.glob("*.json")):
if not path.name.startswith("_"):
sha.update(path.name.encode())
sha.update(hashlib.sha1(path.read_bytes()).digest())
return sha.hexdigest()
def _chunk(keys: list[str], blocked: set[str]) -> list[dict]:
"""일꾼 프로세스 몫 — 로직 키들의 줄(마스터는 그 프로세스가 읽음)."""
whole = m01.loaded()[1]
out = []
for key in keys:
out += _rows_of(whole, whole.logic(key), key in blocked)
return out
def build(files: dict, workers: int = 1) -> dict:
"""기준 목록 통째 — 장 차례 · `workers` > 1 이면 로직을 나눠 프로세스 여럿이 셈(차례 그대로)."""
why_of = m01.blocks()
blocked = {k for k, why in why_of.items() if why}
keys = [
str(logic.get("키"))
for _, data in sorted(m01._logic_files(files), key=lambda f: f[1].get("차례") or 0)
for logic in data.get("줄") or []
]
size = max(1, len(keys) // (workers * 8))
parts = [keys[i : i + size] for i in range(0, len(keys), size)]
if workers > 1:
with ProcessPoolExecutor(workers) as pool:
got = list(pool.map(_chunk, parts, [blocked] * len(parts)))
else:
got = [_chunk(part, blocked) for part in parts]
kinds = [k for k, _, _ in KINDS] + [OTHER]
return {"판": edition(files), "공종": kinds, "줄": [row for part in got for row in part]}
def _make(files: dict, whole: Any, key: str) -> None:
try:
value = build(files, WORKERS)
write_derived(layers.storage_root(), KIND, key, value)
with _LOCK:
if _MEMO.get("whole") is whole:
_MEMO.update(value=value)
except Exception as e: # noqa: BLE001 — 다음 요청이 다시 셈
print(f"[M02 기준 목록] 셈 실패: {e}", file=sys.stderr)
finally:
with _LOCK:
if _MEMO.get("whole") is whole:
_MEMO["job"] = None
def listing() -> dict:
"""기준 목록 `{판, 공종, 줄, 준비}` — 메모리 → 파생 저장본 → 뒤에서 셈(그동안 빈 줄)."""
files, whole = m01.loaded()
with _LOCK:
if _MEMO.get("whole") is not whole:
_MEMO.clear()
_MEMO.update(whole=whole, key=derived_key(KIND, SHAPE, _stamp()), value=None, job=None)
if _MEMO["value"] is None and _MEMO["job"] is None:
got = read_derived(layers.storage_root(), KIND, _MEMO["key"])
if got:
_MEMO["value"] = got["value"]
else:
_MEMO["job"] = threading.Thread(
target=_make, args=(files, whole, _MEMO["key"]), daemon=True
)
_MEMO["job"].start()
if _MEMO["value"] is not None:
return {**_MEMO["value"], "준비": True}
kinds = [k for k, _, _ in KINDS] + [OTHER]
return {"판": edition(files), "공종": kinds, "줄": [], "준비": False}
def warm() -> None:
"""서버가 뜰 때 뒤에서 — 저장본이 있으면 읽기만 · 없으면 셈 시작."""
threading.Thread(target=lambda: _quiet(listing), daemon=True).start()
def _quiet(fn) -> None:
try:
fn()
except Exception as e: # noqa: BLE001 — 데움 실패는 첫 요청이 다시 셈
print(f"[M02 기준 목록] 데움 실패: {e}", file=sys.stderr)
def _given(whole: Any, logic: dict, change: dict) -> tuple[dict, list]:
base, ax = axes(whole, logic)
known = {name: dict(reps) for name, reps in ax}
for name, value in (change or {}).items():
if name not in known:
raise m01.StoreError(400, f"규격 축이 아닌 입력 「{name}」")
want = Decimal(str(value)) if isinstance(value, (int, float)) else value
if want not in known[name]:
raise m01.StoreError(400, f"「{name}」 대표 값이 아님: {value}")
base[name] = want
return base, ax
def sheet(key: str, change: dict) -> dict:
"""계산서 한 장 — 머리(품명 · 규격 · 단위 · 출처 · 판) + 비목 묶음 줄 · 소계 · 계(엔진 값)."""
files, whole = m01.loaded()
try:
logic = whole.logic(key)
except m01.cm.mf.FormulaError as e:
raise m01.StoreError(404, str(e)) from e
given, ax = _given(whole, logic, change)
got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given))
groups = [
{
"비목": g["비목"],
"소계": g.get("소계"),
"끝수": g.get("끝수") or "",
"줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]],
}
for g in got.get("groups") or []
]
return labor_inputs._num(
{
"키": key,
"이름": logic.get("이름") or "",
"규격": _spec(given, ax),
"단위": _unit(logic),
"출처": labor_inputs.sources(logic) or "",
"판": edition(files),
"입력": given,
"묶음": groups,
"계": got.get("계"),
}
)
@@ -467,6 +467,8 @@ export const editTree = (
export interface TreeRef {
경로: string[];
키?: string;
/** 복사 생성(701-4) — 새 이름 = 「원래 이름 복사」(같은 이름 둘 안 생김) */
복사이름?: boolean;
}
/** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */
@@ -85,6 +85,13 @@
border-top: 2px solid var(--color-border);
}
/* 701-4 수량 안 넣은 줄 경고 — 합계 아래 제 줄 · 좁은 카드에서도 줄바꿈 */
.m02-labrow__name > .m02-labrow__noqty {
display: block;
max-width: 9rem;
white-space: normal;
}
.m02-labrow__move {
white-space: nowrap;
}
@@ -481,3 +488,30 @@
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* 기준 목록(704-1) — 찾기 · 거름 위 · 기준 목록 표 아래(표 높이 = 창 몸에 맞춤) */
.m02-std-pick {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
}
.m02-std-pick .ui-find__list {
max-height: 10rem;
}
/* 계산서 보기(704-2) — 계산서 | 원문 띠 */
.m02-std-sheet {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--spacing-16);
min-height: 0;
}
.m02-std-sheet__body {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
@@ -115,6 +115,51 @@ export const fetchLibLogics = (): Promise<LogicSummary[]> =>
export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs);
/* --- 기준 목록 · 계산서 보기(704 · 서버 M01 엔진이 셈 · `M02_Logic_Standard`) --- */
/** 기준 목록 한 줄 — 로직 하나를 대표 규격 하나로 펼친 줄 · `바꿈` = 바탕에서 바꾼 입력 */
export interface StandardRow {
키: string;
이름: string;
규격: string;
단위: string;
금액: number | null;
출처: string;
공종: string;
별칭: string;
구분: string;
상세구분: string;
막힘: boolean;
바꿈: Record<string, string | number>;
}
export interface StandardList {
판: string;
공종: string[];
줄: StandardRow[];
}
export interface StandardSheet {
이름: string;
규격: string;
단위: string;
출처: string;
판: string;
묶음: {
비목: string;
소계: number | null;
끝수: string;
줄: { 이름: string; 글: string; 금액: number | null }[];
}[];
계: number | null;
}
let standard: Promise<StandardList> | null = null; // 페이지에 한 벌(서버 저장본 · 판 바뀌면 새로 고침)
export const fetchStandard = (): Promise<StandardList> =>
(standard ??= request<StandardList>("/logic-lib/standard").catch((error) => {
standard = null;
throw error;
}));
export const fetchStandardSheet = (row: StandardRow): Promise<StandardSheet> =>
request("/logic-lib/sheet", "POST", { key: row.키, 바꿈: row.바꿈 });
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
@@ -13,6 +13,7 @@
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만
* ========================================================================== */
import {
@@ -64,6 +65,7 @@ import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { laborGrid } from "./M02_MasterTemplete_Labor_Grid";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
@@ -135,6 +137,7 @@ export function mountLaborDetail(
let pills = new Map<string, { 이름?: string; 단위?: string }>();
const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved;
const gridHost = el("div", { attrs: { "data-labor-grid": "" } }); // 704-3 엑셀 꼴 격자(맨 위)
const logicHost = el("div");
const matHost = el("div");
const dgHost = el("div");
@@ -215,6 +218,7 @@ export function mountLaborDetail(
),
);
const draw = (): void => {
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics));
drawLogic();
matHost.replaceChildren(
matTable(
@@ -642,8 +646,11 @@ export function mountLaborDetail(
}),
]),
// 사전조합 = 같은 [로직 추가](51-11)
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
section(tc("Grid_Title"), null, gridHost),
...[
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만
...(opt.card || lib ? [] : [dgBox]),
],
}),
@@ -0,0 +1,95 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Grid.ts
* 일위대가 엑셀 꼴 격자(704-3) — 문서 · 서버 calc 답 → 공용 격자(`ui_template_hopyo_grid` · B09 일위대가표와 같은 꼴).
* 일위대가 카드 · 상세(`Labor_Detail`)가 위에 붙임. 값은 calc 답 그대로(여기서 셈하지 않음):
* 머리 = 일위대가번 · 단위 · 1 단위 합계(`반영합`)
* 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈
* 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
* 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
* ========================================================================== */
import {
createHopyoGrid,
HOPYO_TAG,
type HopyoBlock,
type HopyoLine,
type HopyoMoney,
} from "@ui/ui_template_hopyo_grid";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { valueLine } from "./M02_Labor_Values_Model";
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
m
? {
sum: m.계 / times,
labor: m.노무비 / times,
material: m.재료비 / times,
expense: m.경비 / times,
}
: {};
/** 문서 + calc 답 → 호표 하나 — calc 없으면(계산 중) 금액 빈 칸 · 빈 줄 0 */
export function laborBlock(
doc: LaborDoc,
calc: LaborCalc | null,
logics: ReadonlyMap<string, ItemLogic>,
): HopyoBlock {
const logicLines = doc.담은로직.map((row): HopyoLine => {
const id = lineId(row) ?? "";
const line = calc?.줄.find((l) => lineId(l) === id);
const logic = logics.get(row.로직);
const layer =
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
const qty = line?.수량 ?? null;
return {
name: row.별칭 || ((row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직),
spec: [valueLine(logic?.입력 ?? [], row.입력값).text, layer].filter(Boolean).join(" · "),
qty,
unit: line?.분모 ?? (row.참조 ? "" : (logic?.결과단위 ?? "")),
prices: money(line?.기준, line?.배 || 1),
amounts: money(line?.반영),
note: line?.까닭 ?? (calc && qty === null ? tc("Grid_NoQty") : (row.참조?.키 ?? row.로직)),
tag: HOPYO_TAG.pum,
};
});
const matLines = doc.자재.map((mat): HopyoLine => {
const line = calc?.자재.find((m) => m.코드 === mat.코드);
const qty = line?.수량 ?? null;
const price = line?.기준단가 ?? null;
const cost = line?.반영 ?? null;
return {
name: mat.이름 ?? mat.코드,
spec: mat.규격 ?? "",
qty,
unit: mat.단위 ?? "",
prices: { sum: price, material: price },
amounts: { sum: cost, material: cost },
note:
line?.까닭 ??
(calc && qty === null
? tc("Grid_NoQty")
: mat.별도계상 !== "직접계산"
? tc("Grid_Apart") // 짝 `settle` 의 aside 와 같은 규칙
: mat.코드),
};
});
const lines = [...logicLines, ...matLines];
return {
key: doc.열,
name: doc.일위대가번 || doc.열,
unit: doc.단위 ?? "",
total: money(calc?.반영합),
lines,
...(calc ? {} : { blank: 0 }),
};
}
/** 격자 하나(읽기 전용) */
export const laborGrid = (
doc: LaborDoc,
calc: LaborCalc | null,
logics: ReadonlyMap<string, ItemLogic>,
): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root;
@@ -6,9 +6,13 @@
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more`
* (324-4 실무 호표 · `withHopyo`)
* 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 ·
* 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 ·
* 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`)
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { createDataTable } from "@ui/ui_template_data_table";
import { createFindList } from "@ui/ui_template_find_list";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { t as L } from "@ui/ui_template_locale";
@@ -19,8 +23,11 @@ import {
fetchLibLogics as fetchLogics,
fetchLibLogicSubs as fetchLogicSubs,
fetchLibrary,
fetchStandard,
type LibraryRow,
type StandardRow,
} from "./M02_MasterTemplete_Labor_Api";
import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { ownOwner } from "./M02_MasterTemplete_Layer";
import { tc } from "./M02_MasterTemplete_Labor_Text";
@@ -29,6 +36,7 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
@@ -90,6 +98,9 @@ export function openLogicPicker(
const subs: SubBrief[] = [];
let classes: ReturnType<typeof precomboClasses> = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만
let all: LogicSummary[] = [];
let std: StandardRow[] | null = null; // 기준 목록(서버 셈) — 오기 전 = 로직 줄 그대로
let listed: StandardRow[] = [];
const work = createSelectField({ options: opts([]), compact: true, label: tc("Std_Kind") });
const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") });
const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") });
detail.select.disabled = true;
@@ -115,19 +126,56 @@ export function openLogicPicker(
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
const finder = createFindList({
placeholder: tc("Pick_Search"),
filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root],
filters: [...(kindSel ? [kindSel.root] : []), work.root, sub.root, detail.root],
extra: [layerBar],
});
const table = createDataTable({
caption: tc("Pick_Title"),
columns: [
{ key: "품명", label: tc("Std_Name"), width: 220 },
{ key: "규격", label: tc("Std_Spec"), width: 220 },
{ key: "단위", label: tc("Std_Unit"), width: 56 },
{ key: "금액", label: tc("Std_Money"), kind: "number", width: 96 },
{ key: "출처", label: tc("Std_Source"), width: 150 },
],
rows: [],
onRowClick: (i) => {
const row = listed[i];
const logic = all.find((x) => x.키 === row?.키);
if (row && logic)
openStandardSheet(row, () => {
close();
done(logic);
});
},
});
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
const plain = (x: LogicSummary): StandardRow => ({
키: x.키,
이름: x.이름,
규격: "",
단위: x.결과단위.replace(/^원\/?/, ""),
금액: null,
출처: x.출처 ?? "",
공종: "",
별칭: x.별칭 ?? "",
구분: x.구분,
상세구분: x.상세구분,
막힘: x.blocked,
바꿈: {},
});
if (pre?.query) finder.search.value = pre.query;
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const found = all.filter(
const rows = std ?? all.map(plain);
const found = rows.filter(
(x) =>
kindOk("logic") &&
(!work.select.value || x.공종 === work.select.value) &&
(!sub.select.value || x.구분 === sub.select.value) &&
(!detail.select.value || x.상세구분 === detail.select.value) &&
matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1)
matchLogic(finder.search.value, { ...x, 이름: `${x.이름} ${x.규격}` }).ok, // 공용 · 208-1
);
const libFound = libRows.filter(
(x) =>
@@ -142,27 +190,25 @@ export function openLogicPicker(
!sub.select.value &&
(!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)),
);
const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length;
const total = rows.length + libRows.length + (lib?.extra?.length ?? 0) + later.length;
const shown = found.length + libFound.length + extra.length;
finder.hint.textContent = loading
? tc("Loading")
: shown
? `${shown} / ${total}`
: tc("Pick_None");
finder.setRows([
...found.slice(0, 100).map((x) => ({
key: x.키,
tag: tc("Kind_Logic"),
name: x.이름,
sub: [`${x.키} · ${x.구분} ${x.상세구분}`.trim(), aliasNote(matchLogic(q, x).alias)]
.filter(Boolean)
.join(" · "),
unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
onPick: () => {
close();
done(x);
},
listed = found;
table.root.hidden = !!lib?.libOnly;
table.setRows(
found.map((x) => ({
품명: [x.이름, aliasNote(matchLogic(q, x).alias)].filter(Boolean).join(" · "),
규격: x.규격,
단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`,
금액: x.금액,
출처: x.출처,
})),
);
finder.setRows([
...libFound.map((x) => {
const at = scope as Scope;
return {
@@ -246,6 +292,7 @@ export function openLogicPicker(
find();
});
detail.select.addEventListener("change", find);
work.select.addEventListener("change", find);
kindSel?.select.addEventListener("change", find);
finder.search.addEventListener("input", find);
@@ -272,8 +319,15 @@ export function openLogicPicker(
title: lib?.title ?? tc("Pick_Title"),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 기준 목록 표 다섯 칸(704-1)
backdropClass: "m01-logic__backdrop",
mount: (body) => body.append(finder.root),
mount: (body) =>
body.append(
el("div", {
className: "m02-std-pick",
children: [finder.root, createText(tc("Std_Hint")), table.root],
}),
),
}));
finder.search.focus();
@@ -288,6 +342,11 @@ export function openLogicPicker(
all = l;
drawSubs();
find();
return fetchStandard().then((got) => {
std = got.줄.length ? got.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
work.setOptions(opts(got.공종), "");
find();
});
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
@@ -493,6 +493,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
// 701-4 수량산출 빈 줄 = 반영 금액 없음 → 합계에서 빠짐 — 조용히 빠지지 않게 합계 줄에 경고
const noQty = data.calc
? data.rows.filter((row) => {
const line = lineOf(lineId(row) ?? "");
return !!line && (line.수량 === null || line.수량 === undefined);
}).length
: 0;
const foot = data.rows.length
? [
el("tr", {
@@ -500,7 +507,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", { className: "m02-labrow__kind", text: "" }),
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", {
className: "m02-labrow__name",
children: [
el("span", { text: tc("Prev_Sum") }),
...(noQty
? [
el("small", {
className: "ui-text ui-text--error m02-labrow__noqty",
text: tc("Labor_NoQty").replace("{n}", String(noQty)),
attrs: { "data-labor-noqty": String(noQty) },
}),
]
: []),
],
}),
el("td", { className: "m02-labrow__alias", text: "" }),
el("td", { className: "m02-labrow__qty", text: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
@@ -0,0 +1,76 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Sheet.ts
* 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다
* 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 ·
* [담기] = 부르는 쪽 담기 동작 그대로(`put`).
* ========================================================================== */
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 { 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")));
const source = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
let close = (): void => {};
const add = createButton({
label: tc("Std_Put"),
variant: "filled",
onClick: () => {
close();
put();
},
});
({ close } = openModal({
title: `${tc("Std_Sheet")} — ${row.이름}`,
closeLabel: tc("Pick_Close"),
width: "1100px",
wide: true,
actions: [add],
mount: (body) =>
body.append(el("div", { className: "m02-std-sheet", children: [sheet, source.root] })),
}));
void fetchStandardSheet(row).then(
(got) => {
const head = [got.이름, got.규격, got.단위].filter(Boolean).join(" · ");
const lines: HTMLElement[] = [
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)),
);
sheet.replaceChildren(...lines);
source.show(got.출처);
},
(error) => {
sheet.replaceChildren(createText(tc("Load_Failed"), "error"));
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
},
);
}
@@ -57,6 +57,17 @@ const TEXT = {
Pick_None: ["찾은 로직 없음", "No logics found"],
Pick_Close: ["닫기", "Close"],
Pick_Blocked: ["막힘", "Blocked"],
// 기준 목록 · 계산서 보기(704)
Std_Name: ["품명", "Item"],
Std_Spec: ["규격", "Spec"],
Std_Unit: ["단위", "Unit"],
Std_Money: ["금액", "Amount"],
Std_Source: ["출처", "Source"],
Std_Kind: ["공종", "Work type"],
Std_Sheet: ["계산서", "Cost sheet"],
Std_Put: ["담기", "Add"],
Std_Sum: ["계", "Total"],
Std_Hint: ["줄을 누르면 계산서 · [담기]", "Click a row for its sheet · [Add]"],
Prev_Labor: ["노무비", "Labor"],
Prev_Material: ["재료비", "Material"],
Prev_Expense: ["경비", "Expense"],
@@ -96,6 +107,10 @@ const TEXT = {
Lib_Personal: ["개인", "Personal"],
Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"],
Labor_Empty: ["빈 값 {n}", "{n} empty"],
Labor_NoQty: [
"수량 안 넣은 줄 {n} — 합계에서 빠짐",
"{n} rows without quantity — left out of the sum",
],
Labor_Lost: [
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
"Inputs no longer in the logic (dropped on save) — {value}",
@@ -158,6 +173,10 @@ const TEXT = {
Lib_Base: ["기준 금액", "Base amount"],
Lib_Date: ["고친 날", "Modified"],
// 서고 구분 · 연결(51-12)
// 엑셀 꼴 격자(704-3)
Grid_Title: ["일위대가표", "Unit-price sheet"],
Grid_NoQty: ["수량 없음", "No quantity"],
Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -9,12 +9,17 @@
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
704 — 기준 목록(`/logic-lib/standard`) · 계산서(`/logic-lib/sheet`) 로그인만(`M02_Logic_Standard`).
"""
from __future__ import annotations
import sys
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_http_cache import cached_file_response
@@ -22,6 +27,7 @@ from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Make as make
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_Logic_Standard as standard
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
@@ -36,6 +42,9 @@ router = APIRouter(
prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)]
)
if "pytest" not in sys.modules: # 기준 목록을 뒤에서 한 번 셈(저장본이 있으면 읽기만)
standard.warm()
def _where(key: str) -> tuple[str, str | None]:
got = combos.where(key)
@@ -107,6 +116,23 @@ def get_logic_inputs(keys: str = "") -> dict:
return _read(labor_inputs.logic_inputs, keys.split(","))
@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)])
def get_standard() -> dict:
"""기준 목록 `{판, 공종, 줄}` — 대표 규격 펼침 · 금액 = M01 엔진(704-1)."""
return _read(standard.listing)
class SheetBody(BaseModel):
key: str
바꿈: dict[str, Any] = {}
@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)])
def post_sheet(body: SheetBody) -> dict:
"""계산서 한 장 — 노임 × 품 = 금액 줄 · 비목 소계 · 계(704-2 · 저장 안 함)."""
return _read(standard.sheet, body.key, body.바꿈)
#: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음)
SOURCE_PDF = {
"건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf",
@@ -673,7 +673,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const run = api.paste;
if (!run || !opt.canEdit || !(await ready())) return;
await apply(() =>
run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest),
run(
opt.layer,
owner,
{ kind: clipKind, from, refs: [{ 경로: [], 키: key, 복사이름: true }] }, // 701-4 「… 복사」
dest,
),
);
};
@@ -235,6 +235,8 @@ async function b09(withNext) {
return { ok: true, status: 200, json: async () => any() };
};
const tabs = any();
let unfinished = null; // 701-3 빈 줄 띠 글 — 있으면 [>] 막힘
tabs.unfinished = () => unfinished;
tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] };
if (withNext) tabs.DRAFT.next = () => calls.push("next");
const nextBtn = Object.assign(new Node("button"), { disabled: true });
@@ -290,6 +292,9 @@ async function b09(withNext) {
out.completeFail = await nav(1);
failOn = null;
out.prev = await nav(-1);
unfinished = "빈 줄 2 · 미완성";
out.blocked = await nav(1);
unfinished = null;
// [초기화]
const reset = dock.children[0];
const press = async () => {
@@ -14,10 +14,12 @@ IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
COMMON = "ui_template/ui_template_group_cards.ts"
TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts"
GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts"
GRID = "ui_template/ui_template_hopyo_grid.ts" # 704-3 일위대가표 격자(Tabs_Labor 앞)
# 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝
STUBS = r"""
const kept = new Map();
const dateTimeText = (iso) => String(iso).replace("T", " "); // 593-1 PC 시간대 글 — 견본은 시간대 없음
const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null);
const writeState = (name, value) => kept.set(name, JSON.stringify(value));
const createButton = (o) => {
+4 -7
View File
@@ -125,15 +125,12 @@ def test_제목_합_빈_줄과_원가계산서_이음():
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
assert sheets["bill"]["sums"] == {
"material": None,
"labor": None,
"expense": None,
"supply_net": 69474220,
}
# 145-4 이음 = {material, labor, expense, supply_net, blank} · 701-3 빈 줄 있어도 아는 금액까지
known = {"material": 2771766, "labor": 7565300, "expense": 2627525, "supply_net": 69474220}
assert sheets["bill"]["sums"] == {**known, "blank": 2} # 절토(연암) · 막자갈(단가 없음)
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
sums = compute_estimation(full, USER)["bill"]["sums"]
assert sums == {**known, "blank": 0}
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
assert sums["expense"] == 2627525
+3 -2
View File
@@ -35,10 +35,11 @@ BLOCK = {
def _node(js_body, tab="UI_Tabs_Labor"):
src = "".join(
grid = IMPORTS.sub("", (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text("utf-8"))
src = grid + "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("UI_Tabs", tab)
)
) # 704-3 일위대가표 = 공용 격자
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + js_body],
@@ -57,9 +57,14 @@ def test_규격_칸_풍선():
"labor": 1, "material": 0, "expense": 0, "sum": 1}],
} # fmt: skip
sangeun = (B09 / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
src = re.search(r"^export const raw =.*?;$", sangeun, re.MULTILINE | re.DOTALL)[0] + "".join(
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("UI_Tabs", "UI_Tabs_Labor")
grid = IMPORTS.sub("", (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text("utf-8"))
src = (
grid
+ re.search(r"^export const raw =.*?;$", sangeun, re.MULTILINE | re.DOTALL)[0]
+ "".join(
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("UI_Tabs", "UI_Tabs_Labor")
)
) # 산근 탭의 풍선 함수 `raw` 만(같은 이름 상수가 겹쳐 파일째는 못 잇음)
body = f"""
const sheets = {json.dumps(sheets, ensure_ascii=False)};
@@ -131,7 +131,10 @@ def test_B08_B09_적용():
mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8")
assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat
labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor
grid = (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text(
"utf-8"
) # 704-3 줄 글 = 공용 격자
assert 'HOPYO_CHILD = "ㄴ "' in grid and '" 합 계"' not in labor + grid
# 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림)
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert ".cellIndex" not in shell and shell.count("cellColumn(") >= 2
@@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
from resources.tester.helper_b09_group_cards import COMMON, GRID, GROUPS, RAW, TABS, run # noqa: E402
B09 = "B09_Estimation/B09_Estimation_UI_"
BAR = f"{B09}TabBar.ts"
@@ -74,7 +74,7 @@ console.log(JSON.stringify({
def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
"""쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음."""
out = run(
[COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
[COMMON, TABS, GRID, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
"\nconst bars = createTabBars(() => {});"
"\nconst hint = document.createElement('p');"
@@ -106,7 +106,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert (
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
"panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
+39 -16
View File
@@ -2,7 +2,9 @@
- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버)
- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending)
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌
- 701-2 첫 화면 = 설계내역서 · 목록 머리 「빈 줄 n」 = 설계내역서 금액 모르는 줄(줄마다 하나 · 까닭 모음) ·
그 밖(호표 · 재료) = 「그 밖 확인 n곳」(탭 · 키마다 하나)
"""
import json
@@ -31,15 +33,15 @@ def test_묶음_이름_단추_글_설명_없음():
assert "check.draw(payload, sheets);" in SHELL
def test_연결_점검_탭_없음_첫_탭_공사원가계산서():
def test_연결_점검_탭_없음_첫_화면_설계내역서():
out = run(
[TABS],
"\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n),"
" groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));",
)
assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"]
assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서"
assert "let current: TabId = TABS[0][0];" in SHELL
assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" # 엑셀 탭 차례 그대로
assert 'let current: TabId = "bill";' in SHELL
assert "buildCheck" not in SHELL and "check:" not in SHELL
@@ -50,43 +52,64 @@ def test_좌측_목록_이동_호버_배지():
const moves = [];
const refresh = document.createElement("button");
const panel = createCheckPanel(refresh, (link) => moves.push(link));
const sheets = { gaps: [
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
{ tab: "material", key: "MT9", name: "모래", why: "단가 없음" },
] };
const sheets = {
bill: { rows: [
{ kind: "title", cost: "direct", key: "sec:1", name: "토공사", sum: null },
{ kind: "item", cost: "direct", key: "earth:3", name: "제근", sum: null },
{ kind: "item", cost: "direct", key: "earth:4", name: "절토", sum: 100 },
{ kind: "item", cost: "direct", key: "st:w", name: "옹벽", sum: null },
{ kind: "item", cost: "owner", key: "mat:1", name: "레미콘", sum: null },
] },
gaps: [
{ tab: "bill", key: "earth:3", name: "제근", why: "수량 연결 없음" },
{ tab: "bill", key: "earth:3", name: "제근", why: "준비공 대가 — 연결 없음" },
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
{ tab: "material", key: "MT9", name: "모래", why: "단가 없음" },
],
};
const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2",
notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } };
panel.draw(payload, sheets);
const view = () => ({
pending: refresh.classList.contains("is-pending"),
texts: panel.list.children.map((c) => c.text),
rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]),
rows: [1, 3].flatMap((n) =>
(panel.list.children[n]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text])),
});
const a = view();
const rowsBox = panel.list.children[1];
const blankBox = panel.list.children[1];
const rowsBox = panel.list.children[3];
rowsBox.children[1].click();
const tipRefresh = hoverCalls.find((h) => h.root === refresh).text();
const rowHover = hoverCalls.find((h) => h.root === panel.list);
const tipRow = rowHover.text(rowsBox.children[0]);
const tipBlank = [0, 1].map((n) => rowHover.text(blankBox.children[n]));
panel.draw({ book: null, stale: null }, { gaps: [] });
const b = view();
const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" });
console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty }));
console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector: rowHover.selector,
tipEmpty }));
""",
)
a, b = out["a"], out["b"]
assert a["pending"] and not b["pending"]
assert a["texts"][0] == "연결 없음 2곳"
# 빈 줄 = 직접공사 항목 중 금액 모름(관급 · 제목 · 아는 줄 빼고) · 같은 줄 까닭은 하나로
assert a["texts"][0] == "빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳"
assert a["rows"] == [
["0", "ui-sidebar-row", "찰쌓기일위대가표"],
["1", "ui-sidebar-row", "모래재료비목록표"],
["0", "ui-sidebar-row", "제근설계내역서"],
["1", "ui-sidebar-row", "옹벽설계내역서"],
["2", "ui-sidebar-row", "찰쌓기일위대가표"],
["3", "ui-sidebar-row", "모래재료비목록표"],
]
assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"]
assert out["tipBlank"][0] == "수량 연결 없음 · 준비공 대가 — 연결 없음"
assert out["tipBlank"][1].startswith("금액 모름") # 까닭 없는 빈 줄(이은 대가에 빈 줄)
assert a["texts"][4:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"]
assert out["moves"] == [{"tab": "material", "key": "MT9"}]
assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]"
assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"]
assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == []
assert b["texts"] == ["빈 줄 0"] and b["rows"] == []
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
@@ -88,6 +88,7 @@ def test_B09_다음_저장_완료_인쇄(r):
"toast:error",
]
assert b["prev"] == [False, []] # [<] = 공용 틀 몫
assert b["blocked"] == [True, ["toast:warning"]] # 701-3 빈 줄 있으면 저장 · 완료 없이 머묾
def test_B09_초기화(r):
@@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
from resources.tester.helper_b09_group_cards import COMMON, GRID, GROUPS, RAW, TABS, run # noqa: E402
LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts"
DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts"
@@ -33,7 +33,7 @@ SPEC = (
def test_한_설정_셋_탭_함께():
out = run(
[COMMON, TABS, LABOR, GROUPS],
[COMMON, TABS, GRID, LABOR, GROUPS],
SPEC + "\nconst a = kinds();"
"\nchooseGroupView('table');"
"\nconst b = kinds();"
@@ -59,7 +59,7 @@ def test_옛_탭별_값_첫_읽기에_한_값으로():
]
for old, want in cases:
out = run(
[COMMON, TABS, LABOR, GROUPS],
[COMMON, TABS, GRID, LABOR, GROUPS],
"\nkept.set('b09-group-view', "
+ json.dumps(json.dumps(old))
+ ");"
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
"""701-3 — 원가계산서 = 아는 금액까지 + 「빈 줄 n · 미완성」 띠 · 인쇄 · 확정은 빈 줄 0 일 때만.
- 설계내역서 `sums` = 빈 줄이 있어도 아는 비목 합 + `blank`(파이썬 · TS 짝 같은 값)
- 총괄 · 원가계산서 = 그 합으로 셈(빈칸 아님)
- [>] 확정 = 서버가 정본으로 다시 셈 · 빈 줄 있으면 409 · 단계 완료 안 함
"""
from __future__ import annotations
import asyncio
import json
import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(Path(__file__).parent))
from test_145_3_b09_bill import INPUTS, USER, _run_ts # noqa: E402
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
def test_빈_줄_있어도_아는_금액_합_거울():
py = compute_estimation(INPUTS, USER)
sums = py["bill"]["sums"]
assert sums["blank"] == 2 and sums["labor"] == 7565300 and sums["material"] == 2771766
assert _run_ts(INPUTS, USER)["bill"]["sums"] == sums
def test_원가계산서_아는_금액으로_셈():
sums = compute_estimation(INPUTS, USER)["bill"]["sums"]
estimate = {**USER["estimate"], "estimated_price": 5e8, "duration_days": 120} # 요율 구간
total = compute_total(sums, estimate, rate_table())
assert total["sums"]["direct"] == 7565300 + 2771766 + 2627525
assert total["cost"]["grand"] is not None and total["cost"]["grand"] > total["sums"]["direct"]
def _complete(monkeypatch, blank: int) -> list[str]:
marks: list[str] = []
async def root(_pid):
return Path("x")
async def payload(_pid, _root):
return {"sheets": {"bill": {"totals": {"direct": {"blank": blank}}}}}
async def mark(pid, stage, how):
marks.append(f"{stage}:{how}")
monkeypatch.setattr(router, "_project_root", root)
monkeypatch.setattr(router, "payload", payload)
monkeypatch.setattr(router, "mark_stage", mark)
asyncio.run(router.estimation_complete("P1"))
return marks
def test_확정_빈_줄_있으면_409_단계_그대로(monkeypatch):
with pytest.raises(HTTPException) as caught:
_complete(monkeypatch, 3)
assert caught.value.status_code == 409 and "빈 줄 3 · 미완성" in caught.value.detail
assert _complete(monkeypatch, 0) == ["6:next"]
def test_띠_글_빈_줄_0_이면_없음():
source = (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text(encoding="utf-8")
tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8")
start = tabs.index("export const blankRows")
end = tabs.index("\n};\n", tabs.index("export const unfinished")) + 4
rows = [
{"kind": "item", "cost": "direct", "sum": None},
{"kind": "item", "cost": "direct", "sum": 5},
]
script = (
"type Row = Record<string, any>;\n"
+ tabs[start:end]
+ f"\nconsole.log(JSON.stringify([unfinished({{ bill: {{ rows: {json.dumps(rows)} }} }}),"
" unfinished({ bill: { rows: [] } })]));"
)
assert "export type Row" in source # 셈 짝 Row 꼴 그대로(흉내 타입)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
banner, none = json.loads(result.stdout)
assert banner.startswith("빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner
assert none is None
@@ -0,0 +1,45 @@
"""702-3 B09 단가연결 즉시 금액 · [저장] 단추 · 서고 사본 산근.
- 서고 사본(단가연결로 들여온 사전조합)의 단가산출 줄 = 산근 목록에 듦 · 대가 블록 단가 채움
(옛 흠 — 산근이 구조물 사본만 훑어 서고 사본 산근 0 · 대가 빈칸)
- 셈 미리보기 길(`POST …/estimation/calc`) = 화면 캐시로 서버 셈 · 저장 안 함 · 로직 연결 줄 금액
- 화면 — 연결 즉시 뒤에서 셈(차례 번호 · 옛 요청 끊기) · [저장] 단추 · 옮길 때 물음
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402
from resources.tester.test_204_2_b09_bill_library import ( # noqa: E402, F401
daega_inputs,
library,
linked_resource_keys,
world,
)
B09 = ROOT / "B09_Estimation"
def test_서고_사본_산근_목록에_듦_대가_단가_채움(world):
root = world["root"]
folder = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"]
daega = daega_inputs(root)
block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder])
wanted = [ln["key"] for ln in block["lines"] if ln["kind"] == "sangeun"]
base = compute_base(prices.build_inputs(None, linked_resource_keys(root)), {})
sg = sangeun.build(root, base)
assert [r["key"] for r in sg["sg_list"]] == wanted # 둘 다 산근 · 블록 줄 키 그대로
assert all(r["sum"] for r in sg["sg_list"])
assert sg["sg_list"][0]["uses"] == ["서고 서고 조합"]
by_key = {r["key"]: r for r in sg["sg_list"]}
labor = compute_labor(daega, {**base, "gaps": []}, by_key)
row = next(r for r in labor["daega"] if r["key"] == block["id"])
assert row["sum"] and not row.get("blank")
@@ -0,0 +1,79 @@
"""703-3 B09 [업데이트] 한 곳 — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 재적용.
- 미리 보기 수(`Refresh.preview`) = 적용 뒤 책 견줌(`Prices.book_diff`) 수 · 노임 판 전 → 후 · 고친 칸 수
- 재적용 = 연결된 사본 중 원본이 바뀐 것만 · 하나가 막혀도 나머지 · 덮개(사용자 값) 안 건드림
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Refresh as refresh # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
KEYS = ["LB000001", "LB000033"] # 작업반장(건설업) · 석공
def test_책_견줌_미리보기_수_적용_수_같음(tmp_path, monkeypatch):
monkeypatch.setattr(refresh, "changed_copies", lambda root: [])
new = prices.refresh_book(KEYS)
assert new["labor_edition"] == prices.labor_editions(KEYS) and new["labor_edition"]
old = json.loads(json.dumps(new))
old["rows"]["LB000001"] = {**old["rows"]["LB000001"], "price": 1} # 옛 판 값
del old["rows"]["LB000033"] # 새 줄
old["labor_edition"] = {"건설업": "2025-09-01"}
cover = {
"S:a": {"x#0": {"qty": 1, "labor": 2}},
"K:b": {"@copy": {"from": "S:a"}, "y#0": {"qty": 3}},
}
got = refresh.preview(tmp_path, old, KEYS, cover)
after = prices.refresh_book(KEYS, old)
diff = prices.book_diff(old, after)
assert (got["새줄"], got["바뀐줄"]) == (diff["new"], diff["changed"]) == (1, 1)
assert got["단가줄"] == 2 and got["고친칸"] == 3 and got["사본"] == []
assert got["노임판"]["전"] == "건설업 2025-09"
assert got["노임판"]["후"].startswith("건설업 2026")
old.pop("labor_edition")
assert refresh.preview(tmp_path, old, KEYS, None)["노임판"]["전"].startswith("굳힌 날 ")
assert prices.book_diff(after, prices.refresh_book(KEYS, after)) == {
"new": 0,
"changed": 0,
"factor": False,
"fx": False,
}
assert prices.book_stale(after, KEYS) is None
def test_재적용_원본_바뀐_사본만_막혀도_나머지_덮개_그대로(tmp_path, monkeypatch):
cover = {"S:a": {"x#0": {"qty": 0.3}}}
store.write_user(tmp_path, {"daega_cover": cover})
monkeypatch.setattr(
refresh,
"linked_snapshots",
lambda root: {"p1": {"폴더": "a"}, "p2": {"폴더": "b"}, "p3": {"폴더": "c"}},
)
items = [
{"폴더": "a", "이름": "가", "마스터바뀜": True},
{"폴더": "b", "이름": "나", "마스터바뀜": False}, # 원본 그대로 = 안 함
{"폴더": "c", "이름": "다", "마스터바뀜": True},
{"폴더": "z", "이름": "끊김", "마스터바뀜": True}, # 연결 안 됨 = 안 함
]
monkeypatch.setattr(refresh.item_copy, "list_items", lambda root: items)
calls = []
def fake(root, folder, who=None):
calls.append(folder)
if folder == "c":
raise LookupError("원본 층 없음")
monkeypatch.setattr(refresh.item_copy, "reapply", fake)
assert [c["이름"] for c in refresh.changed_copies(tmp_path)] == ["가", "다"]
got = refresh.reapply_changed(tmp_path)
assert calls == ["a", "c"]
assert got == {"다시": ["가"], "막힘": ["다 — 원본 층 없음"]}
assert store.read_user(tmp_path)["daega_cover"] == cover
+194
View File
@@ -0,0 +1,194 @@
"""703-2 B09 펼친 일위대가 우클릭 — 복사해 새 일위대가 · 내 자료집에 저장.
- 복사본 = 덮개 `K:…` 의 `@copy` {from · name · spec} — 셈 짝(`Calc_Labor` 파이썬 · TS 거울)이
원본 블록을 본떠 끝 번호 새 호표 · 덮개 = 제 것 · 원본 없음 = 짝 잃음 ·
저장 다듬기 · 서고 사본 지킴(prune)
- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 손 · 품 · 단가 덮개 = 안 담음 수 ·
같은 이름 409 → 덮어쓰기 · 새 이름
"""
import copy
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
from B09_Estimation import B09_Estimation_Daega_Personal as personal # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
from config import config_system # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as m02 # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
def _load(name: str):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
b145 = _load("test_145_2_b09_labor_calc")
b702 = _load("test_702_b09_daega_cover")
#: DG9 찰붙임 복사 — 석공 품 0.1 → 0.2(복사본만) · 원본 DG9 덮개 0.12 는 원본에만
COPY = {
"T:DG9": {"labor:L00005#0": {"qty": 0.12}},
"K:aa": {
"@copy": {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"},
"labor:L00005#0": {"qty": 0.2},
},
"K:bb": {"@copy": {"from": "K:aa", "name": "복사의 복사"}},
"K:cc": {"@copy": {"from": "T:없음"}, "x#0": {"qty": 1}},
}
def _block(out, bid):
return next(b for b in out["daega_blocks"] if b["id"] == bid)
def test_복사본_새_호표_끝_번호_덮개_제것():
daega = b702._covered_excel()
out = compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY)
src, mine = _block(out, "DG9"), _block(out, "DG:K:aa")
n = len(daega["blocks"])
assert [mine["no"], _block(out, "DG:K:bb")["no"]] == [n + 1, n + 2] # 번호 새로(끝)
assert (mine["name"], mine["spec"], mine["cover"]) == ("찰붙임 복사", "규격 B", "K:aa")
assert src["lines"][0]["qty"] == 0.12 and mine["lines"][0]["qty"] == 0.2
assert mine["lines"][0]["base"]["qty"] == 0.1 # 품셈 값 = 원본 기본값
assert [ln["amount"] for ln in mine["lines"][1:]] == [ln["amount"] for ln in src["lines"][1:]]
twice = _block(out, "DG:K:bb")
assert twice["spec"] == "규격 B" and twice["lines"][0]["qty"] == 0.1 # 덮개 안 물려받음
assert any(r["key"] == "DG:K:aa" for r in out["daega"]) # 목록표 = 내역 단가연결 자리
assert out["daega_orphans"] == [
{"cover": "K:cc", "cid": "@copy"},
{"cover": "K:cc", "cid": "x#0"},
]
def test_거울_파이썬_TS_복사본():
daega = b702._covered_excel()
py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY)))
ts = b145._ts(daega, b145.SHEETS, b145.SANGEUN, COPY)
assert b145._same(py, ts["out"])
def test_저장_다듬기_복사본_표지():
raw = {"daega_cover": {**COPY, "K:dd": {"@copy": {"name": "원본 없음"}}}}
got = store.clean_user(raw)["daega_cover"]
assert got["K:aa"]["@copy"] == {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"}
assert got["K:bb"] == {"@copy": {"from": "K:aa", "name": "복사의 복사"}}
assert "K:dd" not in got
def test_서고_사본_원본_지킴():
user = {"daega_cover": {"K:aa": {"@copy": {"from": "L:combo@system@-@SS1"}}}}
assert library.used_folders(user) == {"combo@system@-@SS1"}
# ── 내 자료집(개인 층 사전조합 · 임시 storage) ─────────────────────────────
@pytest.fixture()
def world(tmp_path, monkeypatch) -> dict[str, Any]:
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
root = b145._project(tmp_path / "proj", [(100, "SS000901")])
b145._snap(root, "pipe@100.0", "SS000901", b145.LINES, b145._values(b145.STONE))
folder = layers.personal_dir(7, 42)
folder.mkdir(parents=True)
items_store.ensure(folder)
got = daega_inputs(root)
block = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@100.0"])
return {"root": root, "folder": folder, "block": block}
def test_자료집_문서_덮개_손_안담음_수(world):
block = world["block"]
each = block["lines"][1]["qty"] # GF000119 줄 수량(산근 = 손 그대로 0.5)
cover = {
block["cover"]: {
"sangeun:SG:GF000119#0": {"qty": 0.8, "labor": 1000}, # 줄 수량 = 담음 · 단가 = 안 담음
}
}
made = personal.build(world["root"], block["cover"], cover, "내 돌붙임")
doc = made["문서"]
assert each == 0.5 and doc["이름"] == "내 돌붙임"
hands = [ln["수량"]["손"] for ln in doc["담은로직"]]
assert hands == [2, 0.8, 1] # 덮개 줄만 바뀜
assert doc["담은로직"][0]["입력값"] == b145.STONE # 넣을 값 얹음
assert made["담음"] == {"수량": 1, "넣을값": 1, "고른자재": 0}
assert made["안담음"] == {"품": 0, "단가": 1}
# 복사본 = 원본까지 따라감 · 이름 기본 = 복사본 이름 · 덮개 = 복사본 것
cover2 = {"K:aa": {"@copy": {"from": block["cover"], "name": "복사"}}}
cover2["K:aa"]["sangeun:SG:GF000219#0"] = {"qty": 3}
made2 = personal.build(world["root"], "K:aa", cover2)
assert made2["문서"]["이름"] == "복사"
assert [ln["수량"]["손"] for ln in made2["문서"]["담은로직"]] == [3, 0.5, 1]
with pytest.raises(LookupError):
personal.build(world["root"], "S:없음", {})
def test_자료집_같은_이름_물음(world):
block = world["block"]
doc = personal.build(world["root"], block["cover"], {}, "내 돌붙임")["문서"]
with m02.at(world["folder"]):
first = personal.write(doc)
with pytest.raises(m02.StoreError) as e:
personal.write(doc)
assert e.value.status == 409 and e.value.detail["같은이름"] == first["이름"]
changed = copy.deepcopy(doc)
changed["담은로직"][1]["수량"] = {"손": 9}
again = personal.write(changed, first["이름"]) # 덮어쓰기
assert again["이름"] == first["이름"]
other = personal.write({**doc, "이름": "내 돌붙임 2"}) # 새 이름
assert other["이름"] != first["이름"]
saved = json.loads(
(world["folder"] / "precombo" / f"{first['이름']}.json").read_text(encoding="utf-8")
)
assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] == {"손": 9}
assert saved["담은로직"][0]["입력값"] == b145.STONE
assert len(list((world["folder"] / "precombo").glob("SC-*.json"))) == 2
def test_자료집_길_같은_이름_409_덮어쓰기(world, monkeypatch):
"""서버 길 — 화면 캐시 덮개째 · 개인 층(임시 storage) · 같은 이름 409 {같은이름} → 덮어쓰기."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B09_Estimation import B09_Estimation_Router as b09
from common_util.common_util_auth import verify_session
async def root_of(_pid):
return world["root"]
monkeypatch.setattr(b09, "_project_root", root_of)
app = FastAPI()
app.include_router(b09.router)
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app.dependency_overrides[verify_session] = lambda: session
client = TestClient(app)
block = world["block"]
url = "/api/projects/8b98ca75-c572-4002-8468-2adae4323414/estimation/daega/personal"
user = {"daega_cover": {block["cover"]: {"sangeun:SG:GF000219#0": {"qty": 4}}}}
body = {"cover": block["cover"], "name": "길 시험", "user": user}
first = client.post(url, json=body)
assert first.status_code == 200, first.text
assert first.json()["담음"]["수량"] == 1
again = client.post(url, json=body)
assert again.status_code == 409 and again.json()["detail"]["같은이름"] == first.json()["키"]
over = client.post(url, json={**body, "overwrite": first.json()["키"]})
assert over.status_code == 200 and over.json()["키"] == first.json()["키"]
saved = world["folder"] / "precombo" / f"{first.json()['키']}.json"
assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] == {"손": 4}
assert client.post(url, json={**body, "cover": "S:없음"}).status_code == 404
+142
View File
@@ -0,0 +1,142 @@
"""704-3 일위대가 엑셀 꼴 격자 — 공용 부품 `ui_template_hopyo_grid` 줄 꼴 · M02 문서 + calc → 격자 줄 값.
TS 소스를 이어 붙여 Node(타입 지움)로 돌림 — 값은 받은 그대로(단가 = 기준 ÷ 배 · 금액 = 반영 · 합계 = 반영합).
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
GRID = "ui_template/ui_template_hopyo_grid.ts"
def _node(files, body, prelude=""):
src = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=prelude + src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
LINE = {"name": "돌쌓기", "spec": "뒷길이 35cm", "qty": 2.09, "unit": "㎡", "tag": "품셈",
"prices": {"sum": 52995, "labor": 41250, "expense": 11745},
"amounts": {"sum": 110759.6, "labor": 86212.5, "expense": 24547.1}} # fmt: skip
def test_머리_구성_합계_줄_꼬리표_빈_줄():
blocks = [
{"key": "A", "no": 3, "name": "돌기슭막이", "unit": "m", "cite": "산림품셈 13-2",
"total": {"sum": 110759.6, "labor": 86212.5}, "lines": [LINE, {**LINE, "name": "잡재료",
"qty": None, "prices": {}, "amounts": {}}]},
{"key": "B", "name": "번호 없음", "unit": "식", "total": {}, "lines": [], "blank": 2},
] # fmt: skip
out = _node([GRID], f"\nconst g = hopyoGrid({json.dumps(blocks, ensure_ascii=False)});"
"\nconst cls = g.rows.map((_, i) => hopyoCellClass(g.rows, i, 'tag') ?? hopyoCellClass(g.rows, i, 'note'));"
"\nconsole.log(JSON.stringify({ rows: g.rows, titles: [...g.titles], at: [...g.at], cls,"
" cols: g.columns.map((c) => c.key) }));") # fmt: skip
rows = out["rows"]
assert [r["name"] for r in rows] == [
"제 3 호표 돌기슭막이", "ㄴ 산림품셈 13-2", "ㄴ 돌쌓기", "ㄴ 잡재료", "합 계", "번호 없음", "합 계",
] # fmt: skip
assert rows[0]["sum"] == 110759.6 # 머리 = 1 단위 합계
assert (
rows[2]["labor_price"] == 41250 and rows[2]["labor"] == 86212.5 and rows[2]["qty"] == 2.09
)
assert rows[2]["material_price"] is None and rows[2]["tag"] == "품셈"
assert rows[4]["note"] == "빈 줄 1" and rows[4]["blank_names"] == [
"잡재료"
] # 금액 모르는 줄에서 셈
assert rows[6]["note"] == "빈 줄 2" # 준 빈 줄 수 그대로
assert out["titles"] == [0, 4, 5, 6] and out["at"] == [[2, [0, 0]], [3, [0, 1]]]
assert out["cls"][2] == "ui-hopyo-grid__tag is-pum" and out["cls"][4] == "ui-hopyo-grid__blank"
assert out["cols"][-1] == "tag" and out["cols"][:4] == ["name", "spec", "qty", "unit"]
STUBS = r"""
const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%" })[k] ?? k;
const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 });
"""
DOC = {
"종류": "일위대가",
"열": "C1",
"일위대가번": "SU-000006",
"단위": "m",
"담은로직": [
{"로직": "GF1", "별칭": None, "입력값": {"뒷길이": "35cm"}, "수량": None, "켜쌓기율": 90},
{"로직": "GF2", "별칭": "고임돌", "입력값": {}, "수량": None},
],
"자재": [
{"코드": "MT1", "이름": "큰돌", "규격": "30cm", "단위": "개", "수량": None},
{
"코드": "MT2",
"이름": "철선",
"규격": "",
"단위": "kg",
"수량": None,
"별도계상": "직접계산",
},
],
}
CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
"기준": {"노무비": 412500, "재료비": 0, "경비": 117450, "계": 529950},
"반영": {"노무비": 86212.5, "재료비": 0, "경비": 24547.05, "계": 110759.55}},
{"로직": "GF2", "분모": "㎥", "배": 1, "수량": None,
"기준": {"노무비": 24816, "재료비": 0, "경비": 0, "계": 24816}, "반영": None}],
"자재": [{"코드": "MT1", "기준단가": 1200, "수량": 5, "반영": 6000},
{"코드": "MT2", "기준단가": 900, "수량": 2, "반영": 1800}],
"반영합": {"노무비": 86212.5, "재료비": 1800, "경비": 24547.05, "계": 112559.55}} # fmt: skip
def test_M02_문서_calc_를_격자_줄로():
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
logics = [
["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
["GF2", {"이름": "고임돌 채집"}],
]
out = _node(files, (
f"\nconst b = laborBlock({json.dumps(DOC, ensure_ascii=False)}, {json.dumps(CALC, ensure_ascii=False)},"
f" new Map({json.dumps(logics, ensure_ascii=False)}));"
"\nconst wait = laborBlock(" + json.dumps(DOC, ensure_ascii=False) + ", null, new Map());"
"\nconsole.log(JSON.stringify({ b, foot: hopyoGrid([b]).rows.at(-1), wait: hopyoGrid([wait]).rows.at(-1) }));"
), STUBS) # fmt: skip
b = out["b"]
assert b["name"] == "SU-000006" and b["unit"] == "m" and b["total"]["sum"] == 112559.55
one, two, mt1, mt2 = b["lines"]
assert (
one["name"] == "돌쌓기 찰쌓기"
and one["spec"] == "35cm · 켜쌓기 90%"
and one["unit"] == "㎡"
)
assert one["prices"] == {
"sum": 52995,
"labor": 41250,
"material": 0,
"expense": 11745,
} # 기준 ÷ 배
assert one["amounts"]["sum"] == 110759.55 and one["tag"] == "품셈"
assert two["name"] == "고임돌" and two["qty"] is None and two["note"] == "수량 없음"
assert mt1["prices"] == {"sum": 1200, "material": 1200} and mt1["note"] == "별도계상 · 합계 밖"
assert (
mt2["amounts"] == {"sum": 1800, "material": 1800}
and mt2["note"] == "MT2"
and "tag" not in mt2
)
assert out["foot"]["note"] == "빈 줄 1" and out["foot"]["blank_names"] == ["고임돌"]
assert out["wait"]["note"] == "" # 계산 중 = 빈 줄 알림 없음
def test_세_곳_같은_부품():
detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail
m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
kit = (ROOT / "ui_template/ui_template_ui_kit_parts.ts").read_text("utf-8")
assert 'key: "hopyo-grid"' in kit # 부품 모음 견본
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""PLAN 704-1 · 704-2 — M02 기준 목록(대표 규격 펼침 · 금액 = M01 엔진) · 계산서 보기.
① 세 항목(돌쌓기 메쌓기 · 파형강관 · 구조물터파기) 줄 금액 = M01 시험 계산(`store.calc`)
② 대표 규격 — 관경 고르기 11 · 뒷길이 표 줄 3 · 지역 = 자동값 한 벌
③ 실무 낱말 — 「석축」 찾으면 돌쌓기 · 찰쌓기(서버 찾기 맞춤 `hit` = 화면 `matchLogic` 거울 · 208-1)
④ 계산서 — 계 = 엔진 값 · 비목 소계 합 = 계 · 규격 축 아닌 입력은 400
⑤ 목록 저장본 — 임시 storage `_derived/` · 같은 지문이면 다시 안 셈
⑥ 화면 — 고르기 창이 기준 목록 표 · 계산서 [담기] = 지금 담기 동작(`done(logic)`)
"""
from __future__ import annotations
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData.M01_MasterData_Store_Find import hit
from M02_MasterTemplete import M02_Logic_Standard as st
ROOT = Path(__file__).resolve().parents[2]
THREE = ("GF000220", "GC000404", "GF000101") # 돌쌓기 메쌓기(장비) · 파형강관 부설 · 구조물터파기
def _rows(key: str) -> list[dict]:
whole = m01.loaded()[1]
return st._rows_of(whole, whole.logic(key), False)
def _given(key: str, change: dict) -> dict:
whole = m01.loaded()[1]
return st._given(whole, whole.logic(key), change)[0]
@pytest.mark.parametrize("key", THREE)
def test_줄_금액은_M01_시험_계산(key: str) -> None:
rows = _rows(key)
assert rows
for row in rows:
got = m01.calc(key, _given(key, row["바꿈"]))
assert got["ok"], got
assert row["금액"] == pytest.approx(float(got["sums"]["계"]), abs=0.01), row["규격"]
def test_대표_규격_펼침() -> None:
pipe = _rows("GC000404")
assert len({r["규격"] for r in pipe}) == len(pipe) # 줄마다 규격이 다름
assert sum(1 for r in pipe if not r["바꿈"] or "관경" in r["바꿈"]) == 11
assert pipe[0]["규격"].startswith("관경 250") and pipe[0]["단위"] == "본"
stone = _rows("GF000220")
backs = [r for r in stone if not r["바꿈"] or "뒷길이" in r["바꿈"]]
assert [r["규격"].split(" · ")[0] for r in backs] == [
"뒷길이 35㎝ 이하",
"뒷길이 55㎝ 이하",
"뒷길이 75㎝ 이하",
]
assert all("지역" not in r["바꿈"] for r in stone) # 지역 = 자동값 한 벌
hand = _rows("GF000219") # 메쌓기(인력) — 견치돌 · 뒷길이 25 같은 표 밖 조합은 0 원 → 뺌
assert all(r["금액"] != 0 for r in hand[1:])
assert stone[0]["공종"] == "돌공" and stone[0]["단위"] == "㎡"
assert _rows("GF000101")[0]["공종"] == "토공"
def test_석축_찾으면_쌓기() -> None:
hits = [
r["이름"]
for _, data in m01._logic_files(m01.loaded()[0])
for logic in data.get("줄") or []
for r in [{**logic, "별칭": ", ".join(st.words_of(logic))}]
if hit(r, "석축")
]
assert "돌쌓기 메쌓기(장비)" in hits
assert any("찰쌓기" in name for name in hits)
assert "석축" in _rows("GF000220")[0]["별칭"]
@pytest.mark.parametrize("key", THREE)
def test_계산서_계는_엔진_값(key: str) -> None:
row = _rows(key)[-1]
got = st.sheet(key, row["바꿈"])
assert got["계"] == pytest.approx(row["금액"], abs=0.01)
assert sum(g["소계"] for g in got["묶음"]) == pytest.approx(got["계"], abs=0.01)
assert got["판"].endswith("노임") and got["출처"]
assert all(line["글"] for g in got["묶음"] for line in g["줄"])
def test_계산서_규격_축_아닌_입력은_거절() -> None:
with pytest.raises(m01.StoreError) as e:
st.sheet("GF000220", {"지역": "서울"})
assert e.value.status == 400
with pytest.raises(m01.StoreError):
st.sheet("GF000220", {"뒷길이": 40}) # 대표 값 아님
def test_목록_저장본_같은_지문이면_안_셈(tmp_path, monkeypatch) -> None:
calls = []
def fake(files, workers=1):
calls.append(workers)
return {"판": "2026 상반기 노임", "공종": ["돌공"], "줄": [{"키": "GF000220"}]}
monkeypatch.setattr(st.layers, "storage_root", lambda: tmp_path)
monkeypatch.setattr(st, "build", fake)
st._MEMO.clear()
first = st.listing()
assert first["준비"] is False and first["줄"] == [] # 셈 도중 = 빈 줄
st._MEMO["job"].join()
assert st.listing()["줄"] == [{"키": "GF000220"}]
st._MEMO.clear() # 서버 다시 켬 — 저장본에서 읽음
assert st.listing()["준비"] is True and len(calls) == 1
assert list((tmp_path / "_derived" / st.KIND).glob("*.json"))
st._MEMO.clear()
def test_화면_기준_목록_계산서_담기() -> None:
pick = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts").read_text("utf-8")
assert 'from "@ui/ui_template_data_table"' in pick and "fetchStandard()" in pick
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
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
router = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py").read_text("utf-8")
assert '@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)])' in router
assert '@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)])' in router
+3 -1
View File
@@ -360,7 +360,9 @@ def test_좌측_판_자리와_권한():
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert len(detail.splitlines()) == 693 # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2)
assert (
len(detail.splitlines()) == 700
) # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2 · 704-3 격자 +7)
assert "withHopyo(library)," in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
+25
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from resources.tester import test_m02_tree as base
@@ -158,3 +159,27 @@ def test_항목_추가_창_카드_격자_표_접기():
assert "max-height: calc(100dvh - var(--app-header-h));\n overflow-y: auto;" in css
main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main
def test_복사_생성_이름_원래_이름_복사_같은_이름_둘_없음(world):
"""701-4 — 복사 생성(대상 `복사이름`) = 「원래 이름 복사」 · 또 하면 「… 복사(1)」 · 그냥 붙여넣기는 그대로."""
company = _company(world)
wall = company.item(["옹벽"])["key"]
client = _as(world)
body = {
"from_layer": "company",
"대상": [{"경로": [], "키": wall, "복사이름": True}],
"경로": [],
}
first = client.post(PASTE.format("personal"), json=body).json()
second = client.post(PASTE.format("personal"), json=body).json()
plain = client.post(PASTE.format("personal"), json={**body, "대상": [{"경로": [], "키": wall}]})
names = [r["경로"][-1] for r in plain.json()["items"]]
assert sorted(names) == ["옹벽", "옹벽 복사", "옹벽 복사(1)"]
assert first["알림"]["바뀐이름"] == ["옹벽 복사"] and second["알림"]["바뀐이름"] == [
"옹벽 복사(1)"
]
assert len(set(names)) == len(names) # 같은 이름 둘 없음
root = Path(__file__).resolve().parents[2]
ui = (root / "M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts").read_text("utf-8")
assert "refs: [{ 경로: [], 키: key, 복사이름: true }]" in ui # 복사 생성 길만
+21
View File
@@ -0,0 +1,21 @@
/* 일위대가 엑셀 꼴 격자(PLAN 704-3) — 줄 꼬리표 · 빈 줄 알림만(표 꼴은 공용 데이터표) */
.ui-data-table__table tbody td.ui-hopyo-grid__tag {
text-align: center;
font-size: var(--text-xs);
font-weight: var(--font-weight-bold);
}
.ui-data-table__table tbody td.ui-hopyo-grid__tag.is-pum {
color: var(--color-accent);
}
.ui-data-table__table tbody td.ui-hopyo-grid__tag.is-fix {
color: var(--color-warning);
}
/* 합계 줄(is-title) 안에서도 붉게 */
.ui-data-table__table tbody tr td.ui-hopyo-grid__blank.ui-hopyo-grid__blank {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
+223
View File
@@ -0,0 +1,223 @@
/* =============================================================================
* ui_template_hopyo_grid.ts
* 일위대가 엑셀 꼴 격자(PLAN 704-3) — 호표마다 머리 줄 + 구성 줄 + 합계 줄 · 공용 데이터표(`ui_template_data_table`) 위.
* M02 일위대가 카드 · 상세 · B09 일위대가표(호표보기)가 같은 열 · 같은 줄 꼴을 씀.
*
* 머리 줄 = 「제 N 호표 이름」(번호 없으면 이름만) · 규격 · 단위 · 1 단위 합계(합계 금액 칸) · 비고 = 호표 꼴
* 품셈 인용 줄(있으면) = 「ㄴ 조항」 · 비고 「품셈 인용」
* 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
* 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
* 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
*
* const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
* B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀
* ========================================================================== */
import {
createDataTable,
type DataColumn,
type DataRow,
type DataTableOptions,
} from "./ui_template_data_table";
import "./ui_template_hopyo_grid.css";
export interface HopyoMoney {
sum?: number | null;
labor?: number | null;
material?: number | null;
expense?: number | null;
}
export interface HopyoLine {
name: string;
spec?: string | null;
/** 규격 원문(풍선 · 부르는 쪽 몫) */
spec_raw?: unknown;
/** 품 · 수량(1 단위 당) — null = 수량 없음 */
qty: number | null;
unit?: string | null;
/** 1 단위(위 단위) 당 단가 */
prices: HopyoMoney;
/** 단가 × 품/수량 — sum null = 금액 모름(빈 줄) */
amounts: HopyoMoney;
note?: string | null;
/** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */
tag?: string | null;
}
export interface HopyoBlock {
/** 줄 키 앞머리(머리 = key · 줄 = key|n · 합계 = key|합계) */
key: string;
no?: string | number | null;
name: string;
spec?: string | null;
spec_raw?: unknown;
unit?: string | null;
/** 호표 꼴(머리 줄 비고) */
kind?: string | null;
/** 품셈 인용(조항) */
cite?: string | null;
total: HopyoMoney;
lines: HopyoLine[];
/** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */
blank?: number;
blank_names?: string[];
}
/** 줄 꼬리표 낱말 */
export const HOPYO_TAG = { pum: "품셈", fix: "고정" } as const;
/** 하위 줄 앞 — 공용 표가 앞 빈칸을 접지 않음(들여쓰기 = level) */
export const HOPYO_CHILD = "ㄴ ";
const HOPYO_COSTS = [
["sum", "합계"],
["labor", "노무비"],
["material", "재료비"],
["expense", "경비"],
] as const;
const hopyoText = (key: string, label: string, width?: number): DataColumn => ({
key,
label,
width,
});
const hopyoMoney = (key: string, label: string, group: string): DataColumn => ({
key,
label,
group: [group],
kind: "number",
decimals: 1,
width: 96,
});
/** 열 — 명칭 · 규격 · 수량 · 단위 · 비목 넷(단가 / 금액) · 비고 · 구분 */
export const HOPYO_COLUMNS: DataColumn[] = [
hopyoText("name", "명칭", 240),
hopyoText("spec", "규격", 160),
{ key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 76 },
hopyoText("unit", "단위", 56),
...HOPYO_COSTS.flatMap(([key, label]) => [
hopyoMoney(`${key}_price`, "단가", label),
hopyoMoney(key, "금액", label),
]),
hopyoText("note", "비고", 120),
hopyoText("tag", "구분", 56),
];
export interface HopyoGrid {
columns: DataColumn[];
rows: DataRow[];
/** 머리 · 합계 줄 번호(굵게 · 레벨 1) */
titles: Set<number>;
/** 줄 번호 → [호표 차례, 구성 줄 차례] · 구성 줄만 */
at: Map<number, [number, number]>;
}
const hopyoBlank = (b: HopyoBlock): { n: number; names: string[] } => {
if (b.blank !== undefined) return { n: b.blank, names: b.blank_names ?? [] };
const names = b.lines.filter((l) => l.amounts.sum == null).map((l) => l.name);
return { n: names.length, names };
};
/** 호표 → 표 줄(값 그대로) */
export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
const rows: DataRow[] = [];
const titles = new Set<number>();
const at = new Map<number, [number, number]>();
blocks.forEach((b, bi) => {
titles.add(rows.length);
rows.push({
key: b.key,
name: b.no != null && b.no !== "" ? `제 ${b.no} 호표 ${b.name}` : b.name,
no: b.no,
title: b.name,
spec: b.spec ?? "",
spec_raw: b.spec_raw,
unit: b.unit ?? "",
sum: b.total.sum ?? null, // 1 단위 합계
note: b.kind ?? "",
});
if (b.cite)
rows.push({ key: `${b.key}|cite`, name: `${HOPYO_CHILD}${b.cite}`, note: "품셈 인용" });
b.lines.forEach((ln, n) => {
at.set(rows.length, [bi, n]);
const row: DataRow = {
key: `${b.key}|${n}`,
name: `${HOPYO_CHILD}${ln.name}`,
spec: ln.spec ?? "",
spec_raw: ln.spec_raw,
qty: ln.qty,
unit: ln.unit ?? "",
note: ln.note ?? "",
tag: ln.tag ?? "",
};
for (const [key] of HOPYO_COSTS) {
row[`${key}_price`] = ln.prices[key] ?? null;
row[key] = ln.amounts[key] ?? null;
}
rows.push(row);
});
const blank = hopyoBlank(b);
titles.add(rows.length);
rows.push({
key: `${b.key}|합계`,
name: "합 계",
sum: b.total.sum ?? null,
labor: b.total.labor ?? null,
material: b.total.material ?? null,
expense: b.total.expense ?? null,
note: blank.n ? `빈 줄 ${blank.n}` : "",
blank: blank.n,
blank_names: blank.names,
});
});
return { columns: HOPYO_COLUMNS, rows, titles, at };
}
/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */
export function hopyoCellClass(rows: DataRow[], i: number, key: string): string | null {
const r = rows[i];
if (key === "tag" && r?.tag)
return `ui-hopyo-grid__tag${r.tag === HOPYO_TAG.fix ? " is-fix" : " is-pum"}`;
if (key === "note" && Number(r?.blank) > 0) return "ui-hopyo-grid__blank";
return null;
}
/** 공용 표 옵션(격자 꼴 그대로) — `extra` 로 누름 · 풍선 자리를 더함 */
export function hopyoTableOptions(
blocks: HopyoBlock[],
extra: Partial<DataTableOptions> = {},
): DataTableOptions {
const g = hopyoGrid(blocks);
return {
columns: g.columns,
rows: g.rows,
frozen: 1,
empty: "",
rowClass: (i) => (g.titles.has(i) ? "is-title" : null),
cellClass: (i, key) => hopyoCellClass(g.rows, i, key),
level: { key: "name", of: (i) => (g.titles.has(i) ? 1 : 2) },
...extra,
};
}
export interface HopyoGridHandle {
root: HTMLElement;
set: (blocks: HopyoBlock[]) => void;
}
/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */
export function createHopyoGrid(
blocks: HopyoBlock[],
extra: Partial<DataTableOptions> = {},
): HopyoGridHandle {
const root = document.createElement("div");
root.className = "ui-hopyo-grid";
const set = (next: HopyoBlock[]): void => {
root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root);
};
set(blocks);
return { root, set };
}
+2
View File
@@ -40,6 +40,7 @@ import { createCard } from "./ui_template_card";
import { createCollapsibleHead, createPanelCard } from "./ui_template_collapsible";
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "./ui_template_calc_row";
import { createDataTable } from "./ui_template_data_table";
import { createHopyoGrid } from "./ui_template_hopyo_grid";
import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart";
import { createEmpty } from "./ui_template_empty";
import { createLegend } from "./ui_template_flow_legend";
@@ -337,6 +338,7 @@ export const KIT_PARTS: KitPart[] = [
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
demo: (s) => (s === "default" ? kitCalc() : null) },
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);
const corner = createProgressCircle({ size: 40, corner: true }); corner.set(0.65, "지형 65%"); const box = document.createElement("div"); box.style.cssText = "position:relative;height:72px;border:1px dashed var(--color-border);border-radius:var(--radius-inputs)"; box.append(corner.root);