Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 # 부품 모음 견본
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 # 복사 생성 길만
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user