diff --git a/A00_Common/formula_doc_copy.ts b/A00_Common/formula_doc_copy.ts index b5ac3e499..5dc250758 100644 --- a/A00_Common/formula_doc_copy.ts +++ b/A00_Common/formula_doc_copy.ts @@ -11,6 +11,7 @@ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { showConfirmDialog } from "@ui/ui_template_elements"; +import type { Basis } from "../common_util/common_util_formula_doc_import"; import { ft } from "./formula_doc_text"; export type CopyJob = "단가갱신" | "재생성"; @@ -22,6 +23,8 @@ export interface CopyMark { 단가새값?: boolean; 문서새판?: string[]; 따로?: boolean; + /** 가져온 식(엑셀 불러오기 · 807-1) 단가기준 — 엑셀 = 엑셀 값 기억 · 우리 = 연결 값 */ + 단가기준?: Basis; } export interface CopyOptions { diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index 2ed7e96b1..d3183f97c 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -72,6 +72,8 @@ const TEXT = { Changed: ["바뀐 자원", "Changed resources"], Before_After: ["계", "Sum"], Job_Done: ["적용함", "Applied"], + Basis_Excel: ["엑셀 단가 그대로", "Excel prices"], + Basis_Ours: ["우리 단가로", "Our prices"], Dirty_First: [ "저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저", "Save or discard edits first", diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 2d1b6f594..42e216cdf 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -25,6 +25,8 @@ import { } from "@ui/ui_template_elements"; import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc"; import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { importView, type Basis } from "../common_util/common_util_formula_doc_import"; +import { createSegment } from "@ui/ui_template_segment"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; import { lineMap } from "../common_util/common_util_formula_doc_view"; @@ -170,7 +172,8 @@ export function createStackScreen(opt: StackScreenOptions) { }; const recalc = (): void => { if (!doc) return; - answer = calcDoc(doc, cache.links, usable()); + const basis = mark?.단가기준; // 가져온 식(807-1) — 서버 셈과 같은 보기 + answer = calcDoc(basis ? importView(doc, basis) : doc, cache.links, usable()); if (cache.takeMissing()) ask(); }; /** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함 */ @@ -275,6 +278,7 @@ export function createStackScreen(opt: StackScreenOptions) { }); const busy = dirty(); const tools = [ + ...(mark?.단가기준 && opt.page ? [basisPick(mark.단가기준)] : []), // 모달 = 머리 판 단추(793-3b) ...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []), ...(edit() && key && !opt.page ? [ @@ -380,6 +384,28 @@ export function createStackScreen(opt: StackScreenOptions) { if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done")); }; + /** 가져온 식 [엑셀 단가 그대로] · [우리 단가로](807-1) — 저장(서버가 그 기준으로 다시 셈) */ + const basisPick = (now: Basis): HTMLElement => { + const seg = createSegment({ + options: [ + { value: "엑셀", label: ft("Basis_Excel") }, + { value: "우리", label: ft("Basis_Ours") }, + ], + value: now, + size: 28, + onChange: (v) => { + if (v === now) return; + if (!edit() || dirty()) { + seg.set(now); + return void showToast(ft("Dirty_First"), "warning"); + } + void save({ 단가기준: v, 판: version }, ft("Job_Done")).then((ok) => ok || seg.set(now)); + }, + }); + seg.root.dataset.importBasis = now; + return seg.root; + }; + let loading: Promise = Promise.resolve(); const save = async (job?: Record, done = ft("Saved")): Promise => { await loading; // 새 로직 저장 뒤 여는 중이면 다 읽고(캐시 이어 받음) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css index 175dcb073..eb4724990 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css @@ -66,3 +66,8 @@ html:not([data-size="compact"], [data-size="medium"]) .m01-master__panel .m01-logic .m01lab-side { margin-inline-end: calc(-1 * (var(--spacing-12) + var(--spacing-24))); } + +/* 원문 패널 맨 위 기초 찾기(807-1) — 결과는 패널 몸을 다 먹지 않게 */ +.m01lab-src__find .ui-find__list { + max-height: 40vh; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts index fc83f2f02..d83df5440 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts @@ -3,12 +3,14 @@ * 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」(품셈 본문 절) * 공용 오른쪽 접는 패널(ui_template_side_panels) — 접힘 됨 · 접힌 패널 = 세로 글 띠 * 열림 · 폭은 취향 키(`m01-logic-side`) · 원문 = 공용 원문 보기(`LogicLab_Source`) + * 맨 위 「단가산출 기초 찾기」(807-1 · `LogicLab_SourceFind`) — 고르면 그 기초 원문 · 지우면 연 로직 원문 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { el } from "@ui/ui_template_elements"; import { createSourceView } from "./M01_MasterData_UI_LogicLab_Source"; +import { createSourceFind } from "./M01_MasterData_UI_LogicLab_SourceFind"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { @@ -27,20 +29,41 @@ export interface LogicSide { export function buildLogicSide(left: HTMLElement): LogicSide { const source = createSourceView(); let want = ""; + let found: string | null = null; // 찾기로 고른 기초 출처(지우면 연 로직으로) /** 원문 패널이 열려 있을 때만 찾음 */ const load = (): void => { - if (sp.current() === "source") source.show(want); + if (sp.current() === "source") source.show(found ?? want); }; + const find = createSourceFind({ + onPick: (x) => { + found = x.출처 ?? ""; + load(); + }, + onClear: () => { + found = null; + load(); + }, + }); const sp = createSidePanels({ prefKey: "m01-logic-side", defaultWidth: 520, minWidth: 320, - panels: [{ id: "source", title: ts("Source"), body: source.root }], + panels: [ + { + id: "source", + title: ts("Source"), + body: el("div", { className: "m01lab-src", children: [find.root, source.root] }), + }, + ], onChange: load, }); return { root: el("div", { className: "m01lab-side ui-side-panels-row", children: [left, sp.root] }), showSource: (src) => { + if (src !== want) { + find.clear(); + found = null; + } want = src; load(); }, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind.ts new file mode 100644 index 000000000..ea60d88f9 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_SourceFind.ts + * 원문 패널 맨 위 「단가산출 기초 찾기」(PLAN 807-1 · 요구 699) — M01 로직 상세 · M02 단가산출 원문 패널이 같이 씀. + * M01 로직 목록(로그인 길 `/m02/logic-lib/logics` · 처음 칠 때 한 번 읽음) · 낱말 맞춤 = 공용 `matchLogic` + * (키 · 원문번호 · 이름 · 구분 · 별칭) · 30 줄까지 · 고르면 `onPick`(부르는 쪽이 그 출처 원문을 보임 · 기억 안 함) + * 빈 칸 = 목록 비움 + `onClear`(부르는 쪽이 처음 화면으로) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createFindList } from "@ui/ui_template_find_list"; +import { matchLogic } from "@ui/ui_template_logic_find"; + +const TEXT = { + Find: ["단가산출 기초 찾기 — 이름 · 원문번호", "Find base build-up — name · clause"], + Many: ["{n} 개 중 30 개 — 낱말을 더 침", "Showing 30 of {n} — type more"], + None: ["맞는 기초 없음", "No match"], + Failed: ["기초 목록을 못 읽음", "Failed to load"], +} as const; +const ts = (key: keyof typeof TEXT, n = 0): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n)); + +export interface FoundLogic { + 키: string; + 원문번호?: string; + 이름: string; + 구분?: string; + 상세구분?: string; + 별칭?: string | null; + 결과단위?: string; + 출처?: string; +} + +const MAX = 30; +let all: Promise | null = null; // 한 화면 수명 동안 한 번(M01 로직 목록) +const logics = (): Promise => + (all ??= fetch(`${API_BASE_URL}/m02/logic-lib/logics`, { credentials: "include" }) + .then((r) => (r.ok ? (r.json() as Promise<{ logics: FoundLogic[] }>) : Promise.reject())) + .then((d) => d.logics) + .catch((e: unknown) => { + all = null; + throw e; + })); + +export function createSourceFind(opts: { + onPick: (logic: FoundLogic) => void; + onClear: () => void; +}): { root: HTMLElement; clear: () => void } { + const find = createFindList({ placeholder: ts("Find") }); + find.root.classList.add("m01lab-src__find"); + find.root.setAttribute("data-source-find", ""); + let seq = 0; + const run = async (): Promise => { + const q = find.search.value.trim(); + const mine = ++seq; + if (!q) { + find.setRows([]); + find.hint.textContent = ""; + return opts.onClear(); + } + let rows: FoundLogic[]; + try { + rows = await logics(); + } catch { + find.hint.textContent = ts("Failed"); + return; + } + if (mine !== seq) return; + const hit = rows.filter((x) => matchLogic(q, x).ok); + find.hint.textContent = hit.length > MAX ? ts("Many", hit.length) : hit.length ? "" : ts("None"); // prettier-ignore + find.setRows( + hit.slice(0, MAX).map((x) => ({ + key: x.키, + tag: x.원문번호 || x.키, + name: x.이름, + sub: [x.구분, x.상세구분].filter(Boolean).join(" · "), + unit: x.결과단위 ?? "", + onPick: () => { + find.setRows([]); + find.hint.textContent = `${x.원문번호 || x.키} ${x.이름}`; + opts.onPick(x); + }, + })), + ); + }; + find.search.addEventListener("input", () => void run()); + return { + root: find.root, + clear: () => { + find.search.value = ""; + find.setRows([]); + find.hint.textContent = ""; + }, + }; +} diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py new file mode 100644 index 000000000..59e98b857 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -0,0 +1,416 @@ +"""M02 가져온 식(엑셀 불러오기) 단가산출 = 수식 문서(PLAN 802-4b-1 · 요구 664 · 665) — 서버 단독. + +경쟁사 산근 블록도 같은 꼴(식 + 값)로 들어옴 — 엑셀 단가 그대로 엔진 줄(`가져온.로직` + SK 고침)을 +`master_doc_convert` 로 문서로 옮기고(손 값 = 숫자 연결 · 중간 = 식 줄 · 덧줄 = 금액 줄 식 · 끝수 · +단계 · 조정 · 합밖 · 자리 그대로) 이은 것을 연결로 얹음 · 연결마다 엑셀 값을 기억(`엑셀` 칸): + 인력 · 재료 · 유가 · 전력 · 환율 이음 = 요소 {키} · 기계 이음(EQ) = 기계경비 단가산출 + {키 GC, 입력 기계 …, 칸 = 그 줄 비목}(시간당 자료 없음 = 숫자 그대로 · 설명) · + 로직 이음 「로직(GC…)」 = 단가산출 {키, 입력} · 산근 속 산근(797-1 참조) = 단가산출 + {키 형제 SK, 참조 {층, 주인}, 칸?} · + 소요량 · 계수 고른 것(797-2 `가져온.연결.고름`) = 그 수 자리에 표 연결 기호(pct · kgt = ÷ 배). +문서 머리 `가져온` = {파일, 시트, 행, 갈래, 코드?, 표지(797-1a · 못 읽음 까닭 그대로), 알림?}. +셈(`run`) — 단가기준 엑셀(기본) = 엑셀 값 기억 연결을 숫자로 보고 셈 · 우리 = 연결 값으로 셈 · + 형제 SK 는 늘 연결(부르는 쪽 단가기준으로 · 안쪽이 바뀌면 따라감). +""" + +from __future__ import annotations + +import copy as cp +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Imported as imp + +XL = "엑셀" # 연결 칸 — 엑셀 값 기억 +HEAD = ("파일", "시트", "행", "갈래", "코드") +PICK = "소요량" # 고른 표 칸 기호 이름 머리 +_SCALE = {"eq": (1,), "pct": (100, 0.01), "kgt": (1000, 0.001)} + + +def is_imported(sk: Any) -> bool: + return imp.is_imported(sk) + + +def basis_of(sk: dict) -> str: + return "우리" if (sk.get(imp.KEY) or {}).get("단가기준") == "우리" else "엑셀" + + +# ── 엑셀 단가 그대로 엔진 줄(옛 셈 길 · SK 고침 얹은 것) ─────────────────── +def _ctx(sk: dict, whole: Any) -> tuple: + """엑셀 단가 기준 고친 줄 — 옛 셈 길(`OLD`)로 돌려 엔진에 넣은 (색인, 키, 입력, 답, 재료).""" + from M02_MasterTemplete import M02_Doc_Sk as dsk + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + raw = {**sk, imp.KEY: {**sk[imp.KEY], "단가기준": "엑셀"}} + box: list = [] + token = dsk.OLD.set(True) + try: + calc.run(sd.clean(raw, None, whole), whole, catch=box) + finally: + dsk.OLD.reset(token) + return box[-1] + + +# ── 소요량 · 계수 고른 칸 → 수 자리에 기호 ────────────────────────────── +def _swap_nth(node: Any, n: int, new: tuple, seen: list) -> Any: + """`M02_Sangeun_QtyLinks._literals` 차례의 n 번째 수 → `new` 마디(같은 걸음 규칙).""" + mf = dc.cv.mf + if not isinstance(node, tuple): + return node + kind = node[0] + if kind == "num": + seen[0] += 1 + return new if seen[0] - 1 == n else node + if kind == "bin": + cmp_ = node[1] in mf._OPS + sides = [ + side + if cmp_ and isinstance(side, tuple) and side[0] == "num" + else _swap_nth(side, n, new, seen) + for side in node[2:] + ] + return (kind, node[1], *sides) + if kind in ("neg", "field"): + return (kind, _swap_nth(node[1], n, new, seen), *node[2:]) + if kind == "call": + return ("call", node[1], [_swap_nth(a, n, new, seen) for a in node[2]]) + return node + + +def _conds(table: dict, at: int) -> dict | None: + """표 줄 차례 → 그 줄 하나만 맞는 조건 {조건: 값}(범위 = 위 끝 › 아래 끝) · 못 가림 = None.""" + rows = table.get("줄") or [] + if not (0 <= at < len(rows)): + return None + row, out = rows[at], {} + for cond, how in (table.get("조건") or {}).items(): + if cond not in row: + continue + have = row[cond] + if how == "범위": + low, high = have + have = high if high is not None else low + if have is None: + return None + out[cond] = have + try: + hit = dc.ml.find(table, out) + except Exception: # noqa: BLE001 — 못 가림 = 수 그대로 + return None + return out if hit is row else None + + +def _picks(row: dict, ids: list[str], base: list[str], meta: dict, master: Any) -> dict: + """고른 칸(`가져온.연결.고름`) → 중간 줄(표 찾기 하나 · 연결 기호) · 수 자리에 그 기호 ÷ 배 · + 돌려줌 = {중간 이름: 엑셀 칸 값}. 자리 줄이 SK 고침으로 바뀌었으면 건너뜀.""" + mf, mee = dc.cv.mf, dc.cv.mee + src = meta["로직"] + picked = ((meta.get("연결") or {}).get("고름")) or [] + out: dict[str, Any] = {} + mids = row.setdefault("중간", []) + taken = {str(x.get("이름")) for x in [*(row.get("입력") or []), *mids]} + for one in picked: + part, i, n = str(one.get("자리") or "::").split(":")[:3] + field = "수량" if part == "호표" else "식" + lines = src.get(part) or [] + if not (i.isdigit() and n.isdigit() and int(i) < len(lines)): + continue + cid = base[int(i) + (len(src.get("호표") or []) if part == "덧줄" else 0)] + if cid not in ids: + continue + at = ids.index(cid) + mine = (row["호표"] if at < len(row["호표"]) else row.get("덧줄") or [])[ + at if at < len(row["호표"]) else at - len(row["호표"]) + ] + if str(mine.get(field)) != str(lines[int(i)].get(field)): + continue # SK 덮개가 그 식을 바꿈 — 수 자리 어긋남 + try: + table = master.get(str(one.get("표"))) + conds = _conds(table, int(one.get("줄"))) + node = mf.parse(str(mine[field])) + except Exception: # noqa: BLE001 — 표 · 식 못 읽음 = 수 그대로 + continue + if conds is None: + continue + name = next(f"{PICK}{k}" for k in range(1, 999) if f"{PICK}{k}" not in taken) + taken.add(name) + scale = next( + (m for m in _SCALE.get(str(one.get("꼴")), (1,)) if _near(one, m)), None + ) # 엑셀 수 = 칸 값 ÷ 배 + if scale is None: + continue + ref = ( + ("name", name) + if scale == 1 + else ("bin", "/", ("name", name), ("num", Decimal(str(scale)))) + ) + mine[field] = mee.raw_text(_swap_nth(node, int(n), ref, [0])) + args = ", ".join(f"{k}={_lit(v)}" for k, v in conds.items()) + mids.append( + {"이름": name, "식": f"찾기({one['표']}{', ' if args else ''}{args}).{_q(one['칸'])}"} + ) + out[name] = Decimal(str(one.get("값"))) + return out + + +def _near(one: dict, m: float) -> bool: + cell, v = float(one.get("값") or 0), float(one.get("수") or 0) + return abs(cell - v * m) <= 1e-9 * max(1.0, abs(cell)) + + +def _lit(v: Any) -> str: + if isinstance(v, (int, float, Decimal)) and not isinstance(v, bool): + return dc.cv.mf._plain(Decimal(str(v))) + return "'" + str(v).replace("'", "") + "'" + + +def _q(name: Any) -> str: + return dc.cv.mee._q(str(name)) + + +# ── 이음 → 연결 ──────────────────────────────────────────────────────── +def hourly_link(eq: str, part: str) -> dict | None: + """기계(EQ) 시간당 그 비목 = 기계경비 단가산출 부름(`M02_Machine_Hourly` 와 같은 짝 고름) · + 자료 없음 = None.""" + from M02_MasterTemplete import M02_Machine_Hourly as machine + + if part not in imp.COSTS or not imp.hourly_of(eq).get("ok"): + return None + code = str(imp._whole().get(eq).get("원문번호") or "") + fit = [r for r in machine._family() if code in [str(c) for c in machine._codes(r)]] + if not fit: + return None + logic = min(fit, key=machine._rank) + given = {"기계": _lit(code)} + for spec in logic.get("입력") or []: + if spec.get("이름") != "기계": + given[spec["이름"]] = _lit((spec.get("고르기") or [""])[0]) + return {"갈래": "단가산출", "키": str(logic["키"]), "입력": given, "칸": part} + + +def _logic_link(text: str) -> dict | None: + """「로직(GC…, 이름='값')」 → 단가산출 연결 · 인자가 수 · 글 아니면 None.""" + try: + node = dc.cv.mf.parse(text) + except Exception: # noqa: BLE001 + return None + if node[0] != "logic" or any(v[0] not in ("num", "str") for _, v in node[2]): + return None + return {"갈래": "단가산출", "키": node[1], "입력": {k: _lit(v[1]) for k, v in node[2]}} + + +def _link_of(item: dict) -> tuple[dict | None, str]: + """가져온 줄 하나의 연결(엑셀 값 기억 없이) · 설명(못 이은 까닭).""" + if item.get("참조"): + ref = item["참조"] + link = { + "갈래": "단가산출", + "키": ref["키"], + "참조": {"층": ref["층"], "주인": ref.get("주인")}, + } + return link | ({"칸": item["참조비목"]} if item.get("참조비목") else {}), "" + key = str(item.get("이음") or "") + if not key: + return None, "" + if item.get("종류") == "로직": + return _logic_link(key), "" + if key.startswith("EQ"): + got = hourly_link(key, str(item.get("비목") or "")) + return got, "" if got else f"기계 {key} 시간당 경비 자료 없음 — 엑셀 단가 그대로" + link = {"갈래": "요소", "키": key} + if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음) + link["단위"] = str(item.get("단위") or "") + return link, "" + + +def _excel(item: dict) -> Any: + if item.get("값묶음"): + return {k: Decimal(str(item["값묶음"].get(k) or 0)) for k in imp.COSTS} + return Decimal(str(item["단가"])) if item.get("단가") is not None else None + + +def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None: + """금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로).""" + by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []} + for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]): + item = src.get(ids[at]) if at < len(ids) else None + price = str(line.get("단가") or "") + var = by_id.get(price.removesuffix(".계")) + if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자": + continue + excel = _excel(item) + if not item.get("참조") and var["연결"].get("값") != excel: + continue # SK 덮개 단가 = 사용자 값 + link, note = _link_of(item) + if note: + var["설명"] = note + if link is None: + continue + if item.get("참조비목") and isinstance(excel, dict): + excel = excel[item["참조비목"]] + line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"] + var["연결"] = {**link, XL: excel} + + +def _numbers(doc: dict, keep: dict) -> None: + """수 하나뿐인 식 줄(EST 얼린 변수) → 숫자 연결 · 고른 칸 중간 → 엑셀 칸 값 기억.""" + by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []} + lines = [] + for one in doc.get("식") or []: + var = by_id.get(str(one.get("기호"))) + try: + node = dc.md.parse(one.get("식")) + except dc.md.DocError: + node = None + if var is not None and node is not None and node[0] == "num": + var["연결"] = {"갈래": "숫자", "값": node[1]} + continue + lines.append(one) + doc["식"] = lines + for var in doc.get("변수") or []: + if var.get("열쇠") in keep and (var.get("연결") or {}).get("갈래") == "표": + var["연결"][XL] = keep[var["열쇠"]] + + +def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: + """가져온 식 SK → 수식 문서(키 = SK 키) — `ctx` = 엑셀 단가 기준 옛 셈 길이 엔진에 넣은 것.""" + meta = sk[imp.KEY] + if ctx is None: + ctx = _ctx(sk, whole or imp._whole()) + master, key, values, got, _ = ctx + row = cp.deepcopy(master.logic(key)) + ids = list(got.get("정체") or []) + bare = imp.row_of({**sk, imp.KEY: {**meta, "단가기준": "엑셀"}}) or {} + base = sd.line_ids(bare) + src = dict(zip(base, meta["로직"].get("호표") or [])) + keep = _picks(row, ids, base, meta, master) + given = dc.given_of(master, key, values) + doc, notes = dc.cv.convert(_Row(master, key, row), row, given) + _attach(doc, row, ids, src) + _numbers(doc, keep) + head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)} + marks = list((meta.get("원본") or {}).get("표지") or []) + if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")): + _empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님 + marks.append(EMPTY) + doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {}) + return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))} + + +EMPTY = "참고 · 빈 산근 — 엑셀 줄 없음 · 금액 0 그대로" + + +def _empty(doc: dict) -> None: + """줄 없는 엑셀 산근 — 0 묶음 줄 하나(엑셀 금액 0 = 줄 없는 합 · 지은 값 아님).""" + doc["변수"].append( + {"열쇠": "P_빈", "기호": "P", "아래": "빈", "위": "", "이름": "빈 산근", "단위": "", + "연결": {"갈래": "숫자", "값": dict.fromkeys(imp.COSTS, Decimal(0))}} + ) # fmt: skip + doc["금액"] = [{"이름": "빈 산근", "단위": "식", "비목": "묶음", "단가": "P_빈"}] + + +class _Row: + """변환 색인 — 이 SK 줄만 고친 줄(소요량 중간 더함)로 · 그 밖 그대로.""" + + def __init__(self, master: Any, key: str, row: dict) -> None: + self._m, self._k, self._r = master, key, row + + def logic(self, ref: str) -> dict: + return self._r if ref == self._k else self._m.logic(ref) + + def __getattr__(self, name: str) -> Any: + return getattr(self._m, name) + + +# ── 셈 ──────────────────────────────────────────────────────────────── +def view(doc: dict, basis: str) -> dict: + """단가기준 엑셀 = 엑셀 값 기억 연결(형제 SK 빼고)을 숫자로 · 우리 = 그대로 · + TS 짝 = `common_util/common_util_formula_doc_import.ts` `importView`(거울 시험 test_802_4b1).""" + if basis == "우리": + return doc + out = cp.deepcopy(doc) + for var in out.get("변수") or []: + link = var.get("연결") or {} + if XL in link and not str(link.get("키") or "").startswith("SK-"): + var["연결"] = {"갈래": "숫자", "값": link[XL]} + return out + + +class Links(dc.ml.Links): + """M01 연결(지금 M01 · 수식 문서 폴더) + 형제 가져온 식 SK(부르는 쪽 단가기준).""" + + def __init__(self, master: Any, basis: str, trail: tuple = ()) -> None: + super().__init__(master, dc.folder()) + self.basis, self.trail, self.mine = basis, trail, {} + + def doc(self, key: str) -> dict | None: + if not str(key).startswith("SK-"): + return super().doc(key) + return self.mine.get(key) + + def learn(self, doc: dict) -> None: + """문서의 형제 SK 연결 → 그 SK 문서를 읽어 둠(깊이 · 순환 = 못 읽음 · 표지는 계산기).""" + for var in doc.get("변수") or []: + link = var.get("연결") or {} + key, ref = str(link.get("키") or ""), link.get("참조") + if not key.startswith("SK-") or key in self.mine or not isinstance(ref, dict): + continue + full = {**ref, "키": key} + rid = refs.ref_id(full) + if rid in self.trail or len(self.trail) >= refs.DEPTH: + continue + sib = refs.read_ref(full) + if not is_imported(sib): + continue + self.mine[key] = None # 순환 막음 + try: + sub = view(of(sib), self.basis) + except Exception: # noqa: BLE001 — 못 지음 = 없는 단가산출(연결 끊김 표지) + continue + self.mine[key] = sub + self.trail = (*self.trail, rid) + self.learn(sub) + + +def of(sk: dict) -> dict: + """SK 의 문서 — 수식 길로 고친 것(따로) = 저장본 · 아니면 지금 줄에서 지음.""" + got = sk.get(dc.KEY) if dc.has(sk) else None + if got and got.get("따로") and isinstance(got.get("문서"), dict): + return got["문서"] + return derive(sk) + + +def record(doc: dict, sk: dict) -> dict[str, Any]: + """수식 탭 화면 캐시(`M02_Doc_Screen.record` 짝) — 단가기준 보기 문서 셈이 쓴 연결 · 형제 SK 문서.""" + import master_doc_record as mr # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤 + + basis = basis_of(sk) + links = Links(imp._whole(), basis) + mine = view(doc, basis) + rec = mr.Rec(links) + try: + links.learn(mine) + rec.prefetch(mine) + dc.mc.calc(mine, rec) + except Exception: # noqa: BLE001 — 셈 틀림 = 셈 답 표지 · 기록은 모은 데까지 + pass + return rec.out() + + +def run(doc: dict, sk: dict, master: Any = None) -> dict: + """가져온 식 문서 셈(SK 단가기준) — 계산기 = M01 과 같은 `master_doc_calc`.""" + basis = basis_of(sk) + links = Links(master or imp._whole(), basis) + mine = view(doc, basis) + try: + links.learn(mine) + return dc.mc.calc(mine, links) + except Exception as e: # noqa: BLE001 — 계산기 예외 없음이 원칙 · 남은 틈도 「—」 + return { + "셈됨": False, + "표지": [{"갈래": "셈 틀림", "글": str(e)}], + **dict.fromkeys(dc.COSTS), + } diff --git a/M02_MasterTemplete/M02_Doc_Migrate.py b/M02_MasterTemplete/M02_Doc_Migrate.py index 2836032c0..bf44cf518 100644 --- a/M02_MasterTemplete/M02_Doc_Migrate.py +++ b/M02_MasterTemplete/M02_Doc_Migrate.py @@ -4,7 +4,8 @@ 층 문서 — 전 = 옛 셈 길(`M02_Doc_Sk.OLD` · 로직 사본 엔진) · 후 = 수식 사본 문서 셈: SK = 기준(노 · 재 · 경 · 계) · SC · 일위대가 = `Labor_Doc.calc` 기준합 · 반영합(원 단위 · 반올림 6 자리). 같음 = 파일에 `수식` 칸만 더해 씀(그 밖 칸 그대로) · 다름 · 못 셈 = 안 씀(목록 · 까닭) · - 가져온 식 SK = 802-4b(그대로) · 바탕 참조 SK · 로직 사본 없는 문서 = 필요없음 · 이미 있음 = 있음. + 가져온 식 SK(807-1) = 가져온 줄 문서(`M02_Doc_Import`) · 전 = 옛 엔진 · 후 = 문서 셈 · + 바탕 참조 SK · 로직 사본 없는 문서 = 필요없음 · 이미 있음 = 있음. B09 사본 — `수식.json`(`M02_Doc_B09.build` · 만든 때 `단가.json` 줄 기준) 더함 · 견적 셈은 그대로 (`prices.calc` 전후 같음) · 문서 대조 = 일위대가 로직 줄마다 옛 엔진(`run_line`) 대 문서 셈 (M01 규칙 · 같은 사본 값) — 다르면 목록. @@ -75,15 +76,13 @@ def formula_of(kind: str, doc: dict) -> dict | None: def check(kind: str, path: Path) -> dict[str, Any]: - """층 문서 하나 — {상태: 있음 | 필요없음 | 가져온식 | 같음 | 다름 | 못셈, 전, 후, 수식?, 까닭?}.""" + """층 문서 하나 — {상태: 있음 | 필요없음 | 같음 | 다름 | 못셈, 전, 후, 수식?, 까닭?}.""" from M02_MasterTemplete import M02_MasterTemplete_Store as store doc = json.loads(path.read_bytes()) head = {"이름": doc.get("이름") or ""} if dc.has(doc): return {**head, "상태": "있음"} - if kind == "sangeun" and doc.get("가져온"): - return {**head, "상태": "가져온식", "까닭": "엑셀 가져온 식 = 802-4b 몫(옛 길 그대로)"} try: with store.at(path.parent.parent): copy = formula_of(kind, doc) @@ -97,7 +96,7 @@ def check(kind: str, path: Path) -> dict[str, Any]: if before == after: return {**row, "상태": "같음", "수식": copy} why = (mark or {}).get("까닭") or "전후 금액 다름 — 안 옮김" - if kind == "sangeun" and not (doc.get("바탕") or doc.get("더한줄")): + if kind == "sangeun" and not (doc.get("바탕") or doc.get("더한줄") or doc.get("가져온")): why = "빈 SK(바탕 · 줄 없음) — 옛 0 · 새 빈 문서 「—」(요구서_664 0 장 · 브레인 802-4a ③)" return {**row, "상태": "다름(빈 문서)", "까닭": why, "수식": copy} return {**row, "상태": "다름", "까닭": why} diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py index 935b87bb2..f248f7f1f 100644 --- a/M02_MasterTemplete/M02_Doc_Router.py +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -26,6 +26,7 @@ class DocBody(BaseModel): 끊음: dict[str, Any] | None = None 할일: str | None = None 판: str | None = None + 단가기준: str | None = None # 가져온 식만(807-1 · 엑셀 | 우리) @router.get("/layers/{layer}/sangeun/{key}/doc") diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py index bf114de29..292245c77 100644 --- a/M02_MasterTemplete/M02_Doc_Sk.py +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -9,7 +9,8 @@ SK `수식`(꼴 = `M02_Doc_Copy`) — `문서` = 이 SK 문서(키 = SK 키 · 따로 — 수식 길(`M02_Doc_Router`)로 문서를 고침: 저장한 문서가 정본 · 옛 칸 고침 저장 = 409 · [재생성] = 지금 M01 로 다시 찍어 파생으로 돌아감(고친 문서 버림). SK 금액(기준 · 1 단위 = 기준 ÷ 배) = 문서 셈 — 일위대가 · 사전조합 · 구조물 조합이 이 값을 씀. -가져온 식 SK(엑셀 · 바탕 null) = 옛 길 그대로(802-4b) · 바탕 참조 SK = 옛 길(값 사본 없음). +가져온 식 SK(807-1) = `M02_Doc_Import` 문서(파생 = 가져온 줄 + SK 고침에서 지음 · 따로 = +저장본) · 셈 = 단가기준(엑셀 = 엑셀 값 · 우리 = 연결 값) · 바탕 참조 SK = 옛 길(값 사본 없음). """ from __future__ import annotations @@ -21,6 +22,7 @@ from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Import as dim from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd @@ -35,8 +37,8 @@ SAVING: ContextVar[dict | None] = ContextVar("수식길저장", default=None) # def own(doc: Any) -> bool: - """수식 사본을 가질 SK — 가져온 식 · 바탕 참조 SK 빼고.""" - if not isinstance(doc, dict) or doc.get("가져온"): + """수식 사본을 가질 SK — 바탕 참조 SK 빼고(가져온 식 = 가져온 줄 문서 · 807-1).""" + if not isinstance(doc, dict) or (doc.get("가져온") and not dim.is_imported(doc)): return False return "참조" not in (doc.get("바탕") or {}) @@ -50,8 +52,11 @@ def _strip(v: Any) -> Any: def old_fp(doc: dict) -> str: - """옛 칸 지문 — 따로 문서 위 옛 칸 고침 막음.""" - return dc.fp(_strip({k: doc.get(k) for k in OLD_FIELDS if doc.get(k) not in (None, {}, [])})) + """옛 칸 지문 — 따로 문서 위 옛 칸 고침 막음(가져온 식 = 가져온 줄 · 고른 칸 · 이음도).""" + got = {k: doc.get(k) for k in OLD_FIELDS if doc.get(k) not in (None, {}, [])} + if dim.is_imported(doc): + got["가져온"] = {k: doc["가져온"].get(k) for k in ("로직", "연결")} + return dc.fp(_strip(got)) # ── 파생 — 엔진에 넣는 고친 줄 → 문서 ───────────────────────────────── @@ -90,7 +95,10 @@ def derive(sk: dict, ctx: tuple) -> dict: def seed(sk: dict) -> dict | None: - """수식 사본 없는 SK(옮기기 전 · 새로)의 씨 — 로직 사본 값 + 빈 부름 · 빈 SK = 빈 문서.""" + """수식 사본 없는 SK(옮기기 전 · 새로)의 씨 — 로직 사본 값 + 빈 부름 · 빈 SK = 빈 문서 · + 가져온 식 = 빈 부름(문서는 셈마다 지음).""" + if dim.is_imported(sk): + return {"부름": {}} if skc.has(sk): return {**dc.values_of(sk[skc.KEY]), "부름": {}} if sk.get("바탕") or sk.get("더한줄"): @@ -109,6 +117,13 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None: return None if got.get("따로") or ctx is None: return got + if dim.is_imported(sk): # 가져온 식 — 엑셀 단가 기준 줄에서 지음(우리 = 한 번 더 돎) + try: + same = dim.basis_of(sk) == "엑셀" + root = dim.derive(sk, ctx if same else None, dc.base(ctx[0])) + except dc._ERR as e: + root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)} + return {**got, "문서": root, "부름": {}} try: root = derive(sk, ctx) except dc._ERR as e: @@ -127,7 +142,7 @@ def on_run(sk: dict, ans: dict, ctx: tuple | None) -> dict: copy = copy_of(sk, ctx) if copy is None: return ans - got = dc.run(copy["문서"], copy) + got = run(sk, copy, ctx[0] if ctx else None) money = dc.money(got) base = {k: Decimal(str(v)) for k, v in money.items()} if money else None times = Decimal(str(ans.get("배") or 1)) @@ -138,6 +153,13 @@ def on_run(sk: dict, ans: dict, ctx: tuple | None) -> dict: return {**ans, "기준": base, "1단위": unit, "수식셈": mark} +def run(sk: dict, copy: dict, master: Any = None) -> dict: + """문서 셈 — 가져온 식 = 단가기준 · 형제 SK(`dim.run`) · 그 밖 = 값 사본(`dc.run`).""" + if dim.is_imported(sk): + return dim.run(copy["문서"], sk, dc.base(master) if master is not None else None) + return dc.run(copy["문서"], copy) + + # ── 저장 첫머리(옛 길 `clean_doc` 끝) ────────────────────────────────── def stored(key: Any) -> dict | None: from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -174,15 +196,15 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: "수식 문서로 고친 단가산출 — 옛 칸(덮개 · 줄) 고침은 수식 탭에서 · 또는 [재생성]", 409, ) - copy = dc.refresh(have)[0] if job == skc.PRICE else have + copy = dc.refresh(have)[0] if job == skc.PRICE and not dim.is_imported(out) else have return {**out, KEY: norm(copy)} # 빈 SK(바탕 · 줄 없음) = 빈 문서(요구서_664 0 장) · 수식 길로 고친 빈 SK = 위 따로(804-4) - if not skc.has(out): + if not skc.has(out) and not dim.is_imported(out): empty = seed(out) return {**out, KEY: norm(empty)} if empty else out box: list = [] calc.run(out, whole, catch=box) calls = {} if job == skc.REGEN else (have or {}).get("부름") or {} - first = {**dc.values_of(out[skc.KEY]), "부름": calls} + first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls} copy = copy_of({**out, KEY: first}, box[-1] if box else None) return {**out, KEY: norm({k: v for k, v in (copy or first).items() if k != "따로"})} diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index 690ec71d2..6ff696a0f 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -7,7 +7,8 @@ M01 수식 문서 길과 같은 꼴: 읽기 {문서, 셈} · 미리 셈 {문서} 저장 = 그 SK 저장(`store.write` — 층 쓰기 권한 · 판 409 · 옛 칸 · 로직 사본 그대로) · 문서 고침 = 따로(이 문서가 정본) · 재생성 = 지금 M01 로 다시 찍어 파생으로(고친 문서 · 끊음 버림) · 단가갱신 = 값 줄만 지금 M01(문서 그대로). -가져온 식 SK(엑셀) = 802-4b 몫(400) · 바탕 참조 SK = 값 사본 없음(400). +가져온 식 SK(807-1) = 가져온 줄 문서 · 셈 = 단가기준(엑셀 값 · 연결 값) · 부름 · 끊음 없음 + (형제 SK 는 셈 때 읽음) · 단가갱신 = 문서 그대로 · 바탕 참조 SK = 값 사본 없음(400). """ from __future__ import annotations @@ -16,6 +17,7 @@ import json from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Import as dim from M02_MasterTemplete import M02_Doc_Screen as screen from M02_MasterTemplete import M02_Doc_Sk as dsk from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -54,8 +56,6 @@ def current(doc: dict, job: Any = None) -> dict: from M02_MasterTemplete import M02_Sangeun_Calc as calc if not dsk.own(doc): - if doc.get("가져온"): - raise _bad("가져온 식 단가산출 — 수식 문서는 엑셀 불러오기 몫(802-4b)") raise _bad("바탕 참조 단가산출 — 수식 사본 없음") clean, whole = _settled(doc, job) if not dc.has(clean): # 옮기기 전 · 새 SK — 저장 때 지을 사본을 미리 @@ -72,18 +72,19 @@ def current(doc: dict, job: Any = None) -> dict: return got if got is not None else clean[dsk.KEY] -def answer(copy: dict) -> dict[str, Any]: - """{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴.""" - got = dc.run(copy["문서"], copy) +def answer(copy: dict, sk: dict) -> dict[str, Any]: + """{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴 · 가져온 식 표지 = 단가기준.""" + got = dsk.run(sk, copy) head = {k: copy.get(k) for k in ("시점", "단가시점")} - mark = {**head, "따로": bool(copy.get("따로")), **dc.state(copy)} + mark = {**head, "따로": bool(copy.get("따로"))} + mark |= {"단가기준": dim.basis_of(sk)} if dim.is_imported(sk) else dc.state(copy) body = { "문서": copy["문서"], "부름": copy.get("부름") or {}, "끊음": copy.get("끊음") or {}, "셈": got, "사본": mark, - "연결": screen.record(copy), # 화면 캐시(802-4b-2) + "연결": dim.record(copy["문서"], sk) if dim.is_imported(sk) else screen.record(copy), } return json.loads(json.dumps(dc.ml._norm(body), ensure_ascii=False, default=str)) @@ -91,7 +92,7 @@ def answer(copy: dict) -> dict[str, Any]: def read(key: str) -> dict[str, Any]: """읽기 — {문서, 부름, 끊음, 셈, 사본, 판}.""" got = _file(key) - return {**answer(current(got["문서"])), "판": got["판"]} + return {**answer(current(got["문서"]), got["문서"]), "판": got["판"]} # ── 몸 검사 ─────────────────────────────────────────────────────────── @@ -106,8 +107,8 @@ def _doc(raw: Any, key: str, where: str) -> dict: return {**raw, "키": key} -def edited(copy: dict, body: dict, key: str) -> dict: - """화면 고침 얹은 사본 — 문서 · 끊음 글만 받음(값 · 부름은 서버 것).""" +def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict: + """화면 고침 얹은 사본 — 문서 · 끊음 글만 받음(값 · 부름은 서버 것 · 가져온 식 = 부름 없음).""" if body.get("할일") not in (None, "", *JOBS): raise _bad("할일은 단가갱신 · 재생성") if body.get("할일") == skc.REGEN: @@ -127,8 +128,8 @@ def edited(copy: dict, body: dict, key: str) -> dict: *dc.calls(out["문서"]), *(c for d in out.get("끊음", {}).values() for c in dc.calls(d)), ] - whole = dc.over(out) - out["부름"] = dc.closure(whole, calls, copy.get("부름")) + if not dim.is_imported(sk): + out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름")) out["따로"] = True return out @@ -136,12 +137,13 @@ def edited(copy: dict, body: dict, key: str) -> dict: def preview(key: str, body: dict) -> dict[str, Any]: """미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}.""" doc = _file(key)["문서"] + mine = _basis(doc, body) before = current(doc) job = body.get("할일") or None - after = edited(current(doc, job) if job else before, body, key) - out = answer(after) - out |= {"전": dc.money(dc.run(before["문서"], before)), "후": dc.money(out["셈"])} - if job == skc.PRICE: + after = edited(current(doc, job) if job else before, body, key, doc) + out = answer(after, mine) + out |= {"전": dc.money(dsk.run(doc, before)), "후": dc.money(out["셈"])} + if job == skc.PRICE and not dim.is_imported(doc): out["바뀐자원"] = dc.refresh(before)[1] if job == skc.REGEN: out["버림"] = { @@ -151,16 +153,26 @@ def preview(key: str, body: dict) -> dict[str, Any]: return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str)) +def _basis(doc: dict, body: dict) -> dict: + """가져온 식 단가기준 바꿈(807-1 · 몸 `단가기준` 엑셀 | 우리) — 그 밖 SK · 빈 값 = 그대로.""" + want = body.get("단가기준") + if not want or not dim.is_imported(doc): + return doc + if want not in ("엑셀", "우리"): + raise _bad("단가기준은 엑셀 · 우리") + return {**doc, "가져온": {**doc["가져온"], "단가기준": want}} + + def save(key: str, body: dict) -> dict[str, Any]: """저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판}.""" got = _file(key) if str(body.get("판") or "") != got["판"]: raise _bad({"stale": [key], "판": got["판"]}, 409) job = body.get("할일") or None - raw = {**got["문서"], **({skc.JOB: job} if job else {})} + raw = {**_basis(got["문서"], body), **({skc.JOB: job} if job else {})} token = None if job != skc.REGEN and (body.get("문서") is not None or body.get("끊음") is not None): - mine = edited(current(got["문서"], job), body, key) + mine = edited(current(got["문서"], job), body, key, got["문서"]) token = dsk.SAVING.set({"키": key, "사본": mine}) try: store.write(sd.KIND, key, got["판"], raw) diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 7e1a19f30..68a1b45cd 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -137,6 +137,9 @@ class Builder: self.src, self.dry, self._sk = Path(source), False, None self.same, self.reused = lsame.Same(), False # 같은 내용 SC 찾기(한 요청 메모) self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓) + self.lenient = ( + False # 하나 들이기(807-1) = 이름 이음은 하되 남은 줄 = 고정 줄(엑셀 숫자 그대로) + ) self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝 # ── 재검사 ─────────────────────────────────────────────────────── @@ -300,7 +303,7 @@ class Builder: doc["본뜬"]["품셈"] = poomsem if self.dry: return {"남은줄": left} - if left and save and self.strict: + if left and save and self.strict and not self.lenient: raise store.StoreError(409, {"까닭": sangeun.LEFT, "남은줄": left}) fixed_n = sum(1 for x in lines if "고정" in x) links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values())) @@ -315,6 +318,7 @@ class Builder: ref, again, self.reused = hkids.save(doc, self.same) wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id) out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub} + out["남은줄"] = left out["장부"] = {"씀": wrote} return {**out, "줄수": {**count, "이음후보": linker.near}} @@ -365,8 +369,8 @@ class Builder: } -def _order(units: dict[int, dict[str, Any]]) -> list[int]: - """하위 호표 먼저(호표 줄 참조) · 고리는 끊음.""" +def _order(units: dict[int, dict[str, Any]], start: int | None = None) -> list[int]: + """하위 호표 먼저(호표 줄 참조) · 고리는 끊음 · `start` = 그 호표와 하위만.""" out: list[int] = [] seen: set[int] = set() @@ -380,20 +384,54 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]: visit(int(ref.get("번") or 0)) out.append(no) - for no in sorted(units): + for no in [start] if start is not None else sorted(units): visit(no) return out +def _imported(folder: Path, source: Path, key: str, nos: list[int], units: dict) -> dict[str, str]: + """하나 들이기(807-1) — 호표들이 부르는 단산 블록 = 이 층 가져온 식 SK(`FromHopyo.make` · 같은 내용 = + 있는 것) · 이미 본뜬 SK 가 있는 블록 · 산근 시트 없음 · 못 만듦 = 건너뜀(그 줄 = 고정) → {본뜬 키: SK}.""" + from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh + + if not fh.has(source, key): + return {} + have, out = sangeun.made(folder, key, refs.where(source)), {} + for no in nos: + for line in units[no].get("줄") or []: + ref = line.get("참조") or {} + if ref.get("종류") != "단산" or not ref.get("번"): + continue + mark_ = sangeun.mark(key, int(ref["번"])) + if mark_ in have or mark_ in out: + continue + try: + out[mark_] = fh.make(source, key, int(ref["번"]))["이름"] + except store.StoreError: + continue + return out + + def build_all( - folder: Path, source: Path, key: str, who: Any = None, strict: bool = False + folder: Path, + source: Path, + key: str, + who: Any = None, + strict: bool = False, + only: int | None = None, ) -> dict[str, Any]: """[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) · `strict`(불러오기 길 · 729-2) = 남은 줄 있는 호표는 안 만듦(`실패 {번, 까닭, 남은줄}`) · 붙여넣기(705-2)는 막지 않음 · 이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, - 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`.""" + 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`. + `only`(807-1 [내 일위대가로]) = 그 호표 + 하위만 · 부르는 단산 = 가져온 식 SK(`단가산출`) · + 못 이은 줄 = 고정 줄(엑셀 숫자 그대로 · 만듦 `남은줄` 수).""" units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} + if only is not None and int(only) not in units: + raise _bad(f"없는 호표 「제{only}호표」", 404) + nos = _order(units, None if only is None else int(only)) + sks = _imported(folder, source, key, nos, units) if only is not None else {} sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0) out: dict[str, Any] = { "호표": len(units), @@ -405,12 +443,17 @@ def build_all( "만듦": [], } shared, failed = lsame.Same(), set() - for no in _order(units): + if only is not None: + out["단가산출"] = sorted(set(sks.values())) + for no in nos: out["줄"] += len(units[no].get("줄") or []) bad = next((k for k in hkids.kids_of(units[no]) if k in failed), None) try: maker = Builder(folder, source, key, no, who) maker.strict, maker.same = strict, shared + if only is not None: + maker.lenient = True + maker._sk = {**sangeun.made(folder, key, maker.source), **sks} have = maker.done.get(mark(key, no)) if bad is not None and not have: # 777-1 — 하위 실패 연쇄 까닭 따로 raise store.StoreError(409, {"까닭": f"하위 제{bad}호표 실패", "하위": bad}) @@ -423,7 +466,10 @@ def build_all( for k in sums: sums[k] += made["줄수"][k] got = compare(ref, maker.hopyo) + left = len(made.get("남은줄") or []) if not have else 0 out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]}) + if only is not None: + out["만듦"][-1]["남은줄"] = left except store.StoreError as e: why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} out["실패"].append({"번": no, **why}) diff --git a/M02_MasterTemplete/M02_Hopyo_Clause.py b/M02_MasterTemplete/M02_Hopyo_Clause.py index a0c751232..6cc098efd 100644 --- a/M02_MasterTemplete/M02_Hopyo_Clause.py +++ b/M02_MasterTemplete/M02_Hopyo_Clause.py @@ -93,7 +93,7 @@ def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[st # ── 출처 풀이(781-1 · 781-2) — 건설 부문 · 임도 옛 번호 대응 · 기초 없음 ─────────── OLD = "(옛)" _ROAD = "임도품셈" + OLD -_ROAD_MAP = make.BODY.parent / "경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv" +_ROAD_MAP = make.BODY.parent / "경쟁사 프로그램/STmate/old/30_원자료/임도품셈_판_대응표.csv" _OLD_NO = re.compile(r"^(\d+(?:-\d+)+)(\s+[가-하])?$") _STOP_GRAMS = frozenset("설치 해체 작업 인력 기계 장비 보완 신설 공사".split()) diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index f0511e9da..9b364f12a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -388,11 +388,13 @@ class BuildBody(_Strict): class BuildAllBody(_Strict): - """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음).""" + """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음) · + `번` = 그 호표 하나 + 하위(807-1 [내 일위대가로]).""" 층: Layer 주인: str | None = None 키: str + 번: int | None = None @router.post("/layers/{layer}/hopyo-build-all") @@ -406,7 +408,14 @@ def hopyo_make_all( `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`.""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) - args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패 + args = ( + folder, + source, + body.키, + _who(session), + True, + body.번, + ) # 729-2 남은 줄 = 실패(번 = 고정) return _call(folder, hopyo_build.build_all, *args) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts index 380d65f83..5ceb88cb0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts @@ -399,9 +399,13 @@ export function createJudgeBlock(opt: JudgeBlockOptions): { const actions: HTMLElement[] = [ok.root]; const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly } })) }); // prettier-ignore open.dataset.judgeImported = String(blk.번); + // [내 단가산출로](807-1) — 늘 개인 층 · 만들 층이 개인이면 [가져온 식으로 열기]는 숨김(같은 일) + const mine = createButton({ label: L("M02_ToMySk"), size: 24, variant: "ghost", onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: { layer: "personal", owner: null }, canWrite: true } })) }); // prettier-ignore + mine.dataset.judgeMine = String(blk.번); + open.hidden = opt.target.layer === "personal" && !opt.target.owner; const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함) fresh.dataset.checkNew = ""; - actions.push(open, fresh); + actions.push(open, mine, fresh); if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`; const sub = [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Mine.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Mine.ts new file mode 100644 index 000000000..9e7a6475a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Mine.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Mine.ts + * 엑셀 보기 오른쪽 호표 목록 줄 [내 일위대가로](PLAN 807-1 · 요구 699) — `Hopyo_Sheet` 목록 줄이 붙임. + * 물음 → 서버 `POST /layers/personal/hopyo-build-all {층, 주인?, 키, 번}`(그 호표 + 하위 → 개인 층 SC · + * 부르는 단산 = 가져온 식 SK · 같은 내용 = 있는 것 · 못 이은 줄 = 고정 줄 엑셀 숫자 그대로) → 알림(만든 SC · 단가산출 수 · 엑셀과 같음) + * 개인 층은 누구나 씀 · 파일 층 읽기 권한은 서버가 봄 + * ========================================================================== */ + +import { + askConfirm, + createButton, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { at, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; + +interface MineReply { + 만듦: { 번: number; 키: string; 남은줄?: number }[]; + 다름: { 번: number }[]; + 실패: { 번: number; 까닭?: string }[]; + 단가산출?: string[]; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** [내 일위대가로] 단추 — `scope` = 파일 층 · `key` = HP 키 */ +export function mineButton(scope: HopyoScope, key: string, h: HopyoBrief): HTMLElement { + const b = createButton({ + label: L("M02_ToMySc"), + variant: "ghost", + size: "sm", + onClick: async (e) => { + e.stopPropagation(); + if (!(await askConfirm({ message: L("M02_ToMyScAsk").replace("{n}", String(h.번)) }))) return; + const file = { 층: scope.layer, ...(scope.owner ? { 주인: scope.owner } : {}) }; + showLoadingOverlay(); + try { + const got = await call( + at({ layer: "personal", owner: null }, "/hopyo-build-all"), + { + method: "POST", + body: JSON.stringify({ ...file, 키: key, 번: h.번 }), + }, + ); + const made = got.만듦.find((x) => x.번 === h.번); + if (!made) throw new Error(got.실패.map((x) => x.까닭).join(" · ") || "실패"); + const same = got.다름.some((x) => x.번 === h.번) ? "다름" : "같음"; + const text = L("M02_ToMyScDone") + .replace("{k}", made.키) + .replace("{s}", String(got.단가산출?.length ?? 0)) + .replace("{same}", same); + showToast(text, same === "같음" ? "success" : "warning"); + } catch (error) { + showToast(why(error), "error"); + } finally { + hideLoadingOverlay(); + } + }, + }); + b.setAttribute("data-hopyo-mine", String(h.번)); + return b; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index e41d7201c..f52e79a20 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -29,6 +29,7 @@ import { type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check"; +import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine"; import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows"; import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; @@ -282,6 +283,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { children: [ el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), editButton(h), + mineButton(opt.scope, opt.key, h), // [내 일위대가로](807-1) el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), linked, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index c3262d5e9..070beb1a1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -126,7 +126,7 @@ export function mountSangeunDetail( .문서 as SkDoc; if (!alive) return; const was = JSON.parse(saved) as SkDoc; - doc.수식 = was.수식 = got.수식; + [doc.수식, was.수식, doc.가져온, was.가져온] = [got.수식, got.수식, got.가져온, got.가져온]; // 807-1 단가기준 saved = JSON.stringify(was); touch(); } catch { @@ -426,10 +426,11 @@ export function mountSangeunDetail( onInput, cover: { ho, open: lineOpener(hoCtx) }, }); + // prettier-ignore side?.set([ ...(base ? [tabOf(base.logic)] : []), ...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])), - ]); + ], doc.가져온); } /** 재료 고름(줄 모달) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts index f75ee3381..f2b558871 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -4,15 +4,23 @@ * 오른쪽 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 로그인 길 `/m02/logic-lib/source`) * 기초마다 탭 — 바탕 로직 · 둘째 기초(더한줄 로직) 각각의 출처 · 기초가 하나면 탭 없음 · 없으면 안내 * 처음 = 넓은 화면만 열림(좁으면 세로 띠로 접힘) · 열림 · 폭 기억 = `m02-sangeun-side` + * 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) · + * 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드 + * (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음) * 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬). * ========================================================================== */ import { createSegment } from "@ui/ui_template_segment"; import { el } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; +import { createButton } from "@ui/ui_template_button"; +import { createCard } from "@ui/ui_template_card"; +import { createKv } from "@ui/ui_template_kv"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { createSourceFind } from "../M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind"; +import { originOf, type Origin, type OriginMeta } from "./logic/M02_Sangeun_Origin"; import { tc } from "./M02_MasterTemplete_Labor_Text"; export interface SourceTab { @@ -34,16 +42,83 @@ export const tabOf = (logic: { /** 이 폭 이상일 때만 처음부터 열림 — 상세 표 여섯 칸이 좁아지지 않게 */ const OPEN_FROM = 1500; +const TEXT = { + Origin: ["가져온 단가산출 — 어디서 왔나", "Imported build-up — where from"], + Program: ["프로그램", "Program"], + File: ["파일", "File"], + Block: ["블록", "Block"], + Rows: ["행", "Rows"], + When: ["가져온 때", "Imported"], + Marks: ["표지", "Flags"], + MarksN: ["{n} 개", "{n}"], + Clauses: ["엑셀 글의 품셈 정보", "Standard clauses in the Excel text"], + NoClause: ["엑셀 글에 품셈 절 글 없음", "No standard clause in the Excel text"], + Hint: ["우리 기초 원문은 위 찾기로", "Find our base above to see its source"], + More: ["표지 모두 보기", "Show all flags"], +} as const; +const tx = (k: keyof typeof TEXT, n = 0): string => + TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n)); + +/** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */ +function originCard(o: Origin): HTMLElement { + const marks = el("div"); + const showMarks = (all: boolean): void => + marks.replaceChildren( + ...(all ? o.표지 : o.표지.slice(0, 3)).map((m) => createText(m, "muted", 12)), + ...(!all && o.표지.length > 3 + ? [createButton({ label: tx("More"), variant: "ghost", size: 24, onClick: () => showMarks(true) })] // prettier-ignore + : []), + ); + showMarks(false); + const clauses = o.품셈.length + ? o.품셈.map((c) => + createKv([{ label: `${c.책} ${c.절}`, value: c.행 !== undefined ? `${c.행}행 · ${c.글}` : c.글 }]), + ) // prettier-ignore + : [createText(tx("NoClause"), "muted", 12)]; + const card = createCard({ + title: tx("Origin"), + pad: 12, + body: [ + createKv([ + { label: tx("Program"), value: o.프로그램 }, + { label: tx("File"), value: o.파일 }, + { label: tx("Block"), value: o.블록 }, + { label: tx("Rows"), value: o.행 }, + { label: tx("When"), value: o.때 }, + { label: tx("Marks"), value: tx("MarksN", o.표지.length) }, + ]), + marks, + el("strong", { text: tx("Clauses") }), + ...clauses, + createText(tx("Hint"), "muted", 12), + ], + }); + card.setAttribute("data-sangeun-origin", ""); + return card; +} + export function createSangeunSide(): { wrap: (main: HTMLElement) => HTMLElement; - set: (tabs: SourceTab[]) => void; + /** 원문 탭 · 가져온 식 머리(`가져온` — 있으면 첫 화면 = 카드) */ + set: (tabs: SourceTab[], imported?: unknown) => void; } { const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf", }); - const host = el("div", { attrs: { "data-sangeun-source": "" } }); + const content = el("div"); + let found: string | null = null; // 찾기로 고른 기초 출처(지우면 처음 화면) + const find = createSourceFind({ + onPick: (x) => ((found = x.출처 ?? ""), paint()), + onClear: () => ((found = null), paint()), + }); + const host = el("div", { + className: "m01lab-src", + attrs: { "data-sangeun-source": "" }, + children: [find.root, content], + }); let tabs: SourceTab[] = []; + let origin: Origin | null = null; let at = 0; const side = createSidePanels({ prefKey: "m02-sangeun-side", @@ -54,8 +129,13 @@ export function createSangeunSide(): { }); function paint(): void { if (side.current() !== "source") return; // 접힘 = 찾지 않음 + if (found !== null) { + view.show(found); + return void content.replaceChildren(view.root); + } + if (origin) return void content.replaceChildren(originCard(origin)); if (!tabs.length) - return void host.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted")); + return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted")); at = Math.min(at, tabs.length - 1); view.show(tabs[at].src); const bar = @@ -70,13 +150,14 @@ export function createSangeunSide(): { }).root, ] : []; - host.replaceChildren(...bar, view.root); + content.replaceChildren(...bar, view.root); } return { wrap: (main) => el("div", { className: "ui-side-panels-row", children: [main, side.root] }), - set: (next) => { - if (JSON.stringify(next) === JSON.stringify(tabs)) return; - tabs = next; + set: (next, imported) => { + const card = originOf(imported as OriginMeta | null); + if (JSON.stringify([next, card]) === JSON.stringify([tabs, origin])) return; + [tabs, origin] = [next, card]; paint(); }, }; diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index e97ec5d64..2bbe189b9 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_now_text from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_San_Emit as em @@ -171,6 +172,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> from M02_MasterTemplete import M02_Sangeun_Imported as imp out = emit(source, key, no) + out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1) _link_inner(source, key, no, out, _trail) try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦 from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts new file mode 100644 index 000000000..b92758ef8 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts @@ -0,0 +1,92 @@ +/* ============================================================================= + * M02_Sangeun_Origin.ts + * 가져온 식 단가산출 「어디서 왔나」 카드 자료(PLAN 807-1 · 요구 699) — 원문 패널 첫 화면. + * SK `가져온.원본`(파일 · 시트 · 행 · 갈래 EST|STmate · 코드 · 표지 · 가져온때 · 글줄)에서 보이기 자료만 뽑음 · + * 품셈 절 글 = 엑셀 글줄에 적힌 「…품셈 N-N-N」 글(책 · 절 번호 · 그 줄 글) — 찾기 · 잇기 없음(보이기만) + * 화면 · DOM · fetch 없음 + * ========================================================================== */ + +export interface OriginMeta { + 로직?: unknown; + 원본?: { + 파일?: string; + 시트?: string; + 행?: number[]; + 갈래?: string; + 코드?: string | number; + 표지?: string[]; + 가져온때?: string; + 글줄?: { 행?: number; 글?: string }[]; + }; +} + +export interface Clause { + 책: string; + 절: string; + 글: string; + 행?: number; +} + +export interface Origin { + 프로그램: string; + 파일: string; + 블록: string; + 행: string; + 때: string; + 표지: string[]; + 품셈: Clause[]; +} + +const BOOKS: [RegExp, string][] = [ + [/건설/, "건설표준품셈"], + [/산림/, "산림표준품셈"], + [/임도/, "임도품셈"], +]; +/** 「건설공사표준품셈8-1-3」 · 「산림사업표준품셈 10-4」 · 「[2025년 표준품셈3-4-8]」 · 「임도품셈 5-1-2」 */ +const CLAUSE = + /((?:건설(?:공사)?|산림(?:사업)?|임도)?\s*(?:표준\s*)?품셈)\s*[[(]?\s*(?:제\s*)?(\d{1,2}(?:-\d{1,3}){1,3})/g; + +/** 글줄 → 품셈 절(책 · 절 같은 것은 처음 하나) */ +export function clausesOf(lines: { 행?: number; 글?: string }[] = []): Clause[] { + const out: Clause[] = []; + const seen = new Set(); + for (const line of lines) { + const text = String(line.글 ?? "") + .replace(/\s+/g, " ") + .trim(); + for (const m of text.matchAll(CLAUSE)) { + const book = BOOKS.find(([re]) => re.test(m[1]))?.[1] ?? "표준품셈"; + const key = `${book} ${m[2]}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ 책: book, 절: m[2], 글: text, ...(line.행 !== undefined ? { 행: line.행 } : {}) }); + } + } + return out; +} + +const pad = (n: number): string => String(n).padStart(2, "0"); +/** 서버 때(UTC 글) → 이 컴퓨터 시각 「YYYY-MM-DD HH:mm」 · 못 읽음 = 빈 글 */ +const localTime = (s?: string): string => { + const at = new Date(s ?? ""); + if (!s || Number.isNaN(at.getTime())) return ""; + return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`; +}; + +/** 가져온 식이 아니면 null */ +export function originOf(meta: OriginMeta | null | undefined): Origin | null { + if (!meta?.로직 || !meta.원본) return null; + const src = meta.원본; + const code = src.코드 ?? ""; + const block = src.갈래 === "EST" && code !== "" ? `단산${code}` : String(code); + const rows = (src.행 ?? []).filter((x) => Number.isFinite(x)); + return { + 프로그램: src.갈래 ?? "", + 파일: src.파일 ?? "", + 블록: block, + 행: rows.length ? `${src.시트 ? `${src.시트} ` : ""}${rows.join("~")}행` : (src.시트 ?? ""), + 때: localTime(src.가져온때), + 표지: [...(src.표지 ?? [])], + 품셈: clausesOf(src.글줄), + }; +} diff --git a/common_util/common_util_formula_doc_import.ts b/common_util/common_util_formula_doc_import.ts new file mode 100644 index 000000000..30fcfb3d3 --- /dev/null +++ b/common_util/common_util_formula_doc_import.ts @@ -0,0 +1,24 @@ +/* ============================================================================= + * common_util_formula_doc_import.ts + * 가져온 식(엑셀 불러오기) 수식 문서 단가기준 보기(PLAN 802-4b-1 · 요구 664) + * [엑셀 단가 그대로](기본) = 연결 칸 `엑셀`(엑셀 값 기억)이 있는 변수를 숫자 연결로 보고 셈 · + * [우리 단가로] = 문서 그대로(연결 값) · 형제 단가산출(SK-…) 연결은 늘 연결(부르는 쪽 단가기준) + * 파이썬 · TS 짝 — 짝 = M02_MasterTemplete/M02_Doc_Import.py `view` · 거울 시험 + * test_802_4b1_import_doc.py(고칠 때 둘을 같이 고침) · 화면 · DOM · fetch 없음 + * ========================================================================== */ + +import type { DocVar, FormulaDoc, Link } from "./common_util_formula_doc"; + +export const EXCEL = "엑셀"; +export type Basis = "엑셀" | "우리"; + +/** 단가기준 보기 — 엑셀 = 엑셀 값 기억 연결(형제 SK 빼고)을 숫자로 · 우리 = 그대로(새 문서) */ +export function importView(doc: FormulaDoc, basis: Basis): FormulaDoc { + if (basis === "우리") return doc; + const vars = (doc.변수 ?? []).map((v: DocVar): DocVar => { + const link = (v.연결 ?? {}) as Link & Record; + if (!(EXCEL in link) || String(link.키 ?? "").startsWith("SK-")) return v; + return { ...v, 연결: { 갈래: "숫자", 값: link[EXCEL] } }; + }); + return { ...doc, 변수: vars }; +} diff --git a/resources/tester/test_802_4b1_import_doc.py b/resources/tester/test_802_4b1_import_doc.py new file mode 100644 index 000000000..e1c141ff8 --- /dev/null +++ b/resources/tester/test_802_4b1_import_doc.py @@ -0,0 +1,307 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""802-4b-1 엑셀 불러오기 → 수식 문서(요구 664 · 665 · 요구서_664 0 장) — 임시 storage · M01 그대로. + +① 가져온 식 SK = 수식 문서(손 값 = 숫자 · 이음 = 연결 + 엑셀 값 기억 · 중간 = 식 줄 · + 얼린 변수 = 숫자 · 금액 줄 = 노무비 · 재료비 · 경비 · 단계 · 끝전 · 머리 `가져온` 표지) +② [엑셀 단가 그대로] = 숫자 값 셈 = 엑셀 · [우리 단가로] = 연결 값 셈(기계 = 시간당 그 비목) +③ 산근 속 산근 = 형제 SK 단가산출 연결(안쪽이 바뀌면 따라감 · 부르는 쪽 단가기준) +④ 소요량 · 계수 고른 칸 = 그 수 자리에 표 연결 기호(금액 그대로) +⑤ SK 금액 = 문서 셈 = 옛 엔진 셈 · 수식 길 읽기 · 고침(따로) · 옛 칸 고침 409 · 옮기기 대조 +실무 엑셀 없으면 그 시험만 skip. +""" + +from __future__ import annotations + +import copy +import json +import subprocess +from decimal import Decimal +from pathlib import Path + +import helper_m02_hopyo_golden as golden +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Doc_Import as dim +from M02_MasterTemplete import M02_Doc_Migrate as mig +from M02_MasterTemplete import M02_Doc_Sk as dsk +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Machine_Hourly as machine +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +KEY = "HP-000001" + + +def _doc(items: list[dict], extra: dict | None = None, src: dict | None = None) -> dict: + row = {"이름": "견본 sub2임시", "결과단위": "원/식", "호표": items, **(extra or {})} + meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": {}, **(src or {})}} + return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta} + + +def _item(kind: str, name: str, price: float, part: str, link: str | None = None, qty_="1"): + out = {"종류": kind, "이름": name, "단위": "식", "수량": qty_, "비목": part, "단가": price} + return out | ({"이음": link} if link else {}) + + +def _ours(doc: dict) -> dict: + return {**doc, imp.KEY: {**doc[imp.KEY], "단가기준": "우리"}} + + +def _var(doc: dict, part: str) -> dict: + """금액 줄 `이름` 의 단가 기호 변수.""" + line = next(x for x in doc["금액"] if x["이름"] == part) + sym = str(line["단가"]).removesuffix(".계") + return next((v for v in doc["변수"] if dim.dc.md.var_id(v) == sym), {}) + + +def _eq(good: bool) -> str: + for row in m01.loaded()[1].numbers["EQ"].values(): + if bool(machine.hourly(row["키"]).get("ok")) is good: + return row["키"] + pytest.skip("EQ 견본 없음") + + +# ── ① ② 꼴 · 두 단가기준 ────────────────────────────────────────────────── +def test_doc_form_numbers_links_and_both_bases() -> None: + eq = _eq(True) + items = [ + _item("인력", "보통인부", 100000, "노무비", "LB000002", "0.5"), + _item("인력", "작업반장", 150000, "노무비", None, "0.1"), + _item("기계", "굴착기", 30000, "경비", eq, "2"), + ] + mids = {"중간": [{"이름": "Qd", "식": "600"}, {"이름": "Q", "식": "Qd / 8"}]} + marks = ["참고 · 금액 줄 못 읽음 · 보인 값 얼림", "추정 · 단가 후보 여럿"] + doc = _doc(items, mids, {"표지": marks, "갈래": "EST", "파일": "견본.xls"}) + root = dim.derive(doc) + assert root["꼴"] == "수식문서" and root["키"] == "SK-000001" + assert root["가져온"]["표지"] == marks and root["가져온"]["갈래"] == "EST" # 까닭 그대로 + labor = _var(root, "보통인부")["연결"] + assert labor == {"갈래": "요소", "키": "LB000002", "엑셀": Decimal(100000)} + assert _var(root, "작업반장")["연결"] == {"갈래": "숫자", "값": Decimal(150000)} # 이음 없음 + mach = _var(root, "굴착기")["연결"] + assert mach["갈래"] == "단가산출" and mach["칸"] == "경비" and mach["엑셀"] == Decimal(30000) + qd = next(v for v in root["변수"] if v["열쇠"] == "Qd") # 얼린 변수 = 숫자 · 식 = 식 줄 + assert qd["연결"] == {"갈래": "숫자", "값": Decimal(600)} + assert [x["식"] for x in root["식"]] == [f"{dim.dc.md.var_id(qd)} ÷ 8"] + excel = dim.run(root, doc) + assert excel["계"] == Decimal(50000 + 15000 + 60000) # 엑셀 단가 그대로 + ours = dim.run(root, _ours(doc)) + lb = Decimal(str(m01.loaded()[1].get("LB000002")["값"])) + hour = machine.hourly(eq)["경비"] + assert ours["노무비"] == int(lb * Decimal("0.5") * 10) / Decimal(10) + 15000 + assert float(ours["경비"]) == pytest.approx(hour * 2, abs=1) # 기계 = 시간당 그 비목 + + +def test_machine_without_hourly_keeps_number_with_note() -> None: + eq = _eq(False) + doc = _doc([_item("기계", "리퍼", 5000, "경비", eq)]) + var = _var(dim.derive(doc), "리퍼") + assert var["연결"] == {"갈래": "숫자", "값": Decimal(5000)} and eq in var["설명"] + assert dim.run(dim.derive(doc), _ours(doc))["계"] == 5000 + + +def test_sk_money_is_doc_calc_and_equals_old_engine(world) -> None: + """⑤ SK 금액 = 문서 셈(수식셈 표지) = 옛 엔진 셈 — 엑셀 · 우리 둘 다 · 덧줄 율 · 합밖.""" + items = [ + _item("인력", "보통인부", 100000, "노무비", "LB000002", "0.33"), + _item("재료", "경유", 1700, "재료비", "MO000002", "3") | {"합밖": True}, + ] + extra = {"덧줄": [{"이름": "공구손료", "식": "노무비 * 0.03", "비목": "경비"}]} + doc = _doc(items, extra) + for basis in ("엑셀", "우리"): + mine = imp._with(doc, 단가기준=basis) + new = imp._money(imp._run(mine, imp._whole())) + token = dsk.OLD.set(True) + try: + old = imp._money(imp._run(mine, imp._whole())) + finally: + dsk.OLD.reset(token) + assert new == old, basis + got = imp.to_price(doc, "우리") + assert got["전"]["계"] == 33000 + 990 and got["후"]["계"] != got["전"]["계"] + + +# ── ③ 산근 속 산근 · 실무 STmate · EST ────────────────────────────────────── +def _hp(world, tag: str): + path_ = golden.FILES.get(tag) + if path_ is None or not path_.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + folder = world["personal"] + hs._dir(folder).mkdir(parents=True, exist_ok=True) + (hs._dir(folder) / f"{KEY}.json").write_text(f'{{"키": "{KEY}", "판": {hs.BOOK}}}', "utf-8") + fh.keep(folder, KEY, path_.name, path_.read_bytes()) + return folder + + +def _read(key: str) -> dict: + return store.read("sangeun", key)["문서"] + + +def test_stmate_nested_is_sibling_link(world) -> None: + folder = _hp(world, "울진대흥") + with store.at(folder): + made = fh.make(folder, KEY, 2200) # D02200 안에 D00075 콘크리트믹서사용 + doc = _read(made["이름"]) + root = dim.derive(doc) + assert root["가져온"]["갈래"] == "STmate" and root["가져온"]["코드"] == "D02200" + inner = _var(root, "콘크리트믹서사용")["연결"] + assert inner["갈래"] == "단가산출" and inner["키"].startswith("SK-") + assert inner["엑셀"]["노무비"] == 6216 # 엑셀 얼린 값 기억 + assert dim.run(root, doc)["계"] == 67383 == made["검산"]["엑셀"]["계"] + assert dim.run(root, _ours(doc))["셈됨"] is True + sub = _read(inner["키"]) # 안쪽 단가 줄 하나 두 배 → 바깥이 따라감(엑셀 기준도) + one = next(x for x in sub[imp.KEY]["로직"]["호표"] if "단가" in x) + one["단가"] = one["단가"] * 2 + store.write("sangeun", inner["키"], store.read("sangeun", inner["키"])["판"], sub) + now = dim.run(dim.derive(_read(inner["키"])), _read(inner["키"]))["계"] + assert now != 7528 + assert dim.run(root, doc)["계"] == pytest.approx(67383 - 7528 + now, abs=1) + + +def test_est_by_part_and_frozen_vars(world) -> None: + folder = _hp(world, "북부청임도") + with store.at(folder): + made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름 + doc = _read(made["이름"]) + root = dim.derive(doc) + parts = [ + (x["비목"], _var(root, x["이름"])["연결"].get("칸")) + for x in root["금액"] + if (_var(root, x["이름"]).get("연결") or {}).get("키", "").startswith("SK-") + ] + assert parts == [("노무비", "노무비"), ("재료비", "재료비"), ("경비", "경비")] + assert not any(x["식"].strip().replace(".", "").isdigit() for x in root["식"]) + excel = dim.run(root, doc) + assert float(excel["계"]) == pytest.approx(float(made["검산"]["엑셀"]["계"]), abs=1) + assert dim.run(root, _ours(doc))["셈됨"] is True + + +# ── ④ 소요량 · 계수 고른 칸 ───────────────────────────────────────────────── +def test_qty_pick_becomes_table_link(world) -> None: + folder = _hp(world, "북부청임도") + found = None + for no in range(1, 200): + try: + out = fh.emit(folder, KEY, no) + except store.StoreError: + continue + if "야면석채집및운반(계곡내)" in out["로직"]["이름"]: + found = out + break + if found is None: + pytest.skip("야면석채집및운반(계곡내) 블록 없음") + row = found["로직"] + doc = _doc(row["호표"], {k: row[k] for k in ("중간", "덧줄", "단계", "끝전") if row.get(k)}) + doc[imp.KEY]["로직"] |= {k: row[k] for k in ("이름", "결과단위")} + at = next(i for i, x in enumerate(row["호표"]) if x["이름"] == "보통인부") + cell = next(c for c in qty.view(doc)["줄"] if c["자리"] == f"호표:{at}:0")["후보"][0] + kept = qty.pick(doc, f"호표:{at}:0", {k: cell[k] for k in ("표", "줄", "칸")})["문서"] + root = dim.derive(kept) + pick = next(v for v in root["변수"] if v["열쇠"].startswith(dim.PICK)) + assert pick["연결"]["갈래"] == "표" and pick["연결"]["표"] == cell["표"] + assert pick["연결"]["칸"] == cell["칸"] and pick["연결"][dim.XL] == Decimal(str(cell["값"])) + line = next(x for x in root["금액"] if x["이름"] == "보통인부") + assert dim.dc.md.var_id(pick) in line["수량"] + before = dim.run(dim.derive(doc), doc)["계"] + assert dim.run(root, kept)["계"] == before # 엑셀 그대로 금액 같음 + assert dim.run(root, _ours(kept))["셈됨"] is True # 표 칸 = 같은 값 + + +# ── ⑤ 수식 길 · 옮기기 ─────────────────────────────────────────────────── +def test_formula_route_edit_then_old_edit_409_and_regen(world) -> None: + folder = world["personal"] + doc = _doc([_item("인력", "보통인부", 100000, "노무비", "LB000002", "0.5")]) + with store.at(folder): + key = imp.create(doc[imp.KEY]["로직"], {"엑셀금액": {"계": 50000}}, "sub2임시 견본")["이름"] + read = path.read(key) + assert read["셈"]["계"] == 50000 and read["사본"]["단가기준"] == "엑셀" + edit = copy.deepcopy(read["문서"]) + edit["금액"][0]["수량"] = "1" + pre = path.preview(key, {"문서": edit}) + assert pre["전"]["계"] == 50000 and pre["후"]["계"] == 100000 + path.save(key, {"문서": edit, "판": read["판"]}) + again = path.read(key) + assert again["사본"]["따로"] is True and again["셈"]["계"] == 100000 + sk = _read(key) + moved = imp.link(sk, sd_id(sk), None)["문서"] # 이음 풂 = 옛 칸 고침 → 409 + with pytest.raises(store.StoreError) as why: + store.write("sangeun", key, store.read("sangeun", key)["판"], moved) + assert why.value.status == 409 + ours = imp.to_price(sk, "우리")["문서"] # 단가기준만 바꿈 = 됨(문서 그대로 · 연결 값) + store.write("sangeun", key, store.read("sangeun", key)["판"], ours) + lb = Decimal(str(m01.loaded()[1].get("LB000002")["값"])) + assert path.read(key)["셈"]["노무비"] == int(lb * 10) / Decimal(10) + + +def sd_id(sk: dict) -> str: + return imp.sd.line_ids(imp.row_of(sk))[0] + + +def test_migrate_imported_same(world) -> None: + folder = world["personal"] + doc = _doc([_item("인력", "보통인부", 100000, "노무비", "LB000002", "0.5")]) + with store.at(folder): + key = imp.create(doc[imp.KEY]["로직"], {"엑셀금액": {"계": 50000}}, "sub2임시 옮김")["이름"] + file = folder / "sangeun" / f"{key}.json" + got = mig.check("sangeun", file) + assert got["상태"] in ("같음", "있음") + if got["상태"] == "같음": + assert got["수식"]["문서"]["키"] == key and got["전"] == got["후"] + + +# ── TS 짝(importView) 거울 ───────────────────────────────────────────── +_NODE = """ +const fs = require("fs"), path = require("path"); +const ts = require(path.join(process.cwd(), "config", "node_modules", "typescript")); +const at = path.join(process.cwd(), "common_util", "common_util_formula_doc_import.ts"); +const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const got = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(got.map(([d, b]) => box.exports.importView(d, b)))); +""" + + +def test_ts_view_mirror() -> None: + eq = _eq(True) + items = [ + _item("인력", "보통인부", 100000, "노무비", "LB000002", "0.5"), + _item("인력", "작업반장", 150000, "노무비", None, "0.1"), + _item("기계", "굴착기", 30000, "경비", eq, "2"), + ] + root = dim.derive(_doc(items)) + root["변수"].append( + {"열쇠": "S", "기호": "S", "연결": {"갈래": "단가산출", "키": "SK-000009", "엑셀": 5}} + ) + plain = json.loads(json.dumps(root, ensure_ascii=False, default=float)) + cases = [[plain, b] for b in ("엑셀", "우리")] + out = subprocess.run( + ["node", "-e", _NODE], + input=json.dumps(cases, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + cwd=Path(__file__).resolve().parents[2], + check=True, + ) + ts_ = json.loads(out.stdout) + py = [json.loads(json.dumps(dim.view(plain, b), ensure_ascii=False)) for b in ("엑셀", "우리")] + assert ts_ == py + assert _var(ts_[0], "보통인부")["연결"] == {"갈래": "숫자", "값": 100000.0} + assert ts_[0]["변수"][-1]["연결"]["갈래"] == "단가산출" # 형제 SK = 늘 연결 + + +def test_empty_block_is_excel_zero_not_dash() -> None: + """줄 없는 엑셀 산근 = 0 원(옛 엔진 · 엑셀과 같음) · 표지 · 우리 단가로도 0.""" + doc = _doc([]) + root = dim.derive(doc) + assert dim.EMPTY in root["가져온"]["표지"] + assert dim.run(root, doc)["계"] == 0 == dim.run(root, _ours(doc))["계"] + assert imp.check(doc)["셈"]["계"] == 0 diff --git a/resources/tester/test_807_1_import_doc_one.py b/resources/tester/test_807_1_import_doc_one.py new file mode 100644 index 000000000..aa7b32492 --- /dev/null +++ b/resources/tester/test_807_1_import_doc_one.py @@ -0,0 +1,160 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""807-1 엑셀 불러오기 = 바뀐 구조(요구 699) — 임시 storage · M01 그대로. + +① 임도품셈 판 대응표 CSV 새 자리(폴더 재편) +② 단가산출 하나 = 개인 층 가져온 식 SK → 수식 길(줄 쌓기 「수식」 탭) 읽힘 · 금액 = 엑셀 · + [우리 단가로] · [엑셀 단가 그대로] = 수식 길 저장 `단가기준` +③ 일위대가 하나 = 개인 층 SC + 부르는 단산 = 가져온 식 SK(중복이면 있는 것) · 금액 = 엑셀 +④ 원문 패널 「어디서 왔나」 카드 · 품셈 절 글(TS `originOf`) · 기초 찾기 칸 이음 +실무 엑셀 없으면 그 시험만 skip. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import helper_m02_hopyo_golden as golden +import pytest + +from M02_MasterTemplete import M02_Doc_Import as dim +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Clause as clause +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] + + +def test_road_map_csv_new_place() -> None: + assert clause._ROAD_MAP.is_file() and "STmate/old/30_원자료" in clause._ROAD_MAP.as_posix() + assert clause.road_map() + + +def _hp(world, tag: str = "북부청임도") -> tuple[Path, str]: + src = golden.FILES.get(tag) + if src is None or not src.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + folder, data = world["personal"], src.read_bytes() + with store.at(folder): + parsed = hx.read_book(src.name, data) + row = hs.save(folder, src.name, parsed, {"이름": "sub3임시"}, None, hs.fingerprint(data)) + fh.keep(folder, row["키"], src.name, data) + return folder, row["키"] + + +def _sk(key: str) -> dict: + return store.read("sangeun", key)["문서"] + + +# ── ② 단가산출 하나 ───────────────────────────────────────────────────── +def test_one_sangeun_formula_route_and_basis(world) -> None: + folder, hp = _hp(world) + with store.at(folder): + made = fh.make(folder, hp, 5) # 단산5 콘테이너운반 — 기계 시간당 이음 + key = made["이름"] + doc = _sk(key) + assert dim.is_imported(doc) and doc["가져온"]["원본"]["가져온때"] + read = path.read(key) # 줄 쌓기 「수식」 탭 길(옛 400 「802-4b 몫」 없어짐) + assert read["사본"]["단가기준"] == "엑셀" + assert float(read["셈"]["계"]) == pytest.approx(float(made["검산"]["엑셀"]["계"]), abs=1) + assert read["연결"] is not None + ours = path.save(key, {"단가기준": "우리", "판": read["판"]}) + assert ours["사본"]["단가기준"] == "우리" and ours["셈"]["셈됨"] is True + back = path.save(key, {"단가기준": "엑셀", "판": ours["판"]}) + assert back["셈"]["계"] == read["셈"]["계"] + with pytest.raises(store.StoreError): + path.save(key, {"단가기준": "남의", "판": back["판"]}) + + +# ── ③ 일위대가 하나 ───────────────────────────────────────────────────── +def test_one_hopyo_to_personal_sc_and_sks(world) -> None: + folder, hp = _hp(world) + with store.at(folder): + got = hb.build_all(folder, folder, hp, None, True, 3) # 제3호표 임도표지석 — 단산 셋 + assert [x["번"] for x in got["만듦"]] == [3] and got["같음"] == 1 and not got["실패"] + sks = got["단가산출"] + assert len(sks) == 3 and all(dim.is_imported(_sk(k)) for k in sks) + sc = store.read("precombo", got["만듦"][0]["키"])["문서"] + refs = {(x.get("참조") or {}).get("키") for x in sc["담은로직"]} + assert set(sks) <= refs # SC 줄 = 그 SK 참조 + again = hb.build_all(folder, folder, hp, None, True, 3) # 다시 = 있는 것(그대로 · 같은 SK) + assert again["그대로"] == 1 and sorted(again["단가산출"]) == sorted(sks) + assert len(list((folder / "sangeun").glob("SK-*.json"))) == 3 + with pytest.raises(store.StoreError): + hb.build_all(folder, folder, hp, None, True, 9999) + + +# ── ④ 원문 패널 ───────────────────────────────────────────────────────── +_NODE = """ +const fs = require("fs"), path = require("path"); +const ts = require(path.join(process.cwd(), "config", "node_modules", "typescript")); +const at = path.join(process.cwd(), "M02_MasterTemplete", "logic", "M02_Sangeun_Origin.ts"); +const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const got = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(got.map((m) => box.exports.originOf(m)))); +""" + + +def _origin(metas: list) -> list: + out = subprocess.run( + ["node", "-e", _NODE], + input=json.dumps(metas, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + cwd=ROOT, + check=True, + ) + return json.loads(out.stdout) + + +def test_origin_card_and_clauses(world) -> None: + folder, hp = _hp(world) + with store.at(folder): + meta = _sk(fh.make(folder, hp, 5)["이름"])["가져온"] + stm = { + "로직": {}, + "원본": { + "갈래": "STmate", + "코드": "D02200", + "글줄": [ + {"행": 3, "글": "4. 기초지정[2025년 표준품셈3-4-8]"}, + {"행": 4, "글": "임도품셈 5-1-2 준용"}, + {"행": 5, "글": "임도품셈 5-1-2 다시"}, + ], + }, + } + est, st, plain = _origin([json.loads(json.dumps(meta, default=str)), stm, {"이름": "우리"}]) + assert est["프로그램"] == "EST" and est["블록"] == "단산5" and est["파일"].endswith(".xls") + assert est["행"].startswith("단가산출 ") and est["때"] and est["표지"] + books = [(c["책"], c["절"]) for c in est["품셈"]] + assert ("산림표준품셈", "10-4") in books and ("건설표준품셈", "8-1-3") in books + assert st["블록"] == "D02200" + assert [(c["책"], c["절"], c["행"]) for c in st["품셈"]] == [ + ("표준품셈", "3-4-8", 3), + ("임도품셈", "5-1-2", 4), + ] + assert plain is None # 우리 SK = 카드 없음(원문 먼저) + + +def test_source_find_on_m01_and_m02_panels() -> None: + m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts").read_text("utf-8") + m01 = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text("utf-8") + assert "createSourceFind" in m02 and "createSourceFind" in m01 + assert "originOf" in m02 + sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Hopyo_Mine.ts").read_text("utf-8") + block = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts").read_text("utf-8") + assert "/hopyo-build-all" in sheet and "번: h.번" in sheet + assert 'target: { layer: "personal", owner: null }' in block and "openImported" in block + detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text("utf-8") + assert "doc.가져온);" in detail # 원문 패널에 가져온 머리를 넘김 diff --git a/ui_template/ui_template_locale_m2_judge.ts b/ui_template/ui_template_locale_m2_judge.ts index c78205157..e6c7e6579 100644 --- a/ui_template/ui_template_locale_m2_judge.ts +++ b/ui_template/ui_template_locale_m2_judge.ts @@ -58,6 +58,16 @@ export const ui_locales_m2_judge = { M02_CheckTitle: ["확인 — 제{n}호표", "Check — item {n}"], M02_CheckNew: ["새", "New"], M02_CheckImported: ["가져온 식으로 열기", "Open as imported formula"], + M02_ToMySk: ["내 단가산출로", "To my build-ups"], + M02_ToMySc: ["내 일위대가로", "To my unit costs"], + M02_ToMyScAsk: [ + "제{n}호표와 그 하위 호표 · 부르는 단가산출을 개인 층으로 가져올까?", + "Bring item {n}, its sub-items and called build-ups into your personal layer?", + ], + M02_ToMyScDone: [ + "{k} 만듦 · 단가산출 {s} · 엑셀과 {same}", + "{k} made · build-ups {s} · vs Excel {same}", + ], M02_CheckNewHint: ["「새」 = 785 에서 더한 기능", "「New」 = added in 785"], M02_CheckApart: ["별도계상 · 품목은 프로젝트에서", "Separate · item chosen in project"], M02_CheckExcelPrice: ["엑셀 단가", "Excel unit price"],