diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 89ce8a19f..154bfaae6 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -33,6 +33,8 @@ `{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음). 수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수}, 자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`). +785-5 — 확인한 줄 몸 = `M02_Hopyo_Make.Lines`(옛 대상 꼴 `put` · 새 고른 것 꼴 `picks` = 몸 `{차례: {고른}}`) · +여기는 모두 고정 길 · 줄 차례 · 쓰기(`Builder` · `_order` · `_origins` · `build_all` = B09 빌림). """ from __future__ import annotations @@ -234,82 +236,17 @@ class Builder: self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]], save: bool = True ) -> dict: """`save` 거짓 = 사전조합 안 씀 · `{문서, 빠진줄, 줄수}`(704-4 [구조물 조합으로]).""" + from M02_MasterTemplete import M02_Hopyo_Make as hmake # 확인한 줄 몸(785-5) + stamp = utc_now_text() hopyo, file = self.hopyo, self.doc.get("파일") or "" - lines, mats, missing, used, mat_lines = [], {}, [], set(), {} - nation: dict[tuple, dict[str, Any]] = {} - taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(self.strict and save), [], set(), 0 - left: list[dict[str, Any]] = [] - where = {"파일": file, "호표": self.no} rows = hopyo.get("줄") or [] - - def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: - if "이음" in one: - others = [x for x in rows if x is not line] - lines.append(hfix.line_of(line, others, where, taken, linker, one["이음"])) - return - qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) - memo = _memo(line, one) - if "전국" in one: - at = (one["이름"], one["규격"], one["단위"]) - have = nation.get(at) - hand = qty + Decimal(str(have["수량"]["손"])) if have else qty - head = {"이름": at[0], "규격": at[1], "단위": at[2], "수량": {"손": _num(hand)}} - nation[at] = {**head, **(hmat.marks(line, at[0]) if not have else { - k: have[k] for k in labor.MARKS if k in have - })} # fmt: skip - missing.append(_missing(line, NATIONAL)) - return - if "자재" in one: - have = mats.get(one["자재"]) - hand = qty + Decimal(str(have["수량"]["손"])) if have else qty - name = self.mat_names.get(one["자재"], "") - mats[one["자재"]] = { - "코드": one["자재"], - "수량": {"손": _num(hand)}, - **( - have - and {k: have[k] for k in labor.MARKS if k in have} - or hmat.marks(line, name) - ), - } - if not have: - mat_lines[one["자재"]] = line - return - if "호표" in one: - kids.add(one["호표"]) - ref = { - "층": refs.here()[0], - "주인": refs.here()[1], - "키": self.done.get(mark(self.key, one["호표"]), "SC-미리"), - } - lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) - return - row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} - kept, why = sklink.kept(self, line, one, qty, alias, memo) - if why: - left.append(sklink.left(line, why)) - if kept is not None: - lines.append(kept) - return - if one["로직"] not in used: - used.add(one["로직"]) - lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}}) - return - at = int(line.get("차례", 0)) - wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 - "이름": f"{hopyo['이름']} — {line.get('이름')}", - "단위": line.get("단위") or None, - "구분": ORIGIN, - "상세구분": file, - "담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}], - "자재": [], - "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), - } - ref = ( - {"키": "SC-미리"} if self.dry else hkids.save(wrap, self.same)[0] - ) # 미리 보기 = 쓰기 없음 - lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + where = {"파일": file, "호표": self.no} + body = hmake.Lines(self, rows, where, stamp, hfix.Linker(self.strict and save)) + lines, mats, missing, left = body.lines, body.mats, body.missing, body.left + taken, linker, kids, put = body.taken, body.linker, body.kids, body.put + nation, mat_lines = body.nation, body.mat_lines + poomsem, kid_n = [], 0 book: dict[str, dict[str, Any]] = {} if whole: @@ -318,6 +255,12 @@ class Builder: book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole) for at, line in enumerate(rows): got = checked.get(at) + if got and "고른" in got: # 새 길 고른 것(785-5 · Make 가 검사 · 다시 셈) + poomsem.append(at) + body.picks(at, line, got["고른"]) + pairs = [hmake.ledger_pick(p) for p in got["고른"]] + book[ledger.key(line)] = self._row(line, "짝", pairs) + continue if whole: missing.append(_missing(line, WHOLE)) continue diff --git a/M02_MasterTemplete/M02_Hopyo_Judge.py b/M02_MasterTemplete/M02_Hopyo_Judge.py new file mode 100644 index 000000000..eb8efa60c --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Judge.py @@ -0,0 +1,340 @@ +"""M02 엑셀 불러오기 판정 · 검산(PLAN 785-5 · 요구 642 · 설계 642-A 2-6 ⑤ · 2-9) — 서버 단독 · 읽기만. + +판정 = 1차 줄 판(`M02_Hopyo_Row.rows`) + 단산 줄 2차 블록 풀이(`Clause.basis` 후보 → `Solve.parts` → +`Solve.block`) · 목록(`listing`) · 호표 하나(`hopyo`) · 블록 하나(`block`). +블록 풀이 = 파생 저장본(HP 폴더 `_derived/hopyo_judge_{키}` · 입력 지문 = HP + M01 파일 내용 + 풀이 판 · +지문 다를 때만 다시 셈 · GET 은 안 지움 · 올림 뒤 한 번 뒤에서 `ensure` · 셈 중 = `셈중` · 판정 None) · +블록 판정 읽기 = `judged`(Marks 칠) · [끝내기] = `forget`. +줄 판 줄에 엑셀 칸(이름 · 규격 · 단위 · 단가) · 블록에 엑셀 금액 · 절에 몫(785-6 화면이 읽음). +검산(`check`) = 고른 것마다 — 기초 = 입력값(`inputs` · 엔진이 쓴 칸 모두 · 지역 · 자재지역 · 조종원직종 +비움) · 입력필요 · 엑셀 단위 줄과 품 대조(`Solve.pair`) · 금액(엔진 한 번 · 입력 필요면 셈 안 함) / +그 밖 = 「확인」 · 수량 = 엑셀 × 몫 × 배(`Row.quantity` · 사용자 배 우선). +저장 없음 · 지어 넣은 값 없음(입력 필요 칸은 비워 둠 · 안 쓰는 칸 = 기초 기본값). +""" + +from __future__ import annotations + +import shutil +import threading +from collections import Counter +from decimal import Decimal +from pathlib import Path +from typing import Any + +from common_util import common_util_derived_store as derived +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Build as hbuild +from M02_MasterTemplete import M02_Hopyo_Clause as clause +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Row as hrow +from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun +from M02_MasterTemplete import M02_Hopyo_Solve as solve +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mf, ma = hb.mf, hb.ma +EXCEL = ("이름", "규격", "단위", "단가") +CHECKED = "확인" +KIND = "hopyo_judge_" # 파생 저장본 종류 = 이것 + HP 키(HP 폴더 `_derived/`) +VERSION = "785-5.1" # 풀이 꼴이 바뀌면 올림 +_LOCK, _RUNNING = threading.Lock(), set() + + +def master() -> mf.Master: + return hb.m01.cm.master() + + +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + +def _blocks(book: dict[str, Any]) -> dict[int, dict[str, Any]]: + return {int(b["번"]): b for b in book.get("단산") or [] if b.get("번") is not None} + + +def _unit(book: dict[str, Any], no: int) -> dict[str, Any]: + got = next((h for h in book.get("호표") or [] if int(h["번"]) == int(no)), None) + if got is None: + raise _bad(f"없는 호표 「제{no}호표」", 404) + return got + + +def _made(folder: Path, key: str, to: Path) -> tuple[dict[str, str], dict[str, str]]: + """(만든 SC {본뜬 키: SC}, 만든 SK {본뜬 키: SK}) — 만들 층 · 이 파일 층 것만.""" + source = refs.where(folder) + return hbuild.confirmed(to, source), sangeun.made(to, key, source) + + +# ── 기초 입력값 ─────────────────────────────────────────────────────── +def inputs(m: mf.Master, code: str, given: dict[str, Any] | None) -> tuple[dict, list[str]]: + """(저장 입력값, 입력필요) — 고른 값(그 기초 칸 · 빈 값 뺌) + 안 쓰는 칸 = 기초 기본값 · + 쓰는데 빈 칸 = 입력필요(비워 둠) · 지역 · 자재지역 · 조종원직종 = 비움(프로젝트가 정함).""" + row = m.logic(code) + names = [s["이름"] for s in hb.specs_of(row)] + have = { + k: v + for k, v in (given or {}).items() + if k in names and k not in hb.SKIP and v not in (None, "") + } + need = [n for n in names if n in hb._needed(m, code, row, have) and n not in hb.SKIP] + base = ma.defaults(m, row) + idle = [n for n in names if n not in have and n not in need and n not in hb.SKIP] + full = {n: hb.plain(base[n]) for n in idle if base.get(n) is not None} + return {**full, **{k: hb.plain(v) for k, v in have.items()}}, need + + +def money(m: mf.Master, code: str, given: dict[str, Any]) -> dict[str, Any]: + """기초 한 줄 셈(일위대가 로직 줄과 같은 길 `Labor_Doc.run_line` · 끝수 · 비운 지역 칸 = 자동값 · + 참고 셈만) → {노무비, 재료비, 경비, 계} · 못 셈 = {계 None, 까닭}.""" + got = labor.run_line(m, code, given) + if got.get("결과") is not None: + return {"결과": hb.plain(got["결과"])} + if got.get("기준") is None: + return {"계": None, "까닭": got.get("까닭")} + return {k: hb.plain(v) for k, v in got["기준"].items()} + + +# ── 판정 ───────────────────────────────────────────────────────────── +def _row(line: dict[str, Any], one: dict[str, Any]) -> dict[str, Any]: + return {**one, **{k: line.get(k) for k in EXCEL}} + + +def _solve(m: mf.Master, blk: dict, line: dict | None, same: list[dict]) -> dict[str, Any]: + """블록 풀이 — 조항 색인 후보 → 절 → 검산 · 판정 + 근거 · 엑셀 금액 · 절 몫.""" + basis = clause.basis(blk) + parts = solve.parts(blk, basis["후보"]) + got = solve.block(m, blk, parts, {"줄": line, "같은줄": same}) + for sec, part in zip(got["절"], parts, strict=True): + share = part.get("몫") + sec["몫"] = share.get("값") if isinstance(share, dict) else share + sec["번"] = part.get("번") + got["근거"] = {k: basis[k] for k in ("표지", "글", "출처", "후보", "뺌")} + got["금액"] = blk.get("금액") + return got + + +# ── 블록 풀이 파생 저장본(올림 뒤 한 번 · 뒤에서) ───────────────────────── +def _stamp(key: str, book: dict[str, Any]) -> str: + """입력 지문 — HP(지문 · 판 · 올린때) + M01 첫 층 파일 내용 + 풀이 판.""" + folder = Path(hb.m01.FOLDER) + files = { + p.name: derived.file_digest(p) + for p in sorted(folder.glob("*.json")) + if not p.name.startswith("_") + } + head = (VERSION, key, book.get("지문"), book.get("판"), book.get("올린때")) + return derived.derived_key(*head, files) + + +def _caller(book: dict[str, Any], no: int) -> tuple[dict | None, list[dict]]: + """블록을 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) — 일위대가 밖 = (None, []).""" + line = sangeun.calls(book)[1].get(no) + if not line: + return None, [] + unit = _unit(book, line["_호표"]) + same = [x for x in unit.get("줄") or [] if x.get("차례") != line.get("차례")] + return {k: v for k, v in line.items() if k != "_호표"}, same + + +def solved(folder: Path, key: str) -> dict[str, Any] | None: + """저장본 `{번 글: 풀이}` — 지문 다르거나 없으면 None(GET 은 안 지움).""" + book = hopyo_store.read(folder, key) + got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book)) + return got["value"] if got else None + + +def solve_all(folder: Path, key: str) -> dict[str, Any]: + """블록 모두 풀어 저장본으로(부르는 첫 줄 기준).""" + book = hopyo_store.read(folder, key) + m, out = master(), {} + for no, blk in sorted(_blocks(book).items()): + out[str(no)] = _solve(m, blk, *_caller(book, no)) + root = hopyo_store._dir(folder) + derived.write_derived(root, KIND + key, _stamp(key, book), out, keep=1) + return out + + +def _work(folder: Path, key: str) -> None: + try: + solve_all(folder, key) + except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본) + pass + finally: + with _LOCK: + _RUNNING.discard(f"{folder}|{key}") + + +def _start(folder: Path, key: str) -> None: + tag = f"{folder}|{key}" + with _LOCK: + if tag in _RUNNING: + return + _RUNNING.add(tag) + threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start() + + +def ensure(folder: Path, key: str) -> bool: + """저장본 있음 = 참 · 없으면 뒤 셈 시작(화면 안 막음 · 같은 파일 한 번) = 거짓.""" + if solved(folder, key) is not None: + return True + _start(folder, key) + return False + + +def judged(folder: Path, key: str) -> dict[int, dict[str, Any]] | None: + """블록 판정 `{번: {판정, 고를수}}` — 셈 중 = None(뒤 셈 시작 · Marks 칠이 부름).""" + got = solved(folder, key) + if got is None: + _start(folder, key) + return None + return {int(no): {"판정": v["판정"], "고를수": v["고를수"]} for no, v in got.items()} + + +def forget(folder: Path, key: str) -> None: + """[끝내기] — 그 HP 저장본 지움.""" + shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True) + + +def listing(folder: Path, key: str, to: Path) -> dict[str, Any]: + """호표 목록(1차 판정 수 · 만든 SC) · 블록 목록(부름 · 표지 · 판정 · 고를수 · 만든 SK) · + `셈중` = 블록 풀이 뒤 셈 중(블록 판정 · 고를수 None).""" + book = hopyo_store.read(folder, key) + rows = hrow.rows(book, folder, ledger.doc_id(folder, key)) + sc, sk = _made(folder, key, to) + units = [] + for h in book.get("호표") or []: + no = int(h["번"]) + count = Counter(r["판정"] for r in rows.get(h["번"]) or []) + head = {"번": no, **{k: h.get(k) for k in ("이름", "규격", "단위", "단가")}} + units.append( + {**head, "줄수": len(h.get("줄") or []), "판정": dict(count), + "만든": sc.get(hbuild.mark(key, no))} + ) # fmt: skip + call, done = sangeun.callers(book), judged(folder, key) + blocks = [] + for no, b in sorted(_blocks(book).items()): + got = (done or {}).get(no) or {"판정": None, "고를수": None} + blocks.append( + {"번": no, "이름": b.get("이름"), "단위": b.get("단위"), "금액": b.get("금액"), + "부름": call.get(no, []), "표지": clause.basis(b)["표지"], **got, + "만든": sk.get(sangeun.mark(key, no))} + ) # fmt: skip + out = {"키": key, "파일": book.get("파일"), "셈중": done is None} + return {**out, "호표": units, "블록": blocks} + + +def hopyo(folder: Path, key: str, no: int, to: Path) -> dict[str, Any]: + """호표 하나 — 줄 판(+ 엑셀 칸) · 단산 줄 `풀이`(부르는 첫 줄이면 저장본).""" + book = hopyo_store.read(folder, key) + unit = _unit(book, no) + rows = hrow.rows({**book, "호표": [unit]}, folder, ledger.doc_id(folder, key))[unit["번"]] + lines, blocks, m = unit.get("줄") or [], _blocks(book), None + cache, first = solved(folder, key) or {}, sangeun.calls(book)[1] + out = [] + for r, line in zip(rows, lines, strict=True): + r = _row(line, r) + sub = r.get("번") + if "단가산출" in r["갈래"] and sub is not None and int(sub) in blocks: + head = first.get(int(sub)) or {} + mine = head.get("_호표") == unit["번"] and head.get("차례") == line.get("차례") + if mine and str(sub) in cache: + r["풀이"] = cache[str(sub)] + else: + m = m or master() + others = [x for x in lines if x is not line] + r["풀이"] = _solve(m, blocks[int(sub)], line, others) + out.append(r) + sc, _ = _made(folder, key, to) + head = {k: unit.get(k) for k in ("번", "이름", "규격", "단위", "단가")} + return {**head, "만든": sc.get(hbuild.mark(key, no)), "줄": out} + + +def block(folder: Path, key: str, no: int, to: Path) -> dict[str, Any]: + """단산 블록 하나 — 풀이(저장본 › 셈) + 이름 · 단위 · 부름 · 만든(부르는 첫 줄로 B1 · B6).""" + book = hopyo_store.read(folder, key) + blocks = _blocks(book) + if no not in blocks: + raise _bad(f"없는 블록 「단산{no}」", 404) + line, same = _caller(book, no) + got = (solved(folder, key) or {}).get(str(no)) or _solve(master(), blocks[no], line, same) + _, sk = _made(folder, key, to) + blk = blocks[no] + head = {"이름": blk.get("이름"), "단위": blk.get("단위") or (line or {}).get("단위")} + head |= {"부름": sangeun.callers(book).get(no, []), "만든": sk.get(sangeun.mark(key, no))} + return {**got, **head} + + +# ── 검산 ───────────────────────────────────────────────────────────── +def _want(m: mf.Master, pick: dict[str, Any]) -> Any: + """고른 것 단위 — 기초 = 결과단위(「원/㎥」 → ㎥ · 「원/자재 단위」 → 자재) · 그 밖 = 고른 것 단위.""" + if pick.get("갈래") == "기초" and pick.get("키"): + unit = str(m.logic(pick["키"]).get("결과단위") or "") + unit = unit.split("/", 1)[1] if unit.startswith("원/") else unit + return unit.removesuffix(" 단위").strip() # 「원/자재 단위」 = 줄 단위 그대로 + return pick.get("단위") + + +def quantity(m: mf.Master, line: dict, pick: dict) -> dict[str, Any]: + """줄 수량 `{엑셀, 몫, 배, 갈래, 우리}` — 사용자 배(단위 환산 값)가 있으면 그것.""" + got = hrow.quantity(line, _want(m, pick), pick.get("몫")) + times = pick.get("배") + if times is not None and line.get("수량") is not None: + ours = Decimal(str(line["수량"])) * Decimal(str(pick.get("몫") or 1)) + got |= {"배": times, "갈래": "사용자", "우리": hrow._plain(ours * Decimal(str(times)))} + return got + + +def one_pick(m: mf.Master, pick: dict[str, Any], items: list[dict], share: Any) -> dict: + """고른 것 하나 검산.""" + out = {"갈래": pick["갈래"], "키": pick.get("키")} + if pick["갈래"] != "기초": + return {**out, "판정": CHECKED} + code = str(pick.get("키") or "") + try: + m.logic(code) + except Exception as e: # noqa: BLE001 — 없는 키 + raise _bad(f"없는 기초 「{code}」") from e + full, need = inputs(m, code, pick.get("입력값")) + rows = solve.pair(hb.quantities(m, code, full), solve._usable(items), share) if items else [] + if need: + verdict = solve.NEED + elif any(r["판정"] == solve.DIFF for r in rows): + verdict = solve.DIFF + else: + verdict = solve.SAME if any(r["판정"] == solve.SAME for r in rows) else CHECKED + got = money(m, code, full) if not need else {"계": None, "까닭": f"입력 필요 {len(need)}"} + return {**out, "입력값": full, "입력필요": need, "줄": rows, "판정": verdict, "금액": got} + + +def check( + folder: Path, key: str, hno: int | None, at: int | None, bno: int | None, picks: list[dict] +) -> dict[str, Any]: + """`{고른 [...], 수량}` — 호표 줄(`hno` · `at`) · 블록(`bno` · 줄이 단산이면 그 블록) · 고른 기초는 + 맡은 절(`Solve.parts`) 단위 줄 · 몫과 대조.""" + book = hopyo_store.read(folder, key) + blocks, line = _blocks(book), None + if hno is not None: + lines = _unit(book, hno).get("줄") or [] + if at is None or not 0 <= at < len(lines): + raise _bad(f"없는 줄 「제{hno}호표 {at}」") + line = lines[at] + ref = line.get("참조") or {} + if bno is None and ref.get("종류") == "단산" and ref.get("번") is not None: + bno = int(ref["번"]) + if bno is not None and bno not in blocks: + raise _bad(f"없는 블록 「단산{bno}」", 404) + blk = blocks[bno] if bno is not None else {} + parts = solve.parts(blk, clause.basis(blk)["후보"]) if blk else [] + m = master() + out = [] + for i, pick in enumerate(picks): + part = next((x for x in parts if pick.get("키") in x["후보"]), None) or {} + items = part.get("항목") # 그 기초가 맡은 절 단위 줄(없으면 블록 통째) + items = (blk.get("항목") or []) if items is None else items + share = part.get("몫") + share = pick.get("몫") or (share.get("값") if isinstance(share, dict) else share) or 1 + out.append({"차례": i, **one_pick(m, pick, items, share)}) + qty = quantity(m, line, picks[0]) if line is not None and picks else None + return {"고른": out, "수량": qty} diff --git a/M02_MasterTemplete/M02_Hopyo_Kids.py b/M02_MasterTemplete/M02_Hopyo_Kids.py index 94bd4c66a..caeedb958 100644 --- a/M02_MasterTemplete/M02_Hopyo_Kids.py +++ b/M02_MasterTemplete/M02_Hopyo_Kids.py @@ -22,13 +22,19 @@ from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Library_Flat as lflat from M02_MasterTemplete import M02_Library_Same as lsame from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete.M02_Hopyo_Sangeun import _same_mark ORIGIN = "실무자료" KIND = "precombo" # ── 같은 내용 재사용 ───────────────────────────────────────────────── +def _same_mark(a: dict, b: dict) -> bool: + """같은 출처 — 본뜬 키 같음 · 원본(파일 층)이 둘 다 있으면 같음(SC · SK 함께 · `Sangeun` 이 빌림).""" + if a.get("키") != b.get("키"): + return False + return not a.get("원본") or not b.get("원본") or tuple(a["원본"]) == tuple(b["원본"]) + + def entries(folder: str | Path): """층 사전조합 실무자료 출처 → (키, 본뜬 · 또 한 칸, 판) — 또 = 같은 내용으로 이 SC 를 쓴 뒤 출처.""" for path in sorted((Path(folder) / KIND).glob("SC-*.json")): diff --git a/M02_MasterTemplete/M02_Hopyo_Make.py b/M02_MasterTemplete/M02_Hopyo_Make.py new file mode 100644 index 000000000..567969533 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Make.py @@ -0,0 +1,399 @@ +"""M02 엑셀 불러오기 만들기(PLAN 785-5 · 요구 642 · 설계 642-A 2-7 ⑥) — 확인한 것 → 우리 SC · SK · 장부. + +옮기기 몸 = `{호표 {번: {차례: [고른 것]}}, 블록 {번: [고른 것]}}` · 고른 것 = 785-3 꼴 +`{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 입력값, 몫, 배?, 비목?, 기준?, 별도계상?, 층?, +주인?, 항목?}` · 서버가 다시 셈(브라우저 금액 · 품 안 받음 · 배 = `Row.factor` · 사용자 배 우선 · +입력값 = 엔진이 쓴 칸 모두 `Judge.inputs`) · 「입력 필요」 · 배 못 정함 · 없는 키 = 그 줄(블록)만 `막힘`. +SC(`M02_Hopyo_Build.Builder` 가 줄 차례 · 확인 안 한 줄 = 모두 고정 길 그대로) — 한 엑셀 줄 = 고른 것마다 +우리 줄(같은 별칭 = 엑셀 품명 · 줄 `본뜬 {호표, 차례, 행}`) · 수량 {손 = 엑셀 수량 × 몫 × 배} · + 기초 = 로직 줄(같은 로직 둘째 = 한 줄 SC 사본) · 재료 · 유가 = 자재 목록(별도계상) · 인력 · 기계 · 유가 이음 · + 율 = 고정 줄 `비율 {기준, 비목}` · 고정 = 엑셀 단가 고정 줄 · 단가산출 · 일위대가 = 참조 + (`단산N` = 같은 옮기기 블록 SK · `제N호표` = 같은 HP 호표 SC — 하위 먼저 · 확인 안 된 하위 = 모두 고정). +SK(블록) = 바탕 없음 + 더한줄 — 기초 `{종류 로직, 입력값, 수량 = 몫}` · 참조 · 단위 줄(`항목`)에 이은 + 인력 · 기계 · 재료 · 유가 · 율 = `SkUnit.adds` · 고정 = 엑셀 금액 · 쓰기 = `Sangeun.Maker.write`. +같은 내용 재사용(`Kids.save` · `Sangeun.Maker.write`) · 장부 = 고른 것 꼴 짝(`Ledger.check_pair`) · +엑셀 대조 = `Compare.compare`(참고 · 비목마다 1 원). +`Lines` = 확인한 줄 몸(옛 대상 꼴 `put` 은 옛 길 몫 · 785-7 지움). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Build as hbuild +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Hopyo_Kids as hkids +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Material as hmat +from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun +from M02_MasterTemplete import M02_Hopyo_SkLink as sklink +from M02_MasterTemplete import M02_Hopyo_SkUnit as skunit +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Hopyo_Units as units +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Same as lsame +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete.M02_Hopyo_Compare import compare + +_SUB = {"단가산출": re.compile(r"^단산(\d+)$"), "일위대가": re.compile(r"^제(\d+)호표$")} +LINK = {"인력": "노임", "기계": "기계", "유가": "유가"} +OLD_PICK = {"인력": "노임", "기계": "기계", "재료": "자재", "유가": "자재"} +NEED = "입력 필요" +COVER = "이미 옮긴 것이 있음 — 덮으려면 [덮어 옮기기](덮음)" + + +class Stuck(Exception): + """그 줄(블록)만 못 옮김 — `{까닭, 칸?}`.""" + + def __init__(self, why: str, cells: list[str] | None = None) -> None: + super().__init__(why) + self.why, self.cells = why, cells or [] + + +def _num(value: Decimal) -> int | float: + return hbuild._num(value) + + +# ── 확인한 줄 몸(SC) ───────────────────────────────────────────────────── +class Lines: + """한 호표 줄 모음 — `Builder.build` 가 줄 차례대로 부름 · 확인 안 한 줄은 Builder 가 고정 길로.""" + + def __init__(self, b: Any, rows: list[dict], where: dict, stamp: str, linker: Any) -> None: + self.b, self.rows, self.where, self.stamp, self.linker = b, rows, where, stamp, linker + self.lines: list[dict] = [] + self.mats: dict[str, dict] = {} + self.missing: list[dict] = [] + self.used: set[str] = set() + self.mat_lines: dict[str, dict] = {} + self.nation: dict[tuple, dict] = {} + self.taken: set[str] = set() + self.kids: set[int] = set() + self.left: list[dict] = [] + + def put(self, line: dict, one: dict, alias: str | None, qty: Decimal | None = None, + mark: dict | None = None) -> None: # fmt: skip + """대상 하나 → 줄 · 자재 — 옛 꼴 `{로직|자재|호표|전국|이음, 입력값, 배, 몫}` · `qty` = 새 길 수량 + (엑셀 × 몫 × 배) · `mark` = 새 길 줄 `본뜬`.""" + keep = len(self.lines) + if "이음" in one: + others = [x for x in self.rows if x is not line] + fx = hfix.line_of(line, others, self.where, self.taken, self.linker, one["이음"]) + self.lines.append(fx) + else: + qty = ( + qty + if qty is not None + else Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) + ) + self._put(line, one, alias, qty, mark is not None) + for row in self.lines[keep:]: + row.update({"본뜬": mark} if mark else {}) + + def _put(self, line: dict, one: dict, alias: str | None, qty: Decimal, new: bool) -> None: + b = self.b + memo = hbuild._memo(line, one) + if "전국" in one: + at = (one["이름"], one["규격"], one["단위"]) + have = self.nation.get(at) + hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + head = {"이름": at[0], "규격": at[1], "단위": at[2], "수량": {"손": _num(hand)}} + self.nation[at] = {**head, **(hmat.marks(line, at[0]) if not have else { + k: have[k] for k in labor.MARKS if k in have + })} # fmt: skip + self.missing.append(hbuild._missing(line, hbuild.NATIONAL)) + return + if "자재" in one: + code = one["자재"] + have = self.mats.get(code) + hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + name = b.mat_names.get(code, "") + marks = ( + have and {k: have[k] for k in labor.MARKS if k in have} or hmat.marks(line, name) + ) + if one.get("별도계상") is not None: # 세 칸 값(빈 칸 = 기본 「별도계상」) + marks = {**marks, "별도계상": "별도계상" if one["별도계상"] else "직접계산"} + self.mats[code] = {"코드": code, "수량": {"손": _num(hand)}, **marks} + if not have: + self.mat_lines[code] = line + return + if "호표" in one or "참조" in one: + if "호표" in one: + self.kids.add(one["호표"]) + key = b.done.get(hbuild.mark(b.key, one["호표"]), "SC-미리") + ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": key} + else: + ref = one["참조"] + self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + return + row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} + if not new: + kept, why = sklink.kept(b, line, one, qty, alias, memo) + if why: + self.left.append(sklink.left(line, why)) + if kept is not None: + self.lines.append(kept) + return + if one["로직"] not in self.used: + self.used.add(one["로직"]) + self.lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}}) + return + at = int(line.get("차례", 0)) + hopyo, file = b.hopyo, b.doc.get("파일") or "" + wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 + "이름": f"{hopyo['이름']} — {line.get('이름')}", + "단위": line.get("단위") or None, + "구분": hbuild.ORIGIN, + "상세구분": file, + "담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}], + "자재": [], + "본뜬": b._origin(f"{hbuild.mark(b.key, b.no, at)} {one['로직']}", self.stamp), + } + ref = {"키": "SC-미리"} if b.dry else hkids.save(wrap, b.same)[0] # 미리 보기 = 쓰기 없음 + self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + + def picks(self, at: int, line: dict, picks: list[dict]) -> None: + """고른 것(검사 · 다시 셈 끝난 것) → 그 엑셀 줄의 우리 줄 · 자재.""" + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + mark = {"호표": self.b.no, "차례": at, "행": line.get("행")} + for p in picks: + kind, code = p["갈래"], p.get("키") + one = {"배": p["배"], "몫": p.get("몫")} + qty = p["수량"] + if kind == "기초": + self.put(line, {**one, "로직": code, "입력값": p["입력값"]}, alias, qty, mark) + elif kind in ("재료", "유가") and not p.get("이음"): + self.put( + line, {**one, "자재": code, "별도계상": p.get("별도계상")}, alias, qty, mark + ) + elif kind in ("단가산출", "일위대가"): + self.put(line, {**one, "참조": p["참조"]}, alias, qty, mark) + elif kind == "율": + self.put(line, {"이음": {"비율": p["비목"]}}, alias, None, mark) + self.lines[-1]["고정"]["비율"] = {"기준": p["기준"], "비목": p["비목"]} + elif kind == "고정": + self.put(line, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) + else: + self.put(line, {"이음": {LINK[kind]: code}}, alias, None, mark) + + +def ledger_pick(p: dict[str, Any]) -> dict[str, Any]: + """장부 짝(고른 것 꼴 · `Ledger.check_pair`) — 참조 = 만든 SK · SC 키.""" + keep = ("갈래", "키", "입력값", "배", "몫", "비목", "기준", "별도계상") + out = {k: p[k] for k in keep if p.get(k) is not None} + return out | ({"키": p["참조"]["키"]} if "참조" in p else {}) + + +# ── 고른 것 검사 · 다시 셈 ────────────────────────────────────────────── +class Move: + """한 번 옮기기 — 파일 · M01 · 만든 SC · SK 를 한 번 읽음(쓰기 = `store.root()`).""" + + def __init__(self, folder: Path, source: Path, key: str, who: Any) -> None: + self.folder, self.source, self.key, self.who = Path(folder), Path(source), key, who + self.hp = hopyo_store.read(source, key) + self.units = {int(h["번"]): h for h in self.hp.get("호표") or []} + self.m = judge.master() + self.known = ledger._known() + self.sk = sangeun.Maker(source, key, who) + self.same = lsame.Same() + self.out: dict[str, Any] = {"만듦": [], "단가산출": [], "막힘": [], "하위": []} + self.out["장부"] = {"씀": 0} + + def _ref(self, p: dict) -> dict[str, Any]: + """단가산출 · 일위대가 키 → 참조 칸(같은 HP 블록 · 호표 = 만든 것) · 없으면 막힘.""" + code, kind = str(p.get("키") or ""), p["갈래"] + hit = _SUB[kind].match(code) + here = refs.here() + if hit: + n = int(hit.group(1)) + made = ( + self.sk.keys.get(sangeun.mark(self.key, n)) + if kind == "단가산출" + else hbuild.confirmed(self.folder, refs.where(self.source)).get( + hbuild.mark(self.key, n) + ) + ) + if not made: + raise Stuck(f"{code} 먼저 옮김") + return {"층": here[0], "주인": here[1], "키": made} + head = "SK-" if kind == "단가산출" else "SC-" + ref = {"층": p.get("층") or here[0], "주인": p.get("주인"), "키": code} + if p.get("층") is None: + ref["주인"] = here[1] + if not code.startswith(head) or refs.read_ref(ref) is None: + raise Stuck(f"없는 {kind} 「{code}」") + return ref + + def pick(self, p: dict, line: dict | None, where: str) -> dict[str, Any]: + """고른 것 하나 재검사 · 다시 셈 → `{갈래, 키, 입력값, 배, 몫, 수량, 참조?, …}`.""" + kind = p["갈래"] + got = {k: p[k] for k in ("갈래", "키", "몫", "비목", "기준", "별도계상") if k in p} + if kind == "기초": + full, need = judge.inputs(self.m, str(p.get("키") or ""), p.get("입력값")) + if need: + raise Stuck(f"{NEED} {len(need)}", need) + got["입력값"] = full + if kind in _SUB: + got["참조"] = self._ref(p) + else: + plain = {k: v for k, v in got.items() if k in ledger.PICK_KEYS} + ledger.check_pair(plain, where, self.known) + if kind in ("재료", "유가") and not got.get("키"): + raise Stuck(f"{kind} 키 없음") + if line is not None: + qty = judge.quantity(self.m, line, p) + if qty["배"] is None: + raise Stuck("단위 환산 입력") + got["배"], got["수량"] = qty["배"], Decimal(str(qty["우리"] or 0)) + return got + + # ── SC ── + def hopyo(self, no: int, rows: dict[int, list[dict]]) -> None: + unit = self.units[no] + lines = unit.get("줄") or [] + checked = {} + for at, picks in sorted(rows.items()): + if not 0 <= at < len(lines): + raise store.StoreError(400, f"없는 줄 「제{no}호표 {at}」") + where = f"제{no}호표 {at + 1}째 줄" + if not picks: + continue + try: + checked[at] = {"고른": [self.pick(p, lines[at], where) for p in picks]} + except Stuck as e: + self.out["막힘"].append({"호표": no, "차례": at, "까닭": e.why, "칸": e.cells}) + b = hbuild.Builder(self.folder, self.source, self.key, no, self.who) + b.strict, b.same = False, self.same + made = b.build(checked, []) + ref = made["참조"] + head = {"번": no, "키": ref["키"], "이름": unit.get("이름")} + head |= {k: made[k] for k in ("다시씀", "재사용")} + self.out["만듦"].append({**head, "비교": compare(ref, unit)}) + self.out["장부"]["씀"] += made["장부"]["씀"] + + def kid(self, no: int) -> None: + """확인 안 된 하위 호표 — 모두 고정(705-3 길)으로 먼저.""" + b = hbuild.Builder(self.folder, self.source, self.key, no, self.who) + b.strict, b.same = False, self.same + if b.done.get(hbuild.mark(self.key, no)): + return + made = b.build({}, []) + self.out["하위"].append({"번": no, "키": made["참조"]["키"], "새로": True}) + + # ── SK ── + def block(self, no: int, picks: list[dict]) -> None: + blk = self.sk.blocks[no] + items = blk.get("항목") or [] + us = self.sk.units() + made: list[dict] = [] + rows: list[dict] = [] + for p in picks: + got = self.pick(p, None, f"단산{no}") + name = str(p.get("이름") or "").strip() or str(blk.get("이름") or f"단산{no}") + share = got.get("몫") or 1 + at = p.get("항목") + item = items[at] if isinstance(at, int) and 0 <= at < len(items) else None + if got["갈래"] == "기초": + made.append({"종류": "로직", "로직": got["키"], "입력값": got["입력값"], + "이름": name, "수량": share}) # fmt: skip + continue + if got["갈래"] in _SUB: + qty = float(item.get("품") or 1) if item else share + made.append({"참조": got["참조"], "이름": name, "수량": qty}) + continue + if item is None: + raise Stuck(f"{got['갈래']} — 블록 단위 줄(항목) 고름") + if got["갈래"] == "고정": + parts = hfix._parts(item.get("금액")) + fx = {"이름": name, "단가": {k: float(v) for k, v in parts.items()}, "이음": {}} + made.append({"고정": fx, "이름": name, "수량": 1}) + continue + old = {"기준": got.get("기준"), "비목": got.get("비목")} + if got["갈래"] in OLD_PICK: + old = {OLD_PICK[got["갈래"]]: got["키"], "별도계상": got.get("별도계상")} + unit = skunit._picked(item, old, us)["단위"] + rows.append({"차례": at, "행": item.get("행"), "상태": units.LINKED, "단위": unit}) + extra, _ = skunit.adds(blk, rows, us, self.sk.ref_of) + doc = {"키": "SK-000000", "이름": "옮김", "단위": blk.get("단위") or "식", "바탕": None} + try: + adds = sd.clean({**doc, "더한줄": made + extra}, None, self.sk.whole)["더한줄"] + except (ValueError, store.StoreError) as e: + raise Stuck(f"더한줄 틀림 — {e}") from e + plan = {"갈래": "고름", "바탕": None, "입력값": {}, "덮개": {}, "자재표시": {}, "뺀줄": []} + plan |= {"더한줄": adds, "못옮김": [], "놓음": []} + mark_ = sangeun.mark(self.key, no) + key = self.sk.write(no, plan, self.sk.keys.get(mark_)) + self.sk.keys[mark_] = key + self.out["단가산출"].append({"번": no, "키": key, "이름": blk.get("이름")}) + + def blocks(self, sure: dict[int, list[dict]]) -> None: + """하위 단산(블록 단위 줄 하위) 먼저.""" + done: set[int] = set() + + def visit(no: int, path: tuple = ()) -> None: + if no in done or no in path: + return + for one in self.sk.blocks[no].get("항목") or []: + sub = one.get("하위") or {} + if sub.get("종류") == "단산" and int(sub.get("번") or 0) in sure: + visit(int(sub["번"]), (*path, no)) + done.add(no) + try: + self.block(no, sure[no]) + except Stuck as e: + self.out["막힘"].append({"블록": no, "까닭": e.why, "칸": e.cells}) + + for no in sorted(sure): + visit(no) + + +def move( + folder: Path, + source: Path, + key: str, + units_: dict[int, dict[int, list[dict]]], + blocks: dict[int, list[dict]], + who: Any = None, + cover: bool = False, +) -> dict[str, Any]: + """[옮기기] → `{만듦, 단가산출, 막힘, 하위, 장부}` — 블록 SK 먼저 · 호표 하위 먼저 · + 이미 옮긴 호표 · 블록(같은 HP 출처 SC · SK)은 `cover`(덮음) 때만 덮음 · 아니면 409 아무것도 안 씀.""" + mv = Move(folder, source, key, who) + bad = [f"제{n}호표" for n in units_ if n not in mv.units] + bad += [f"단산{n}" for n in blocks if n not in mv.sk.blocks] + if bad: + raise store.StoreError(404, f"없는 {bad[0]}") + done = hbuild.confirmed(mv.folder, refs.where(mv.source)) + had = { + "호표": [ + {"번": n, "키": done[k]} for n in sorted(units_) if (k := hbuild.mark(key, n)) in done + ] + } + had["블록"] = [ + {"번": n, "키": mv.sk.keys[k]} + for n in sorted(blocks) + if (k := sangeun.mark(key, n)) in mv.sk.keys + ] + if not cover and (had["호표"] or had["블록"]): + raise store.StoreError(409, {"까닭": COVER, **had}) + mv.blocks(blocks) + wanted = { + int(hit.group(1)) + for rows in units_.values() + for picks in rows.values() + for p in picks + if p.get("갈래") == "일위대가" and (hit := _SUB["일위대가"].match(str(p.get("키") or ""))) + } + order = hbuild._order(mv.units) + for no in order: # 고른 것이 부르는 확인 안 된 하위 = 모두 고정으로 먼저 + if no in wanted and no not in units_: + mv.kid(no) + for no in order: + if no in units_: + mv.hopyo(no, units_[no]) + return mv.out diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py new file mode 100644 index 000000000..e6e2a14d9 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -0,0 +1,590 @@ +"""M02 일위대가 실무자료 길(PLAN 785-5 · 요구 642 · 설계 642-A 2-9) — `/api/m02/layers/{층}/hopyo…`. + +새 길(글 먼저 · 결정적 매칭) — 판정 `hopyo-judge`(1차 `M02_Hopyo_Row` + 2차 `M02_Hopyo_Solve` · +`M02_Hopyo_Judge`) · 검산 `hopyo/{키}/check`(저장 없음) · 옮기기 `hopyo/{키}/move`(확인한 것 → +SC · SK · 장부 · `M02_Hopyo_Make`) · 경로 층 = 파일 층(판정 · 검산 · `to` = 만들 층) / 만들 층(옮기기). +올림 = Book + 격자 + 엑셀 사전(`Row.lexicon`) + 블록 풀이 뒤 셈(`Judge.ensure` · 파생 저장본) · +옛 맞춤 뒤 셈 · 시동 데움 없음 · [끝내기] = 저장본도 지움. +옛 길(`M02_Labor_Router` 에서 옮김 · 옛 화면 = 785-6 끝까지 · 지움 = 785-7) · 그대로 쓰는 길(격자 · +지우기 · 장부 · 붙여넣기 · 다시 잇기 · 구조물 조합 · 칠 = B09 · M01 이 씀). +층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget +from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid +from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item +from M02_MasterTemplete import M02_Hopyo_Judge as hopyo_judge +from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger +from M02_MasterTemplete import M02_Hopyo_Make as hopyo_maker +from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match +from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste +from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink +from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row +from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root + +router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"]) + + +# ── 새 길(785-5) — 판정 · 검산 · 옮기기 ───────────────────────────────────── +def _to(session: Session, folder: Path, to: Layer | None, to_owner: str | None) -> Path: + return _root(session, to, to_owner) if to is not None else folder + + +@router.get("/layers/{layer}/hopyo-judge/{key}") +def hopyo_judge_list( + layer: Layer, + key: str, + owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """호표 목록 + 1차 판정 수 · 블록 목록 `{키, 파일, 호표 [...], 블록 [...]}`(읽기만).""" + folder = _root(session, layer, owner) + target = _to(session, folder, to, to_owner) + return _call(folder, lambda: hopyo_judge.listing(folder, key, target)) + + +@router.get("/layers/{layer}/hopyo-judge/{key}/{no}") +def hopyo_judge_one( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """호표 하나 — 줄 판(1차) · 단산 줄 `풀이`(2차 블록 풀이).""" + folder = _root(session, layer, owner) + target = _to(session, folder, to, to_owner) + return _call(folder, lambda: hopyo_judge.hopyo(folder, key, no, target)) + + +@router.get("/layers/{layer}/hopyo-judge/{key}/block/{no}") +def hopyo_judge_block( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """단산 블록 하나 — 블록 풀이(`Solve.block`) + 이름 · 단위 · 부름 · 근거 · 만든.""" + folder = _root(session, layer, owner) + target = _to(session, folder, to, to_owner) + return _call(folder, lambda: hopyo_judge.block(folder, key, no, target)) + + +class Pick(BaseModel): + """고른 것(785-3 꼴) — 금액 · 품 칸 없음(서버가 셈) · 고정 단가 = 엑셀 줄 것.""" + + model_config = ConfigDict(extra="ignore") + 갈래: Literal["기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정"] + 키: str | None = Field(None, max_length=40) + 이름: str | None = Field(None, max_length=200) + 단위: str | None = Field(None, max_length=40) + 입력값: dict[str, Any] = Field(default_factory=dict) + 몫: float | None = None + 배: float | None = None + 비목: str | None = None + 기준: str | list[str] | None = None + 별도계상: bool | None = None + 층: Layer | None = None + 주인: str | None = None + 항목: int | None = None # 블록 고른 것 — 블록 단위 줄 차례(수량 = 그 줄 품) + + @model_validator(mode="before") + @classmethod + def _no_money(cls, raw: Any) -> Any: + """금액 · 품 · 수량 칸 = 400(서버가 셈 · 브라우저 값 안 받아 적음).""" + if isinstance(raw, dict) and {"금액", "품", "수량"} & set(raw): + raise ValueError("금액 · 품 · 수량은 서버가 셈") + return raw + + +class CheckBody(BaseModel): + """`호표` + `차례` = 일위대가 줄 · `블록` = 단산 블록(항목 줄과 대조) · `고른` = 고른 것.""" + + 호표: int | None = None + 차례: int | None = None + 블록: int | None = None + 고른: list[Pick] = Field(default_factory=list, max_length=20) + + +@router.post("/layers/{layer}/hopyo/{key}/check") +def hopyo_check( + layer: Layer, + key: str, + body: CheckBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """검산(저장 없음) — 고른 것마다 입력값(엔진이 쓴 칸 모두) · 입력필요 · 줄 대조 · 금액(서버 셈).""" + folder = _root(session, layer, owner) + picks = [p.model_dump(exclude_none=True) for p in body.고른] + args = (folder, key, body.호표, body.차례, body.블록, picks) + return _call(folder, hopyo_judge.check, *args) + + +class MoveBody(BaseModel): + """확인한 것 — 파일 층 `층` · `주인` · `호표 {번: {차례: [고른 것]}}` · `블록 {번: [고른 것]}`.""" + + model_config = ConfigDict(extra="forbid") + 층: Layer + 주인: str | None = None + 호표: dict[int, dict[int, list[Pick]]] = Field(default_factory=dict, max_length=500) + 블록: dict[int, list[Pick]] = Field(default_factory=dict, max_length=500) + 덮음: bool = False # 이미 옮긴 호표 · 블록(같은 HP 출처) 덮기 — 아니면 409 {까닭, 호표, 블록} + + +@router.post("/layers/{layer}/hopyo/{key}/move") +def hopyo_move( + layer: Layer, + key: str, + body: MoveBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[옮기기] — 확인한 것 → 이 층 SC · SK · 장부(하위 먼저 · 서버가 다시 셈) → + `{만듦, 단가산출, 막힘, 하위, 장부}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + dump = lambda ps: [p.model_dump(exclude_none=True) for p in ps] # noqa: E731 + units = {no: {at: dump(ps) for at, ps in rows.items()} for no, rows in body.호표.items()} + blocks = {no: dump(ps) for no, ps in body.블록.items()} + args = (folder, source, key, units, blocks, _who(session), body.덮음) + return _call(folder, hopyo_maker.move, *args) + + +# ── 옛 길(785-7 까지 · 옛 화면) · 그대로 쓰는 길 ───────────────────────────── +HOPYO_MAX_BYTES = 20 * 1024 * 1024 + + +def _who(session: Session) -> dict[str, Any]: + return {"id": session.get("user_id"), "이름": session.get("name")} + + +def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: + mark = hopyo_store.fingerprint(data) + hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) + if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): + hopyo_judge.ensure(store.root(), same["키"]) + return {**same, "이어서": True} + try: + parsed = hopyo_excel.read_book(name, data) + except hopyo_excel.ExcelError as error: + raise store.StoreError(400, str(error)) from error + try: + grid = hopyo_grid.read(name, data, parsed) + except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) + grid = None + sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS) + parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정) + row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) + hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림) + return row + + +@router.get("/layers/{layer}/hopyo") +def hopyo_list( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" + return {"줄": hopyo_store.rows(_root(session, layer, owner))} + + +@router.get("/layers/{layer}/hopyo-picks") +def hopyo_picks( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 + `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합).""" + folder = _root(session, layer, owner) + return {"줄": _call(folder, _picks, folder)} + + +def _picks(folder: Path) -> list[dict[str, Any]]: + rows, here, done = hopyo_match.picks(folder), refs.where(folder), {} + for row in rows: + src = (row["층"], row.get("주인")) + if src not in done: + done[src] = hopyo_build.confirmed(folder, src) + key = done[src].get(hopyo_build.mark(row["키"], row["번"])) + row["확인"] = {"층": here[0], "주인": here[1], "키": key} if key else None + return rows + + +@router.post("/layers/{layer}/hopyo") +def hopyo_upload( + layer: Layer, + file: UploadFile = File(...), + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · + 엉터리 · 시트 없음 400.""" + folder = _root(session, layer, owner, write=True) + data = file.file.read(HOPYO_MAX_BYTES + 1) + if len(data) > HOPYO_MAX_BYTES: + raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)") + name = Path(file.filename or "실무자료").name[:120] + return _call(folder, _upload, name, data, _who(session)) + + +@router.get("/layers/{layer}/hopyo/{key}") +def hopyo_read( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_match.view, folder, key) + + +@router.get("/layers/{layer}/hopyo-grid/{key}") +def hopyo_grid_read( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` · + 옛 파일 404.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_store.grid, folder, key) + + +@router.get("/layers/{layer}/hopyo/{key}/{no}") +def hopyo_one( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · + 호표 참조 줄 체크 · 만든).""" + folder = _root(session, layer, owner) + target = _root(session, to, to_owner) if to is not None else None + return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target)) + + +class GuessBody(BaseModel): + """`차례` = 호표 안 줄(0부터) · `대상` = [{로직 | 자재, 입력값?, 몫?}](낱개도 받음).""" + + 차례: int + 대상: list[dict[str, Any]] | dict[str, Any] + + +@router.post("/layers/{layer}/hopyo/{key}/{no}/guess") +def hopyo_guess( + layer: Layer, + key: str, + no: int, + body: GuessBody, + owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """미리 셈(저장 안 함 · 설계 3-4) — 고른 대상 · 옵션으로 엔진 한 번 → `{대상, 품, 글}` · + `to` = 만들 층(없으면 파일 층).""" + folder = _root(session, layer, owner) + target = _root(session, to, to_owner) if to is not None else None + args = (folder, key, no, body.차례, body.대상) + return _call(folder, lambda: hopyo_match.guess(*args, to=target)) + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈) + + +class BuildTarget(_Strict): + """대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫.""" + + 로직: str | None = None + 자재: str | None = None + 호표: int | None = None + 전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈) + 노임: str | None = None # 729-2 사용자 이음 — LB 키(엑셀 숫자 + 이음) + 기계: str | None = None # EQ 키 + 비율: Literal["노무비", "재료비", "경비"] | None = None # % 줄 밑수 + 입력값: dict[str, Any] = Field(default_factory=dict) + 배: float = 1 + 몫: float | None = None + + +class BuildLine(_Strict): + """확인한 줄 — `차례` = 호표 안 줄(0부터) · `대상` 목록 또는 `뺌` 까닭.""" + + 차례: int + 대상: list[BuildTarget] = Field(default_factory=list) + 뺌: Literal["우리에 없음", "안 씀"] | None = None + + +class BuildWhole(_Strict): + 대상: list[BuildTarget] + + +class BuildBody(_Strict): + """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` · + 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음.""" + + 층: Layer + 주인: str | None = None + 키: str + 번: int + 줄: list[BuildLine] = Field(default_factory=list, max_length=500) + 통째: BuildWhole | None = None + + +@router.post("/layers/{layer}/hopyo-build") +def hopyo_make( + layer: Layer, + body: BuildBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → + `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}` · 하위 호표 확인 전 = 함께 만듦(실패 409).""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole, _who(session)) + return _call(folder, hopyo_build.build, *args) + + +@router.post("/layers/{layer}/hopyo-unlinked") +def hopyo_unlinked( + layer: Layer, + body: BuildBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """안 이은 줄 미리 보기(729-2) — 만들기 몸 그대로 → `{남은줄 [{차례, 이름, 규격, 단위, 갈래, 까닭, + 후보}], 수}`(저장 없음).""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole) + return _call(folder, hopyo_build.unlinked, *args) + + +class BuildAllBody(_Strict): + """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음).""" + + 층: Layer + 주인: str | None = None + 키: str + + +@router.post("/layers/{layer}/hopyo-build-all") +def hopyo_make_all( + layer: Layer, + body: BuildAllBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[모두 고정으로 들여오기] — 시트 호표 모두 고정 줄로 · 하위 먼저 · 확인된 호표 그대로 → + `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패 + return _call(folder, hopyo_build.build_all, *args) + + +@router.get("/layers/{layer}/hopyo-ledger") +def hopyo_ledger_read( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}` + (고침 = 이 층 · 쓰기 권한).""" + folder = _root(session, layer, owner) + writable = access.grant(session, layer, owner).write + return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)} + + +class LedgerBody(_Strict): + """`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사.""" + + 줄: dict[str, Any] + + +@router.put("/layers/{layer}/hopyo-ledger") +def hopyo_ledger_write( + layer: Layer, + body: LedgerBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session)) + + +@router.delete("/layers/{layer}/hopyo/{key}") +def hopyo_delete( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """실무자료 지우기 = [끝내기](718-1 · 743-1 · 화면 떠남도) — HP · 격자 · 맞춤셈 캐시 지움 · + 만든 일위대가 · 단가산출은 엑셀 사본만 걷고 남음 · 별칭 장부 그대로 → `{지움, 만든}`.""" + folder = _root(session, layer, owner, write=True) + got = _call(folder, hopyo_forget.finish, folder, key) + hopyo_judge.forget(folder, key) # 블록 풀이 저장본(785-5) + return got + + +class SangeunBody(_Strict): + """`확인` = 사용자가 확인한 블록만 단위 SK `[{번, 고름?: {차례: 고름}}]`(778-5 · 자동 없음).""" + + 확인: list[dict[str, Any]] = Field(default_factory=list, max_length=500) + + +@router.post("/layers/{layer}/hopyo/{key}/sangeun") +def hopyo_make_sangeun( + layer: Layer, + key: str, + body: SangeunBody | None = None, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """불러오기 결과 「단가산출」(718-1 · 778-5) — 블록마다 줄 판정 · 확인한 블록만 단위 SK + (파일 층 = 이 층) → `{블록, 판정, 만듦, 못만듦, 그대로, 같은것씀, 위층같음}` · 맞춤 셈 전 409.""" + folder = _root(session, layer, owner, write=True) + picks = body.확인 if body else [] + return _call(folder, hopyo_sangeun.build, folder, key, _who(session), picks) + + +class PasteBody(_Strict): + """엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) · + `줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함).""" + + 글: str = Field(max_length=hopyo_paste.TEXT_MAX) + 열: dict[str, int] | None = None + 줄만: bool = False + 있음: list[str] = Field(default_factory=list, max_length=500) + + +@router.post("/layers/{layer}/hopyo-paste") +def hopyo_paste_run( + layer: Layer, + body: PasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[Ctrl+V] — `갈래` 물음(열 짝) · 줄(고정 줄 · 저장은 문서 [저장]) · 만듦(실무자료 + 호표마다 사전조합 · + 층 쓰기 · `build_all` 답 + 키).""" + folder = _root(session, layer, owner, write=True) + args = (folder, body.글, body.열, _who(session), body.줄만, body.있음) + return _call(folder, hopyo_paste.paste, *args) + + +class RelinkRowsBody(BaseModel): + """다시 잇기 표(705-2) — `키` = 이 층 사전조합들 · `문서` = 열린 문서(저장 전 · 줄 · 후보만).""" + + 키: list[str] = Field(default_factory=list, max_length=500) + 문서: dict[str, Any] | None = None + + +@router.post("/layers/{layer}/hopyo-relink/rows") +def hopyo_relink_rows( + layer: Layer, + body: RelinkRowsBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """「이 공사 단가로 다시 잇기」 표 — 고정 줄마다 붙인 단가 · 이은 단가 · 후보(층 읽기).""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_relink.rows, body.키, body.문서) + + +class RelinkBody(_Strict): + """바꿈 `[{키, 줄, 노임?, 자재?}]` — 이음 키만(숫자 칸 없음 · 값 null = 풂).""" + + 바꿈: list[dict[str, Any]] = Field(max_length=hopyo_relink.CHANGES_MAX) + + +@router.post("/layers/{layer}/hopyo-relink") +def hopyo_relink_apply( + layer: Layer, + body: RelinkBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_relink.apply, body.바꿈) + + +@router.get("/layers/{layer}/hopyo/{key}/{no}/siblings") +def hopyo_siblings( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표 `{묶음 [{번, 규격, 열}], 이름}`.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_item.siblings, folder, key, no) + + +class ItemBody(BuildBody): + """[구조물 조합으로](704-4) — 확인 창 몸 그대로 + `묶음` = 규격 열로 묶을 호표 번 · `이름` = 항목 이름.""" + + 묶음: list[int] = Field(default_factory=list, max_length=20) + 이름: str | None = Field(None, max_length=120) + 새로: bool = False # 같은 내용 항목 409 뒤 — 그래도 새로(733-2) + 씀: str | None = Field(None, max_length=80) # 같은 내용 409 뒤 — 그 항목 씀 + + +@router.post("/layers/{layer}/hopyo-item") +def hopyo_to_item( + layer: Layer, + body: ItemBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """실무 호표 → 이 층 구조물 조합 항목(사전조합 안 만듦 · 이 층 쓰기 · 파일 층 읽기) → + `{key, 열, 이름, 줄수, 열수, 빠진줄}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session)) + args += (body.새로, body.씀) + return _call(folder, hopyo_item.make_item, *args) + + +@router.get("/layers/{layer}/hopyo-marks/{key}") +def hopyo_marks_read( + layer: Layer, + key: str, + owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """하이라이트 재료(779-2 · 읽기만) — `블록 [{번, 판정, SK, 갈래, 못옮김}]` · `to` = SK 를 볼 층.""" + folder = _root(session, layer, owner) + target = _root(session, to, to_owner) if to else folder + return _call(folder, lambda: {"블록": hopyo_marks.blocks(folder, key, target)}) diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py index 9c4875ada..d347fe422 100644 --- a/M02_MasterTemplete/M02_Hopyo_Sangeun.py +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -1,7 +1,7 @@ """M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 729-1 · 778-5 D-3 · 요구 621 · 623) — 산근(단산) 블록마다 줄 판정 모음(`M02_Hopyo_SkUnit`) → 사용자가 확인한 블록만 단위 SK. -모두 줄로만(바탕 기초 안 씀 · 맞춤셈 1위 기초 = 근거 꼬리표만) · 자동 없음 — `확인 [{번, 고름?: {차례: 고름}}]` +모두 줄로만(바탕 기초 안 씀 · 조항 색인 기초 = 근거 꼬리표만) · 자동 없음 — `확인 [{번, 고름?: {차례: 고름}}]` (블록마다 [확인] · [옮기기])에 든 블록만 만듦 · 확인 안 한 블록 = 판정만(안 만듦) · 줄 다 이어지지 않은 확인 블록 = `못만듦` · 하위 단산 줄 = 그 블록 SK 참조(확인 목록에 있으면 먼저 만듦 · 없으면 못만듦). SK = 이름 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · 구분 「실무자료」 · 상세구분 = 파일 · @@ -19,24 +19,26 @@ from __future__ import annotations import json from pathlib import Path +from types import SimpleNamespace from typing import Any from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis -from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Clause as clause from M02_MasterTemplete import M02_Hopyo_Ledger as ledger -from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch from M02_MasterTemplete import M02_Hopyo_SkUnit as skunit -from M02_MasterTemplete import M02_Hopyo_Units as units from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Hopyo_Units as units from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Library_Same as same from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Hopyo_Kids import _same_mark ORIGIN = "실무자료" KIND = "sangeun" WAIT_WHY = "확인 전 — [단가산출]에서 블록 [확인] 뒤 [옮기기]" +WAIT = "고름 대기" +skpatch = SimpleNamespace(WAIT=WAIT, SK="SK") # 옛 이름(SkLink 가 읽음 · 785-7 지움) def mark(key: str, no: int) -> str: @@ -64,13 +66,6 @@ def made( return out -def _same_mark(a: dict, b: dict) -> bool: - """같은 출처 — 본뜬 키 같음 · 원본(파일 층)이 둘 다 있으면 같음.""" - if a.get("키") != b.get("키"): - return False - return not a.get("원본") or not b.get("원본") or tuple(a["원본"]) == tuple(b["원본"]) - - def origin_of(doc: dict, mark_: str, source: refs.Here | None = None) -> dict[str, Any]: """그 블록 출처의 본뜬(갈래 · 못옮김 · 놓음 …) — 또 가운데 `mark_` · 없으면 제 본뜬.""" own = doc.get("본뜬") or {} @@ -147,14 +142,13 @@ def _doc(line: dict, block: dict, no: int, hp: dict, plan: dict, sure: dict) -> class Maker: - """한 번 만들기 — 파일 · 맞춤셈 · 이 층 SK 를 한 번 읽음(`source` = 파일 층 · 쓰기 = `store.root()`).""" + """한 번 만들기 — 파일 · 이 층 SK 를 한 번 읽음(`source` = 파일 층 · 쓰기 = `store.root()`).""" def __init__(self, source: Path, key: str, who: Any = None) -> None: self.source, self.key, self.who = Path(source), key, who self.hp = hopyo_store.read(source, key) self.blocks, self.first = calls(self.hp) - got = fit.result(source, key) or {} - self.cache, self.done = got.get("블록") or {}, bool(got.get("끝")) + self.done = True # 옛 이름(SkLink) — 맞춤셈 안 기다림(785-5) self.here = refs.where(source) self.whole = m01.loaded()[1] self.sure = {"원본": list(self.here), "확인": {"누가": who, "때": utc_now_text()}} @@ -170,19 +164,27 @@ class Maker: return self._units def basis(self, no: int) -> dict[str, Any] | None: - """근거 꼬리표 = 맞춤셈 1위 기초(참고) · 출처 조항이 우리 기초 밖이고 1위가 맞음 아니면 - 1위 대신 표지(781-2 · 「기초 없음 — 건설 공통 1-2-7 운반」 · 「옛 번호 — 근거 못 씀」).""" - if no not in self.first: # 일위대가 밖 = 맞춤셈 없음 + """근거 꼬리표(참고) — 조항 색인(`M02_Hopyo_Clause.basis`) · 표지(기초 없음 · 옛 번호 · 준용 · + 번호 없음) 있으면 표지 · 후보 하나면 그 기초 · 여럿이면 블록 풀이(`M02_Hopyo_Judge` 저장본 › + 그 블록만 셈) 첫 고름 · 일위대가 밖 블록 = 없음.""" + if no not in self.first: return None - entry = self.cache.get(fit.unit_key(self.first[no])) or {} - top = (entry.get("차례") or [None])[0] - outside = hopyo_basis.no_basis(self.blocks[no].get("출처")) - if outside and (entry.get("판정") or {}).get("상태") != "맞음": - return {"로직": None, "이름": outside["이름"], "판정": outside["판정"]} + got = clause.basis(self.blocks[no]) + if got["표지"]: + return {"로직": None, "이름": got["글"], "판정": got["표지"]} + cands = got["후보"] + top = cands[0]["키"] if len(cands) == 1 else self._solved(no) if not top: return None - name = (self.whole.logic(top) or {}).get("이름") - return {"로직": top, "이름": name, "판정": (entry.get("판정") or {}).get("상태")} + return {"로직": top, "이름": (self.whole.logic(top) or {}).get("이름"), "판정": None} + + def _solved(self, no: int) -> str | None: + from M02_MasterTemplete import M02_Hopyo_Judge as judge # Judge → 여기 — 늦게 읽음 + + cache = judge.solved(self.source, self.key) if getattr(self, "key", None) else None + line = {k: v for k, v in self.first[no].items() if k != "_호표"} + got = (cache or {}).get(str(no)) or judge._solve(judge.master(), self.blocks[no], line, []) + return next((s["고름"] for s in got["절"] if s["고름"]), None) def ref_of(self, sub: dict[str, Any]) -> dict[str, Any] | None: """하위 줄 → 참조 칸 — 단산 = 이 HP 그 블록 SK · 일위 = 확인된 그 호표 SC · 없으면 None.""" @@ -209,10 +211,10 @@ class Maker: def plan(self, no: int, picks: dict | None = None) -> dict[str, Any]: """확인한 블록 → 단위 SK 꼴 · 확인 없음(`picks` None) · 고를 줄 남음 = 고름 대기.""" if picks is None: - return {"갈래": skpatch.WAIT, "까닭": WAIT_WHY, "후보": []} + return {"갈래": WAIT, "까닭": WAIT_WHY, "후보": []} got = self.judge(no, picks) if got["고를줄"]: - return {"갈래": skpatch.WAIT, "까닭": got["판정"], "후보": []} + return {"갈래": WAIT, "까닭": got["판정"], "후보": []} return skunit.plan(got, self.basis(no)) def make(self, no: int, sure: dict[int, dict], out: dict, path: tuple = ()) -> None: @@ -229,7 +231,7 @@ class Maker: got = self.judged[no] lost = [r for r in got["줄"] if r["상태"] == units.LINKED and r["차례"] not in got["담음"]] name = self.blocks[no].get("이름") or (self.first.get(no) or {}).get("이름") - if plan["갈래"] == skpatch.WAIT or lost: + if plan["갈래"] == WAIT or lost: why = plan.get("까닭") or f"하위 「{lost[0]['이름']}」 — 그 블록 먼저 확인" out["못만듦"].append({"번": no, "이름": name, "까닭": why}) return @@ -296,9 +298,6 @@ def build(folder: Path, key: str, who: Any = None, picks: Any = None) -> dict[st bad = sorted(set(sure) - set(maker.blocks)) if bad: raise _bad(f"없는 블록 「단산{bad[0]}」", 404) - if not maker.done: - fit.ensure(folder, key) - raise _bad("맞춤 셈 중 — 끝나면 다시 누름", 409) out: dict[str, Any] = {"블록": len(maker.blocks), "만듦": [], "못만듦": []} for no in sorted(sure): maker.make(no, sure, out) diff --git a/M02_MasterTemplete/M02_Hopyo_SkUnit.py b/M02_MasterTemplete/M02_Hopyo_SkUnit.py index a0476b158..3de969b6c 100644 --- a/M02_MasterTemplete/M02_Hopyo_SkUnit.py +++ b/M02_MasterTemplete/M02_Hopyo_SkUnit.py @@ -8,7 +8,8 @@ 단위 SK 더한줄(바탕 없음 · 778-4 꼴) = 인력 `{요소 LB, 수량 품}` · 기계경비 로직 `{로직 GC, 입력값 {기계}, 수량 시간 × 할증}` · 재료 `{요소 MT, 수량(손료율 든), 별도계상?}`(못 찾은 별도계상 = 0 원 고정 줄) · 율 `{고정 {비율 {기준, 비목}}, 수량 = 율 %}`(줄 목록 = 그 행 줄 `add:#k`) · 하위 `{참조 SK · SC}` · 값 고정 줄. -근거 꼬리표 = 맞춤셈 1위 기초(본뜬 칸 · 참고). 서버 단독 · 쓰기 = `M02_Hopyo_Sangeun`. +근거 꼬리표 = 조항 색인(`Sangeun.Maker.basis` · 참고) · 고른 것(785-5 `M02_Hopyo_Make`)도 `adds` 를 부름 · +기계경비 기초 차례(`_mach_logics`) = 옛 SkPatch 에서 옮김. 서버 단독 · 쓰기 = `M02_Hopyo_Sangeun`. """ from __future__ import annotations @@ -16,7 +17,6 @@ from __future__ import annotations from typing import Any from M02_MasterTemplete import M02_Hopyo_Fixed as hfix -from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line from M02_MasterTemplete import M02_Hopyo_Units as units from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -29,13 +29,41 @@ OUT, DROP = "밖", "뺌" UNIT = "단위" # 본뜬 갈래 MINE = "내가 고름" WORD = {"L": "품", "X": "시간", "M": "수량", "R": "율", "S": "수량"} -PICKS = {"L": ("노임", "LB"), "X": ("기계", "EQ"), "M": ("자재", "MT")} +PICKS = {"L": ("노임", "LB"), "X": ("기계", "EQ"), "M": ("자재", ("MT", "MO"))} +MACH_IN = "기계" # 기계경비 기초 기계 입력 칸 +MACHINE = {"굴착기": "GC000267"} # 굴착기(무한궤도) 시간당 기계경비 · 그 밖 장비 = GC000268 +MACHINE_ANY = "GC000268" +MACHINE_SET = {"GC000267", "GC000268", "GC000269", "GC000275"} +# (주연료, 조종원) 없는 기종 → 그 기계경비 기초(M01 로직이 부르는 것과 같음 · 778-2) +SOLO = {(False, False): "GC000273", (True, False): "GC000272", (False, True): "GC000276"} class PickError(ValueError): """고름 틀림 — 부르는 쪽이 400.""" +def _mach_logics(whole: Any, kinds: set, eq: dict | None = None) -> list[str]: + """기계경비 기초 차례 — 갈래 첫 기초(굴착기 = 무한궤도) › 그 밖 · `eq` 가 있으면 + 연료 · 조종원 없는 기종 = 그 기초 하나 · 아니면 그 기계(또는 같은 이름 기계)를 고르기에 둔 기초가 앞.""" + first = next((c for k, c in MACHINE.items() if k in kinds), MACHINE_ANY) + order = [first, *sorted(MACHINE_SET - {first})] + if not eq: + return order + has = eq.get("값") if isinstance(eq.get("값"), dict) else {} + if solo := SOLO.get((bool(has.get("주연료")), bool(has.get("조종원")))): + return [solo] + eqs = whole.numbers.get("EQ", {}) + + def takes(code: str) -> bool: + ins = whole.logic(code).get("입력") or [] + opts = next((s.get("고르기") or [] for s in ins if s.get("이름") == MACH_IN), []) + same = any((eqs.get(o) or {}).get("이름") == eq.get("이름") for o in opts) + return eq.get("원문번호") in opts or same + + hit = [c for c in order if takes(c)] + return hit + [c for c in order if c not in hit] + + def _picked(one: dict[str, Any], pick: Any, us: units.Units) -> dict[str, Any]: """사용자 고름 → 이어짐 단위 — 노임 LB · 기계 EQ(→ 기계경비 로직) · 자재 MT · 율 기준 · 뺌.""" if not isinstance(pick, dict): @@ -50,7 +78,8 @@ def _picked(one: dict[str, Any], pick: Any, us: units.Units) -> dict[str, Any]: field, head = PICKS.get(kind, ("", "?")) code = str(pick.get(field) or "") if not field or not code.startswith(head): - raise PickError(f"{units.HOW.get(kind, kind)} 줄 · {field or '뺌'} 키({head}…)로 고름") + shown = head if isinstance(head, str) else head[0] + raise PickError(f"{units.HOW.get(kind, kind)} 줄 · {field or '뺌'} 키({shown}…)로 고름") try: row = us.whole.get(code) except Exception as e: # noqa: BLE001 — 없는 키 @@ -58,7 +87,7 @@ def _picked(one: dict[str, Any], pick: Any, us: units.Units) -> dict[str, Any]: unit: dict[str, Any] = {field: code} if kind == "X": kinds = mtypes(f"{row.get('상세구분')} {row.get('이름')}") - unit["로직"] = skpatch._mach_logics(us.whole, kinds, row)[0] + unit["로직"] = _mach_logics(us.whole, kinds, row)[0] unit |= {"할증": one["할증"]} if one.get("할증") else {} if kind == "M" and (one.get("어떻게") == "별도" or pick.get("별도계상")): unit["별도계상"] = True @@ -110,7 +139,7 @@ def adds(block: dict, rows: list[dict], us: units.Units, ref_of: Any) -> tuple[l add = {"종류": "인력", "요소": unit["노임"], "이름": name, "단위": "인", "수량": q} elif kind == "X": eq = us.whole.get(unit["기계"]) or {} - given = {skpatch.MACH_IN: eq.get("원문번호")} + given = {MACH_IN: eq.get("원문번호")} q *= float(unit.get("할증") or 1) add = { "종류": "로직", diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 3dd51b9ad..9513f0af8 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -5,38 +5,19 @@ 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + 서고 나무 자유 폴더(158-1 · 716-4 `{종류}-tree` · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + -일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · -338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · -올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) · -718-1 한시 불러오기 = 올림 지문(작업 중 같은 엑셀 = 그 파일 `이어서` · 끝낸 것 = 새로 · 743-1) · -지우기 = [끝내기](`M02_Hopyo_Forget.finish` · 올림 때 하루 지난 HP 도 끝냄). +일위대가 실무자료 길 = `M02_Hopyo_Router`(785-5). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations -from pathlib import Path from typing import Any, Literal -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from pydantic import BaseModel, ConfigDict, Field +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from common_util.common_util_auth import verify_session -from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build -from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel -from M02_MasterTemplete import M02_Hopyo_Fit as hopyo_fit -from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget -from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid -from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item -from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger -from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks -from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match -from M02_MasterTemplete import M02_Hopyo_Ours as hopyo_ours -from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste -from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink -from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun -from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access @@ -206,420 +187,3 @@ def new_material( raise HTTPException(status_code=400, detail="신규 자재는 개인 층에서만") folder = _root(session, layer, owner, write=True) return _call(folder, own.add, folder, body) - - -# ── 일위대가 실무자료(324 · 338) ─────────────────────────────────────── -HOPYO_MAX_BYTES = 20 * 1024 * 1024 - - -hopyo_ours.warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 - - -def _who(session: Session) -> dict[str, Any]: - return {"id": session.get("user_id"), "이름": session.get("name")} - - -def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: - mark = hopyo_store.fingerprint(data) - hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) - if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): - return {**same, "이어서": True} - try: - parsed = hopyo_excel.read_book(name, data) - except hopyo_excel.ExcelError as error: - raise store.StoreError(400, str(error)) from error - try: - grid = hopyo_grid.read(name, data, parsed) - except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) - grid = None - row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) - hopyo_fit.ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) - return row - - -@router.get("/layers/{layer}/hopyo") -def hopyo_list( - layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" - folder = _root(session, layer, owner) - rows = hopyo_store.rows(folder) - for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) - hopyo_fit.ensure(folder, row["키"]) - return {"줄": rows} - - -@router.get("/layers/{layer}/hopyo-picks") -def hopyo_picks( - layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 - `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합).""" - folder = _root(session, layer, owner) - return {"줄": _call(folder, _picks, folder)} - - -def _picks(folder: Path) -> list[dict[str, Any]]: - rows, here, done = hopyo_match.picks(folder), refs.where(folder), {} - for row in rows: - src = (row["층"], row.get("주인")) - if src not in done: - done[src] = hopyo_build.confirmed(folder, src) - key = done[src].get(hopyo_build.mark(row["키"], row["번"])) - row["확인"] = {"층": here[0], "주인": here[1], "키": key} if key else None - return rows - - -@router.post("/layers/{layer}/hopyo") -def hopyo_upload( - layer: Layer, - file: UploadFile = File(...), - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · - 엉터리 · 시트 없음 400.""" - folder = _root(session, layer, owner, write=True) - data = file.file.read(HOPYO_MAX_BYTES + 1) - if len(data) > HOPYO_MAX_BYTES: - raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)") - name = Path(file.filename or "실무자료").name[:120] - return _call(folder, _upload, name, data, _who(session)) - - -@router.get("/layers/{layer}/hopyo/{key}") -def hopyo_read( - layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합.""" - folder = _root(session, layer, owner) - return _call(folder, hopyo_match.view, folder, key) - - -@router.get("/layers/{layer}/hopyo-grid/{key}") -def hopyo_grid_read( - layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` · - 옛 파일 404.""" - folder = _root(session, layer, owner) - return _call(folder, hopyo_store.grid, folder, key) - - -@router.get("/layers/{layer}/hopyo/{key}/{no}") -def hopyo_one( - layer: Layer, - key: str, - no: int, - owner: str | None = None, - to: Layer | None = None, - to_owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · - 호표 참조 줄 체크 · 만든).""" - folder = _root(session, layer, owner) - target = _root(session, to, to_owner) if to is not None else None - return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target)) - - -class GuessBody(BaseModel): - """`차례` = 호표 안 줄(0부터) · `대상` = [{로직 | 자재, 입력값?, 몫?}](낱개도 받음).""" - - 차례: int - 대상: list[dict[str, Any]] | dict[str, Any] - - -@router.post("/layers/{layer}/hopyo/{key}/{no}/guess") -def hopyo_guess( - layer: Layer, - key: str, - no: int, - body: GuessBody, - owner: str | None = None, - to: Layer | None = None, - to_owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """미리 셈(저장 안 함 · 설계 3-4) — 고른 대상 · 옵션으로 엔진 한 번 → `{대상, 품, 글}` · - `to` = 만들 층(없으면 파일 층).""" - folder = _root(session, layer, owner) - target = _root(session, to, to_owner) if to is not None else None - args = (folder, key, no, body.차례, body.대상) - return _call(folder, lambda: hopyo_match.guess(*args, to=target)) - - -class _Strict(BaseModel): - model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈) - - -class BuildTarget(_Strict): - """대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫.""" - - 로직: str | None = None - 자재: str | None = None - 호표: int | None = None - 전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈) - 노임: str | None = None # 729-2 사용자 이음 — LB 키(엑셀 숫자 + 이음) - 기계: str | None = None # EQ 키 - 비율: Literal["노무비", "재료비", "경비"] | None = None # % 줄 밑수 - 입력값: dict[str, Any] = Field(default_factory=dict) - 배: float = 1 - 몫: float | None = None - - -class BuildLine(_Strict): - """확인한 줄 — `차례` = 호표 안 줄(0부터) · `대상` 목록 또는 `뺌` 까닭.""" - - 차례: int - 대상: list[BuildTarget] = Field(default_factory=list) - 뺌: Literal["우리에 없음", "안 씀"] | None = None - - -class BuildWhole(_Strict): - 대상: list[BuildTarget] - - -class BuildBody(_Strict): - """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` · - 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음.""" - - 층: Layer - 주인: str | None = None - 키: str - 번: int - 줄: list[BuildLine] = Field(default_factory=list, max_length=500) - 통째: BuildWhole | None = None - - -@router.post("/layers/{layer}/hopyo-build") -def hopyo_make( - layer: Layer, - body: BuildBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → - `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}` · 하위 호표 확인 전 = 함께 만듦(실패 409).""" - folder = _root(session, layer, owner, write=True) - source = _root(session, body.층, body.주인) - lines = [one.model_dump(exclude_none=True) for one in body.줄] - whole = body.통째.model_dump(exclude_none=True) if body.통째 else None - args = (folder, source, body.키, body.번, lines, whole, _who(session)) - return _call(folder, hopyo_build.build, *args) - - -@router.post("/layers/{layer}/hopyo-unlinked") -def hopyo_unlinked( - layer: Layer, - body: BuildBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """안 이은 줄 미리 보기(729-2) — 만들기 몸 그대로 → `{남은줄 [{차례, 이름, 규격, 단위, 갈래, 까닭, - 후보}], 수}`(저장 없음).""" - folder = _root(session, layer, owner, write=True) - source = _root(session, body.층, body.주인) - lines = [one.model_dump(exclude_none=True) for one in body.줄] - whole = body.통째.model_dump(exclude_none=True) if body.통째 else None - args = (folder, source, body.키, body.번, lines, whole) - return _call(folder, hopyo_build.unlinked, *args) - - -class BuildAllBody(_Strict): - """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음).""" - - 층: Layer - 주인: str | None = None - 키: str - - -@router.post("/layers/{layer}/hopyo-build-all") -def hopyo_make_all( - layer: Layer, - body: BuildAllBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """[모두 고정으로 들여오기] — 시트 호표 모두 고정 줄로 · 하위 먼저 · 확인된 호표 그대로 → - `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`.""" - folder = _root(session, layer, owner, write=True) - source = _root(session, body.층, body.주인) - args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패 - return _call(folder, hopyo_build.build_all, *args) - - -@router.get("/layers/{layer}/hopyo-ledger") -def hopyo_ledger_read( - layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}` - (고침 = 이 층 · 쓰기 권한).""" - folder = _root(session, layer, owner) - writable = access.grant(session, layer, owner).write - return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)} - - -class LedgerBody(_Strict): - """`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사.""" - - 줄: dict[str, Any] - - -@router.put("/layers/{layer}/hopyo-ledger") -def hopyo_ledger_write( - layer: Layer, - body: LedgerBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`.""" - folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session)) - - -@router.delete("/layers/{layer}/hopyo/{key}") -def hopyo_delete( - layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """실무자료 지우기 = [끝내기](718-1 · 743-1 · 화면 떠남도) — HP · 격자 · 맞춤셈 캐시 지움 · - 만든 일위대가 · 단가산출은 엑셀 사본만 걷고 남음 · 별칭 장부 그대로 → `{지움, 만든}`.""" - folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_forget.finish, folder, key) - - -class SangeunBody(_Strict): - """`확인` = 사용자가 확인한 블록만 단위 SK `[{번, 고름?: {차례: 고름}}]`(778-5 · 자동 없음).""" - - 확인: list[dict[str, Any]] = Field(default_factory=list, max_length=500) - - -@router.post("/layers/{layer}/hopyo/{key}/sangeun") -def hopyo_make_sangeun( - layer: Layer, - key: str, - body: SangeunBody | None = None, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """불러오기 결과 「단가산출」(718-1 · 778-5) — 블록마다 줄 판정 · 확인한 블록만 단위 SK - (파일 층 = 이 층) → `{블록, 판정, 만듦, 못만듦, 그대로, 같은것씀, 위층같음}` · 맞춤 셈 전 409.""" - folder = _root(session, layer, owner, write=True) - picks = body.확인 if body else [] - return _call(folder, hopyo_sangeun.build, folder, key, _who(session), picks) - - -class PasteBody(_Strict): - """엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) · - `줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함).""" - - 글: str = Field(max_length=hopyo_paste.TEXT_MAX) - 열: dict[str, int] | None = None - 줄만: bool = False - 있음: list[str] = Field(default_factory=list, max_length=500) - - -@router.post("/layers/{layer}/hopyo-paste") -def hopyo_paste_run( - layer: Layer, - body: PasteBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """[Ctrl+V] — `갈래` 물음(열 짝) · 줄(고정 줄 · 저장은 문서 [저장]) · 만듦(실무자료 + 호표마다 사전조합 · - 층 쓰기 · `build_all` 답 + 키).""" - folder = _root(session, layer, owner, write=True) - args = (folder, body.글, body.열, _who(session), body.줄만, body.있음) - return _call(folder, hopyo_paste.paste, *args) - - -class RelinkRowsBody(BaseModel): - """다시 잇기 표(705-2) — `키` = 이 층 사전조합들 · `문서` = 열린 문서(저장 전 · 줄 · 후보만).""" - - 키: list[str] = Field(default_factory=list, max_length=500) - 문서: dict[str, Any] | None = None - - -@router.post("/layers/{layer}/hopyo-relink/rows") -def hopyo_relink_rows( - layer: Layer, - body: RelinkRowsBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """「이 공사 단가로 다시 잇기」 표 — 고정 줄마다 붙인 단가 · 이은 단가 · 후보(층 읽기).""" - folder = _root(session, layer, owner) - return _call(folder, hopyo_relink.rows, body.키, body.문서) - - -class RelinkBody(_Strict): - """바꿈 `[{키, 줄, 노임?, 자재?}]` — 이음 키만(숫자 칸 없음 · 값 null = 풂).""" - - 바꿈: list[dict[str, Any]] = Field(max_length=hopyo_relink.CHANGES_MAX) - - -@router.post("/layers/{layer}/hopyo-relink") -def hopyo_relink_apply( - layer: Layer, - body: RelinkBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`.""" - folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_relink.apply, body.바꿈) - - -@router.get("/layers/{layer}/hopyo/{key}/{no}/siblings") -def hopyo_siblings( - layer: Layer, - key: str, - no: int, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표 `{묶음 [{번, 규격, 열}], 이름}`.""" - folder = _root(session, layer, owner) - return _call(folder, hopyo_item.siblings, folder, key, no) - - -class ItemBody(BuildBody): - """[구조물 조합으로](704-4) — 확인 창 몸 그대로 + `묶음` = 규격 열로 묶을 호표 번 · `이름` = 항목 이름.""" - - 묶음: list[int] = Field(default_factory=list, max_length=20) - 이름: str | None = Field(None, max_length=120) - 새로: bool = False # 같은 내용 항목 409 뒤 — 그래도 새로(733-2) - 씀: str | None = Field(None, max_length=80) # 같은 내용 409 뒤 — 그 항목 씀 - - -@router.post("/layers/{layer}/hopyo-item") -def hopyo_to_item( - layer: Layer, - body: ItemBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """실무 호표 → 이 층 구조물 조합 항목(사전조합 안 만듦 · 이 층 쓰기 · 파일 층 읽기) → - `{key, 열, 이름, 줄수, 열수, 빠진줄}`.""" - folder = _root(session, layer, owner, write=True) - source = _root(session, body.층, body.주인) - lines = [one.model_dump(exclude_none=True) for one in body.줄] - whole = body.통째.model_dump(exclude_none=True) if body.통째 else None - args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session)) - args += (body.새로, body.씀) - return _call(folder, hopyo_item.make_item, *args) - - -@router.get("/layers/{layer}/hopyo-marks/{key}") -def hopyo_marks_read( - layer: Layer, - key: str, - owner: str | None = None, - to: Layer | None = None, - to_owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """하이라이트 재료(779-2 · 읽기만) — `블록 [{번, 판정, SK, 갈래, 못옮김}]` · `to` = SK 를 볼 층.""" - folder = _root(session, layer, owner) - target = _root(session, to, to_owner) if to else folder - return _call(folder, lambda: {"블록": hopyo_marks.blocks(folder, key, target)}) diff --git a/main.py b/main.py index c03b8c3d1..e9f2b698c 100644 --- a/main.py +++ b/main.py @@ -96,6 +96,7 @@ from E03_StabSection.E03_StabSection_Router_WhatIf import router as e03_stab_wha from E03_StabSection.E03_StabSection_Router_Prob import router as e03_stab_prob_router from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router +from M02_MasterTemplete.M02_Hopyo_Router import router as m02_hopyo_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router @@ -563,6 +564,7 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +app.include_router(m02_hopyo_router, dependencies=protected) # 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음 app.include_router( __import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router, diff --git a/resources/tester/test_628_hopyo_clause_part.py b/resources/tester/test_628_hopyo_clause_part.py index 39bbc715a..18d0e12e3 100644 --- a/resources/tester/test_628_hopyo_clause_part.py +++ b/resources/tester/test_628_hopyo_clause_part.py @@ -82,7 +82,6 @@ def test_예시1_근거_인력운반_지게(): assert got["차례"][0] == "GC001140" # 지게(사람 줄) — 1141 기계설비 · 1142 벽돌 아님 maker = sangeun.Maker.__new__(sangeun.Maker) maker.first, maker.blocks, maker.whole = {10: line}, {10: block}, m01.loaded()[1] - maker.cache = {fit.unit_key(line): got} basis = maker.basis(10) assert basis["로직"] == "GC001140" and basis["이름"].startswith("인력운반(지게)") judged = skunit.judge(block, units.Units(), lambda sub: None, None, "㎡") diff --git a/resources/tester/test_642_5_make.py b/resources/tester/test_642_5_make.py new file mode 100644 index 000000000..aa449eb04 --- /dev/null +++ b/resources/tester/test_642_5_make.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""642-5(PLAN 785-5) 만들기 · 길 지킴 시험(설계 642-A 5-3 넷째 줄) — 임시 storage(`client` 마당). + +- 판정 길(목록 · 호표 · 블록) · 검산 길(입력값 = 엔진이 쓴 칸 모두 · 지역 · 자재지역 · 조종원직종 비움) +- 옮기기 → SC 줄(고른 것마다 · 같은 별칭 · 본뜬 {호표, 차례, 행} · 수량 = 엑셀 × 몫) · 같은 기초 둘째 = 사본 · + 별도계상 자재 · 입력 필요 = 그 줄만 막힘(고정 줄로 남음) · SK 더한줄(바탕 없음) · 장부 · 다시 옮김 = SC 안 늚 +- 금액 · 품 칸 막음 · M01 원본 그대로 +""" + +import json + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Build as hbuild +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401 + +API = "/api/m02/layers/personal" +KEY = "HP-000001" + + +def _mine(): + return layers.personal_dir(7, 42) + + +def _doc(kind, key): + return json.loads((_mine() / kind / f"{key}.json").read_bytes()) + + +def _at(unit, no): + """호표 줄 판에서 단산 N 을 부르는 줄.""" + return next(r for r in unit["줄"] if r.get("번") == no and "단가산출" in r["갈래"]) + + +def _base(row, sec=0): + """그 줄 풀이 절의 고른 기초 · 입력값.""" + part = row["풀이"]["절"][sec] + cand = next(c for c in part["후보"] if c["키"] == part["고름"]) + return {"갈래": "기초", "키": cand["키"], "입력값": cand["바인딩"]["입력값"]} + + +def _specs(code): + m = hb.m01.cm.master() + return {s["이름"] for s in hb.specs_of(m.logic(code))} - set(hb.SKIP) + + +def test_판정_검산_옮기기(client): + before = master_hash() + assert _post(client).status_code == 200 + got = client.get(f"{API}/hopyo-judge/{KEY}").json() + assert got["셈중"] is True and got["블록"][9]["판정"] is None # 뒤 셈 전 + assert judge.judged(_mine(), KEY) is None + judge.solve_all(_mine(), KEY) # 올림 뒤 뒤 셈(시험은 바로) + got = client.get(f"{API}/hopyo-judge/{KEY}").json() + assert got["셈중"] is False and len(got["호표"]) == 68 and len(got["블록"]) == 95 + assert got["블록"][9]["번"] == 10 and got["블록"][9]["판정"] == "기초로 풂" + assert judge.judged(_mine(), KEY)[10] == {"판정": "기초로 풂", "고를수": 0} + assert got["호표"][0]["판정"] == {"제안 있음": 2, "2차 필요": 4} + + two = client.get(f"{API}/hopyo-judge/{KEY}/2").json() + ten = _at(two, 10) # 예시 1 야면석소운반 단산10 + assert ten["이름"] and "단가" in ten and ten["풀이"]["판정"] == "기초로 풂" + ex1 = _base(ten) + assert ex1["키"] == "GC001140" and ex1["입력값"]["운반무게"] == 575 + blk = client.get(f"{API}/hopyo-judge/{KEY}/block/10").json() + assert blk["부름"] and "몫" in blk["절"][0] and "금액" in blk + + check = client.post( + f"{API}/hopyo/{KEY}/check", json={"호표": 2, "차례": ten["차례"], "고른": [ex1]} + ).json() + one = check["고른"][0] + assert one["판정"] == "품 같음" and one["입력필요"] == [] + assert set(one["입력값"]) == _specs("GC001140") + assert not set(one["입력값"]) & set(hb.SKIP) # 지역 · 자재지역 · 조종원직종 비움 + assert int(one["금액"]["계"]) == 94227 # 엑셀 94,227 (원 미만 버림) + assert check["수량"]["우리"] == ten["수량"]["엑셀"] + + first = client.get(f"{API}/hopyo-judge/{KEY}/1").json() + rebar, form = _at(first, 47), _at(first, 24) # 예시 2 철근 · 예시 3 거푸집 + a, b = _base(rebar, 0), _base(rebar, 1) + b["입력값"] = {**b["입력값"], "구분": "토목"} # 엇갈림 = 사용자 고름 + a["몫"] = 0.5 + mat = first["줄"][0] # 레미콘 별도계상 + cement = {**mat["후보"][0], "별도계상": True} + stuck = {"갈래": "기초", "키": "GC000211", "입력값": {"유형": "보통"}} + ex3 = _base(form) # 예시 3 GC000211 보통 · 4회 · 높이감률 · 구멍땜 = 입력 필요 + url = f"{API}/hopyo/{KEY}/check" + need = client.post(url, json={"호표": 1, "차례": form["차례"], "고른": [ex3]}).json() + assert ( + need["고른"][0]["입력필요"] == ["높이감률", "구멍땜"] + and need["고른"][0]["금액"]["계"] is None + ) + ex3["입력값"] |= {"높이감률": 0, "구멍땜": 0} + got3 = client.post(url, json={"호표": 1, "차례": form["차례"], "고른": [ex3]}).json()["고른"][0] + assert ( + got3["판정"] == "품 같음" and abs(got3["금액"]["노무비"] - 38291) <= 1 + ) # 엑셀 노무 38,291 + other = next(r for r in two["줄"] if r["차례"] > ten["차례"]) # 같은 기초 둘째 줄 + twice = {**ex1, "입력값": {**ex1["입력값"], "운반거리": 12}} + body = { + "층": "personal", + "호표": { + "1": { + str(rebar["차례"]): [a, b], + str(form["차례"]): [stuck], + "0": [cement], + }, + "2": {str(ten["차례"]): [ex1], str(other["차례"]): [twice]}, + }, + "블록": {"10": [{**ex1, "몫": 1}]}, + } + res = client.post(f"{API}/hopyo/{KEY}/move", json=body) + assert res.status_code == 200, res.text + out = res.json() + assert [x["번"] for x in out["만듦"]] == [1, 2] and out["장부"]["씀"] >= 4 + assert out["막힘"] == [ + { + "호표": 1, + "차례": form["차례"], + "까닭": out["막힘"][0]["까닭"], + "칸": out["막힘"][0]["칸"], + } + ] + assert out["막힘"][0]["까닭"].startswith("입력 필요") and out["막힘"][0]["칸"] + + sc2 = _doc("precombo", out["만듦"][1]["키"]) + lines = sc2["담은로직"] + mine = next(x for x in lines if x.get("로직") == "GC001140") + assert mine["별칭"] == ten["이름"][: len(mine["별칭"])] + assert mine["본뜬"] == {"호표": 2, "차례": ten["차례"], "행": ten["행"]} + assert mine["수량"]["손"] == ten["수량"]["엑셀"] + assert set(mine["입력값"]) == _specs("GC001140") + wrap = next(x for x in lines if x.get("별칭") == other["이름"][: len(x.get("별칭") or "")]) + inner = wrap["참조"]["몸"]["담은로직"][ + 0 + ] # 같은 기초 둘째 = 한 줄 SC 사본(본뜬 = 사본 출처 740-1) + assert inner["로직"] == "GC001140" and inner["입력값"]["운반거리"] == 12 + assert len(lines) == len(two["줄"]) # 확인 안 한 줄 = 고정(빠짐 0) + + sc1 = _doc("precombo", out["만듦"][0]["키"]) + pair = [x for x in sc1["담은로직"] if (x.get("본뜬") or {}).get("차례") == rebar["차례"]] + assert [x["로직"] for x in pair] == ["GC000206", "GC000207"] + assert pair[0]["별칭"] == pair[1]["별칭"] # 한 엑셀 줄 = 우리 두 줄 · 같은 별칭 + excel = rebar["수량"]["엑셀"] + assert pair[0]["수량"]["손"] == excel * 0.5 and pair[1]["수량"]["손"] == excel + assert pair[1]["입력값"]["구분"] == "토목" + held = next( + x + for x in sc1["담은로직"] + if (x.get("고정") or {}).get("출처", {}).get("줄") == form["차례"] + ) + assert "본뜬" not in held # 막힌 줄 = 고정 줄(엑셀 숫자) + cement_row = next(m for m in sc1["자재"] if m["코드"] == cement["키"]) + assert labor.mark_of(cement_row, "별도계상") == "별도계상" + assert cement_row["수량"]["손"] == mat["수량"]["엑셀"] + + sk = _doc("sangeun", out["단가산출"][0]["키"]) + assert sk["바탕"] is None and [x["로직"] for x in sk["더한줄"]] == ["GC001140"] + assert sk["더한줄"][0]["입력값"]["운반무게"] == 575 + + book = ledger.read(_mine()) + assert any( + p.get("갈래") == "기초" and p.get("키") == "GC000207" + for row in book.values() + for p in row.get("짝") or [] + ) + scs = sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) + stop = client.post(f"{API}/hopyo/{KEY}/move", json=body) # 이미 옮긴 것 = 덮음 없이 409 + assert stop.status_code == 409 and [x["번"] for x in stop.json()["detail"]["호표"]] == [1, 2] + assert sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) == scs + again = client.post(f"{API}/hopyo/{KEY}/move", json={**body, "덮음": True}) + assert again.status_code == 200, again.text + again = again.json() + assert [x["키"] for x in again["만듦"]] == [x["키"] for x in out["만듦"]] + assert sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) == scs + assert hbuild.confirmed(_mine())[hbuild.mark(KEY, 2)] == out["만듦"][1]["키"] + assert client.delete(f"{API}/hopyo/{KEY}").status_code == 200 # [끝내기] = 저장본도 지움 + assert not list((_mine() / "실무자료" / "_derived").glob(f"*{KEY}*")) + assert master_hash() == before + + +def test_막는_길(client): + assert _post(client).status_code == 200 + pick = {"갈래": "기초", "키": "GC001140", "입력값": {}, "금액": 1} + bad = client.post( + f"{API}/hopyo/{KEY}/move", json={"층": "personal", "호표": {"2": {"0": [pick]}}} + ) + assert bad.status_code == 422 # 금액 · 품 칸 = 서버가 셈 + none = client.post(f"{API}/hopyo/{KEY}/move", json={"층": "personal", "호표": {"99": {}}}) + assert none.status_code == 404 + wrong = {"갈래": "인력", "키": "LB999999"} + res = client.post( + f"{API}/hopyo/{KEY}/move", json={"층": "personal", "호표": {"2": {"0": [wrong]}}} + ) + assert res.status_code == 400 diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index 48d77359f..704f8b06c 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -77,7 +77,6 @@ def test_결과_단가산출_확인한_블록만_단위_SK_겹침_없음(client) folder = layers.personal_dir(7, 42) assert _post(client).json()["키"] == "HP-000001" url = URL.format("personal") + "/HP-000001/sangeun" - assert client.post(url).status_code == 409 # 729-1 — 맞춤 셈 전 fit.run_job(folder, "HP-000001") got = client.post(url) assert got.status_code == 200, got.text diff --git a/resources/tester/test_729_hopyo_sk_link.py b/resources/tester/test_729_hopyo_sk_link.py index 450411f72..0c0ab1e09 100644 --- a/resources/tester/test_729_hopyo_sk_link.py +++ b/resources/tester/test_729_hopyo_sk_link.py @@ -51,7 +51,6 @@ def test_실무_단가산출_확인한_블록만_단위_SK_일위대가_막음(c folder = layers.personal_dir(7, 42) key = _post(client).json()["키"] url = f"{URL.format('personal')}/{key}/sangeun" - assert client.post(url).status_code == 409 # 맞춤 셈 전 fit.run_job(folder, key) got = client.post(url, json={}).json() assert got["만듦"] == [] and not _sk_docs(folder) # 확인 없음 = 판정만 @@ -64,7 +63,7 @@ def test_실무_단가산출_확인한_블록만_단위_SK_일위대가_막음(c for no, head in SAMPLES.items(): doc = docs[no] assert doc["이름"].startswith(head) and doc["바탕"] is None, no - assert doc["본뜬"]["갈래"] == skunit.UNIT and doc["본뜬"]["근거"] == rows[no]["근거"] + assert doc["본뜬"]["갈래"] == skunit.UNIT and doc["본뜬"].get("근거") == rows[no]["근거"] mine = [x["수량"] for x in doc["더한줄"] if x.get("종류") == "인력"] # 엑셀 품 그대로 excel = [r["매개값"]["품"] for r in rows[no]["줄"] if r["묶음"] == "L"] assert mine == excel, no @@ -165,7 +164,7 @@ def test_노임_이름_이음_기계_EQ_이음_사용자_이음(client): line = {"갈래": "자재", "이름": "없는자재", "규격": "", "단위": "개", "수량": 2, "단가": {"재료비": 10}, "차례": 0} # fmt: skip one = hfix.line_of(line, [], {"파일": "x", "호표": 1}, set(), linker, {"노임": "LB000002"}) - assert one["고정"]["이음"] == {"노임": "LB000002", "자재": None, "기계": None} + assert one["고정"]["이음"] == {"노임": "LB000002", "자재": None, "기계": None, "유가": None} assert hfix.linked(one) and not hfix.linked(hfix.line_of(line, [], {}, set(), linker)) diff --git a/resources/tester/test_est_standalone.py b/resources/tester/test_est_standalone.py index 99a8cc9de..562d2b76b 100644 --- a/resources/tester/test_est_standalone.py +++ b/resources/tester/test_est_standalone.py @@ -18,7 +18,7 @@ def calculate(data): def test_est_test_calculation_and_linked_price_change(): data = { "sources": [{"id": "s", "kind": "재료비", "price": 10.129}], - "units": [{"id": "u", "lines": [{"sourceId": "s", "quantity": 2}]}], + "units": [{"id": "u", "lines": [{"sourceId": "s", "quantity": 2, "digits": 2}]}], "estimate": [{"unitId": "u", "quantity": 3}], "costs": [{"base": "직접공사비", "rate": 10}, {"base": "누계", "rate": 5}], } @@ -42,6 +42,74 @@ def test_est_test_unlinked_rows_contribute_zero(): assert result["units"]["u"]["total"] == result["total"] == 0 +def test_source_selection_recursive_unit_and_percent(): + data = { + "sources": [ + { + "id": "s", + "kind": "실적", + "price": 900, + "prices": [0, 125, 155, 0, 0, 0], + "priceMode": "평균", + } + ], + "units": [ + {"id": "u1", "name": "기초", "lines": [{"sourceId": "s", "quantity": 2}]}, + { + "id": "u2", + "name": "상위", + "lines": [ + {"type": "일위대가", "sourceId": "", "unitId": "u1", "quantity": 1}, + { + "type": "비율", + "sourceId": "", + "base": "실적", + "kind": "경비", + "quantity": 10, + }, + ], + }, + ], + "estimate": [{"unitId": "u2", "quantity": 2}], + "costs": [], + } + result = calculate(data) + assert result["units"]["u1"]["parts"]["실적"] == 280 + assert result["units"]["u2"]["parts"]["경비"] == 28 + assert result["directTotal"] == 616 + + +def test_sample_percentage_row_and_cycle_error(): + data = { + "sources": [{"id": "labor", "kind": "노무비", "price": 66841}], + "units": [ + { + "id": "u", + "name": "호표", + "lines": [ + {"sourceId": "labor", "quantity": 1}, + { + "type": "비율", + "sourceId": "", + "base": "노무비", + "kind": "재료비", + "quantity": 2, + }, + ], + } + ], + "estimate": [], + "costs": [], + } + result = calculate(data) + assert result["units"]["u"]["parts"]["재료비"] == 1336 + data["units"][0]["lines"].append( + {"type": "일위대가", "sourceId": "", "unitId": "u", "quantity": 1} + ) + assert result["errors"] == [] + assert calculate(data)["errors"] + + def test_progress_uses_approved_quantity_only_when_selected(): script = ( 'const vm=require("vm"),fs=require("fs");const c={};' @@ -55,3 +123,71 @@ def test_progress_uses_approved_quantity_only_when_selected(): assert ordinary["currentAmount"] == 300 assert ordinary["cumulativeAmount"] == 500 assert approved["cumulativeAmount"] == 300 + + +def test_machine_components(): + script = ( + 'const vm=require("vm"),fs=require("fs");const c={};' + 'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.calc=EstCalc",c);' + "console.log(JSON.stringify(c.calc.machine({purchase:1000000,rentalCoefficient:100," + "fuelPrice:1000,fuelQuantity:2,suppliesRate:5,laborPrice:8000,laborFactor:0.125,laborHours:1})));" + ) + result = json.loads( + subprocess.check_output(["node", "-e", script, str(CALC)], text=True, encoding="utf-8") + ) + assert result == {"재료비": 2100, "노무비": 1000, "경비": 10, "실적": 0} + + +def test_formula_parser_rejects_invalid_expressions(): + formula = ROOT / "tools" / "est-test" / "formula.js" + script = ( + 'const vm=require("vm"),fs=require("fs");const c={};' + 'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.f=EstFormula",c);' + 'let failed=false;try{c.f.evaluate("1/0",{})}catch{failed=true};' + 'console.log(JSON.stringify([c.f.evaluate("([금액]+2)*3",{"금액":8}),failed]));' + ) + assert json.loads( + subprocess.check_output(["node", "-e", script, str(formula)], text=True, encoding="utf-8") + ) == [30, True] + + +def test_escalation_comparison_branches(): + workflows = ROOT / "tools" / "est-test" / "workflows.js" + script = ( + 'const vm=require("vm"),fs=require("fs");const c={};' + 'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.w=EstWorkflows",c);' + "console.log(JSON.stringify([c.w.escalation(100,80,120,2)," + "c.w.escalation(100,110,125,2),c.w.escalation(100,80,90,1)," + "c.w.excluded(3,5,4)]));" + ) + a, b, lower, excluded = json.loads( + subprocess.check_output(["node", "-e", script, str(workflows)], text=True, encoding="utf-8") + ) + assert (a["rate"], a["difference"], a["amount"]) == (20, 16, 192) + assert (b["difference"], b["amount"]) == (15, 250) + assert lower["difference"] == 0 + assert excluded == 5 + + +def test_resource_quantities_follow_nested_units(): + workflows = ROOT / "tools" / "est-test" / "workflows.js" + script = ( + 'const vm=require("vm"),fs=require("fs");const c={};' + 'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.w=EstWorkflows",c);' + "console.log(JSON.stringify(c.w.resources(JSON.parse(process.argv[2]))));" + ) + data = { + "sources": [{"id": "s", "name": "자재"}], + "units": [ + {"id": "a", "lines": [{"sourceId": "s", "quantity": 2}]}, + {"id": "b", "lines": [{"type": "일위대가", "unitId": "a", "quantity": 3}]}, + ], + "estimate": [{"unitId": "b", "quantity": 4}], + } + result = json.loads( + subprocess.check_output( + ["node", "-e", script, str(workflows), json.dumps(data)], text=True, encoding="utf-8" + ) + ) + assert result["rows"][0]["quantity"] == 24 + assert result["errors"] == [] diff --git a/resources/tester/test_m02_hopyo_excel.py b/resources/tester/test_m02_hopyo_excel.py index 75cce83e0..d7602ff87 100644 --- a/resources/tester/test_m02_hopyo_excel.py +++ b/resources/tester/test_m02_hopyo_excel.py @@ -21,6 +21,7 @@ from config import config_system from M02_MasterTemplete import M02_Hopyo_Excel as hx from M02_MasterTemplete import M02_Hopyo_Fit as fit from M02_MasterTemplete import M02_Hopyo_Ours as ours +from M02_MasterTemplete import M02_Hopyo_Router as hopyo_router from M02_MasterTemplete import M02_Hopyo_Store as hs from M02_MasterTemplete import M02_Labor_Router as labor_router from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -133,6 +134,9 @@ def calc_cache(tmp_path, monkeypatch) -> Path: shutil.copy(ours.path_of(), cache / ours.path_of().name) monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache)) monkeypatch.setattr(fit, "ensure", fit.status) + monkeypatch.setattr( + hopyo_router.hopyo_judge, "_start", lambda *_: None + ) # 블록 풀이 뒤 셈 안 돌림 return cache @@ -148,6 +152,7 @@ def client(tmp_path, monkeypatch): calc_cache(tmp_path, monkeypatch) app = FastAPI() app.include_router(labor_router.router) + app.include_router(hopyo_router.router) # 785-5 hopyo 길 옮김 who = dict(USER) app.dependency_overrides[verify_session] = lambda: who got = TestClient(app) @@ -194,7 +199,7 @@ def test_엉터리_시트없음_큰_파일_400(client, monkeypatch): assert bad.status_code == 400 and "읽지 못함" in bad.json()["detail"] none = _post(client, data=NO_SHEET.read_bytes(), name=NO_SHEET.name) assert none.status_code == 400 and none.json()["detail"] == "일위대가 시트 없음" - monkeypatch.setattr(labor_router, "HOPYO_MAX_BYTES", 1000) + monkeypatch.setattr(hopyo_router, "HOPYO_MAX_BYTES", 1000) big = _post(client) assert big.status_code == 400 and "20 MB" in big.json()["detail"] assert client.get(URL.format("personal")).json()["줄"] == [] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 940def73e..a7a40486e 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -2,6 +2,6 @@ `index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다. -기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름, 간단한 수량산출 연결과 기성 수량 입력을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다. +기초 단가의 6개 출처 선택, 일위대가 간 참조·비율, 4개 비목 집계, 단순 기계경비, 원가 사칙연산 수식, 수량산출 연결, 기성 수량, 물가변동 품목 단가, 동원 수량 전개를 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다. -EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 및 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다. +EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비의 외화·다중 연료·낙찰 분기, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 원가 연동, 동원의 산근 전개와 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다. diff --git a/tools/est-test/app.js b/tools/est-test/app.js index 0fc3fa994..40b9893e6 100644 --- a/tools/est-test/app.js +++ b/tools/est-test/app.js @@ -2,6 +2,8 @@ const key = 'aislo-est-test-v1'; const sample = () => ({ projectName: '시험 내역서', quantities: [], + machines: [], + escalation: [], progress: [], progressApproved: false, sources: [ @@ -18,11 +20,11 @@ function valid(d) { const text = v => typeof v === 'string' && v.length <= 500; const num = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e12; const ids = arr => new Set(arr.map(x => x.id)).size === arr.length; - return d && (d.projectName === undefined || text(d.projectName)) && (d.progressApproved === undefined || typeof d.progressApproved === 'boolean') && (d.progress === undefined || (Array.isArray(d.progress) && d.progress.length <= 1000 && d.progress.every(p => text(p.estimateId) && num(p.previous) && num(p.current) && num(p.approved)))) && (d.quantities === undefined || (Array.isArray(d.quantities) && d.quantities.length <= 1000 && d.quantities.every(q => text(q.id) && text(q.estimateId) && text(q.name) && num(q.length) && num(q.width) && num(q.count)))) && ['sources', 'units', 'estimate', 'costs'].every(k => Array.isArray(d[k]) && d[k].length <= 1000) && - d.sources.every(s => text(s.id) && text(s.name) && text(s.spec) && text(s.unit) && EstCalc.kinds.includes(s.kind) && num(s.price)) && ids(d.sources) && - d.units.every(u => text(u.id) && text(u.name) && text(u.unit) && Array.isArray(u.lines) && u.lines.length <= 1000 && u.lines.every(l => text(l.sourceId) && num(l.quantity))) && ids(d.units) && + return d && (d.projectName === undefined || text(d.projectName)) && (d.escalation === undefined || (Array.isArray(d.escalation) && d.escalation.length <= 1000 && d.escalation.every(e => text(e.name) && ['bid','contract','current','quantity','planned','actual','progress'].every(k=>num(e[k]))))) && (d.machines === undefined || (Array.isArray(d.machines) && d.machines.length <= 1000 && d.machines.every(m => text(m.sourceId) && ['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].every(k => num(m[k]))))) && (d.progressApproved === undefined || typeof d.progressApproved === 'boolean') && (d.progress === undefined || (Array.isArray(d.progress) && d.progress.length <= 1000 && d.progress.every(p => text(p.estimateId) && num(p.previous) && num(p.current) && num(p.approved)))) && (d.quantities === undefined || (Array.isArray(d.quantities) && d.quantities.length <= 1000 && d.quantities.every(q => text(q.id) && text(q.estimateId) && text(q.name) && num(q.length) && num(q.width) && num(q.count)))) && ['sources', 'units', 'estimate', 'costs'].every(k => Array.isArray(d[k]) && d[k].length <= 1000) && + d.sources.every(s => text(s.id) && text(s.name) && text(s.spec) && text(s.unit) && EstCalc.kinds.includes(s.kind) && num(s.price) && (s.prices === undefined || (Array.isArray(s.prices) && s.prices.length === 6 && s.prices.every(num))) && (s.priceMode === undefined || ['직접입력','최저','평균','최고','선택'].includes(s.priceMode)) && (s.priceIndex === undefined || (Number.isInteger(s.priceIndex) && s.priceIndex >= 0 && s.priceIndex < 6))) && ids(d.sources) && + d.units.every(u => text(u.id) && text(u.name) && text(u.unit) && Array.isArray(u.lines) && u.lines.length <= 1000 && u.lines.every(l => text(l.sourceId) && num(l.quantity) && (l.type === undefined || ['기초단가','일위대가','비율'].includes(l.type)) && (l.unitId === undefined || text(l.unitId)) && (l.digits === undefined || (Number.isInteger(l.digits) && l.digits >= 0 && l.digits <= 6)) && (l.base === undefined || [...EstCalc.kinds,'합계','비목별'].includes(l.base)) && (l.kind === undefined || EstCalc.kinds.includes(l.kind)))) && ids(d.units) && d.estimate.every(e => text(e.id) && text(e.name) && text(e.unitId) && num(e.quantity)) && ids(d.estimate) && - d.costs.every(c => text(c.id) && text(c.name) && [...EstCalc.kinds, '직접공사비', '누계'].includes(c.base) && num(c.rate)) && ids(d.costs); + d.costs.every(c => text(c.id) && text(c.name) && [...EstCalc.kinds, '직접공사비', '누계'].includes(c.base) && num(c.rate) && (c.formula === undefined || text(c.formula))) && ids(d.costs); } let data; try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); } @@ -42,14 +44,18 @@ const card = (title, subtitle, button, body, note = '') => `
`
${label}${money(n)} 원
`).join(''); + $('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['실적',result.direct['실적']],['원가 합계',result.total]].map(([label,n]) => `
${label}${money(n)} 원
`).join(''); + if (result.errors.length) $('summary').innerHTML += ``; document.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); const sourceChoices = [['', '선택'], ...data.sources.map(s => [s.id, `${s.name} (${s.unit})`])]; const unitChoices = [['', '선택'], ...data.units.map(u => [u.id, `${u.name} (${u.unit})`])]; const kindChoices = EstCalc.kinds.map(k => [k,k]); if (tab === 'sources') { - const rows = data.sources.map((s,i) => `${field('sources',i,'name',s.name)}${field('sources',i,'spec',s.spec)}${field('sources',i,'unit',s.unit)}${select('sources',i,'kind',s.kind,kindChoices)}${field('sources',i,'price',s.price,'number')}${remove('sources',i)}`); - $('panel').innerHTML = card('기초 단가', '자재·노무·장비 등의 시험 단가를 직접 입력합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','단가 (원)',''],rows), '이곳의 단가가 일위대가 구성 행에 연결됩니다. 연결 중인 단가는 삭제할 수 없습니다.'); + const rows = data.sources.map((s,i) => `${field('sources',i,'name',s.name)}${field('sources',i,'spec',s.spec)}${field('sources',i,'unit',s.unit)}${select('sources',i,'kind',s.kind,kindChoices)}${select('sources',i,'priceMode',s.priceMode || '직접입력',['직접입력','최저','평균','최고','선택'].map(k=>[k,k]))}${Array.from({length:6},(_,n)=>`${field('sources',i,`price${n}`,s.prices?.[n] || 0,'number')}`).join('')}${field('sources',i,'priceIndex',(s.priceIndex || 0)+1,'number')}${field('sources',i,'price',s.price,'number')}${money(EstCalc.sourcePrice(s))}${remove('sources',i)}`); + $('panel').innerHTML = card('기초 단가', '단가 출처 여섯 칸과 적용 방법을 선택합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','적용 방법','출처1','출처2','출처3','출처4','출처5','출처6','선택 번호','입력 단가','적용 단가',''],rows), '최저·평균·최고는 0이 아닌 출처를 사용하며 평균은 정수 절사합니다.'); + } else if (tab === 'machines') { + const rows = (data.machines || []).map((m,i) => { const p=EstCalc.machine(m); return `${select('machines',i,'sourceId',m.sourceId,sourceChoices)}${['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].map(k=>`${field('machines',i,k,m[k],'number')}`).join('')}${EstCalc.kinds.map(k=>`${money(p[k])}`).join('')}${remove('machines',i)}`; }); + $('panel').innerHTML = card('기계경비', '기초단가 항목에 손료·연료·운전사 금액을 연결합니다.', add('machines','기계 추가'), table(['연결 기초단가','기계가격','손료계수','연료단가','연료량','잡품률 %','노임단가','환산계수','운전시간','재료비','노무비','경비','실적',''],rows), '손료 = 기계가격 × 손료계수 × 10⁻⁷, 잡품 = 연료 × 잡품률, 운전사 = 노임 × 환산계수 × 시간. 외화·다중 연료·낙찰률 등은 아직 미구현입니다.'); } else if (tab === 'units') { if (!data.units.some(u => u.id === selected)) selected = data.units[0]?.id || ''; const u = data.units.find(u => u.id === selected), ui = data.units.indexOf(u); @@ -58,17 +64,24 @@ function render() { if (u) { body += table(['일위대가 이름','단위','합계 (원)'],[`${field('units',ui,'name',u.name)}${field('units',ui,'unit',u.unit)}${money(result.units[u.id].total)}`]); body += `

구성 항목

${add('lines','구성 추가')}
`; - body += table(['기초 단가','소요량','단가 (원)','비목','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; return `${select('lines',i,'sourceId',l.sourceId,sourceChoices)}${field('lines',i,'quantity',l.quantity,'number')}${r.source ? money(r.source.price) : '—'}${esc(r.source?.kind || '미연결')}${money(r.amount)}${remove('lines',i)}`;})); - body += `
${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}
행 금액은 소수 둘째 자리까지 절사하고 비목별 합계는 원 미만을 절사합니다.
`; + body += table(['종류','연결 / 비율 기준','결과 비목','수량 / 요율','자릿수','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; const type=l.type || '기초단가'; const link=type==='일위대가' ? select('lines',i,'unitId',l.unitId || '',unitChoices.filter(x => x[0] !== u.id)) : type==='비율' ? select('lines',i,'base',l.base || '합계',[...EstCalc.kinds,'합계','비목별'].map(k=>[k,k])) : select('lines',i,'sourceId',l.sourceId,sourceChoices); return `${select('lines',i,'type',type,['기초단가','일위대가','비율'].map(k=>[k,k]))}${link}${type==='비율' ? select('lines',i,'kind',l.kind || '경비',kindChoices) : esc(r.source?.kind || (r.unit ? '비목별' : '미연결'))}${field('lines',i,'quantity',l.quantity,'number')}${field('lines',i,'digits',l.digits ?? 0,'number','max="6"')}${money(r.amount)}${remove('lines',i)}`;})); + body += `
${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}
행 비목별 자릿수 절사 후 합계를 원 미만 절사합니다. ${esc(result.errors.join(' / '))}
`; } else body += '
일위대가를 추가하세요.
'; $('panel').innerHTML = card('일위대가', '기초 단가와 소요량을 조합해 단위당 금액을 계산합니다.', add('units','일위대가 추가'), body); } else if (tab === 'estimate') { const rows = data.estimate.map((e,i) => {const u=result.units[e.unitId], r=result.rows[i]; return `${field('estimate',i,'name',e.name)}${select('estimate',i,'unitId',e.unitId,unitChoices)}${field('estimate',i,'quantity',e.quantity,'number')}${u ? money(u.total) : '—'}${EstCalc.kinds.map(k=>`${money(r.parts[k])}`).join('')}${money(r.total)}${remove('estimate',i)}`;}); - $('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.'); + $('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','실적','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.'); } else if (tab === 'quantities') { const choices = [['', '선택'], ...data.estimate.map(e => [e.id, e.name])]; const rows = (data.quantities || []).map((q,i) => `${field('quantities',i,'name',q.name)}${select('quantities',i,'estimateId',q.estimateId,choices)}${field('quantities',i,'length',q.length,'number')}${field('quantities',i,'width',q.width,'number')}${field('quantities',i,'count',q.count,'number')}${money(q.length*q.width*q.count)}${remove('quantities',i)}`); $('panel').innerHTML = card('수량산출서','수량을 산출하고 선택한 내역에 반영합니다.',`${add('quantities','산출 행 추가')} `,table(['산출 항목','연결 공종','길이','폭','횟수','산출 수량',''],rows),'산출 수량 = 길이 × 폭 × 횟수. 같은 공종에 연결된 산출 수량을 합산해 내역 수량을 덮어씁니다.'); + } else if (tab === 'resources') { + const output=EstWorkflows.resources(data); + const rows=output.rows.map(({source,quantity})=>`${esc(source.name)}${esc(source.spec)}${esc(source.unit)}${esc(source.kind)}${money(quantity)}${money(EstCalc.sourcePrice(source))}`); + $('panel').innerHTML=card('동원인원·자재·장비', '내역 수량에서 일위대가를 전개하여 기초단가별 수량을 집계합니다.', '',table(['품명','규격','단위','비목','소요 수량','단가'],rows),`비율 행의 금액 전개, 단가산출 글과 장비 시간 환산은 미구현입니다. ${esc(output.errors.join(' / '))}`); + } else if (tab === 'escalation') { + const rows=(data.escalation || []).map((e,i)=>{ const r=EstWorkflows.escalation(e.bid,e.contract,e.current,e.quantity), excluded=EstWorkflows.excluded(e.planned,e.actual,e.progress); return `${field('escalation',i,'name',e.name)}${['bid','contract','current','quantity','planned','actual','progress'].map(k=>`${field('escalation',i,k,e[k],'number')}`).join('')}${money(r.rate)}%${money(r.difference)}${money(r.price)}${money(r.amount)}${money(excluded)}${remove('escalation',i)}`; }); + $('panel').innerHTML=card('물가변동', '품목별 기준·계약·비교 단가와 적용제외 수량을 계산합니다.',add('escalation','품목 추가'),table(['품목','입찰 당시','계약 단가','변동 당시','수량','예정공정','실행공정','기성','등락률','등락폭','조정단가','조정금액','제외 수량',''],rows),'REA의 품목조정 경로입니다. 원가계산서 재계산·파일 연동과 성분별 자동 전개는 아직 미구현입니다.'); } else if (tab === 'progress') { const rows = (data.progress || []).map((p,i) => { const e = data.estimate.find(x => x.id === p.estimateId), price = e ? result.units[e.unitId]?.total || 0 : 0; @@ -78,8 +91,8 @@ function render() { $('panel').innerHTML = card('기성고', '전회·금회·사정 수량을 입력해 금회와 누계를 확인합니다.', '', `
${table(['공종','계약 수량','단가','전회 수량','금회 수량','금회사정 수량','금회 금액','누계 수량','누계 금액',''],rows)}`, '내역 불러오기는 새 공종만 추가합니다. 이 화면은 수량 입력 방식만 지원하며 회차별 이력·기성 원가계산은 아직 구현되지 않았습니다.'); } else { const bases = [...EstCalc.kinds,'직접공사비','누계'].map(k=>[k,k]); - const rows = data.costs.map((c,i) => `${field('costs',i,'name',c.name)}${select('costs',i,'base',c.base,bases)}${money(result.costs[i].base)}${field('costs',i,'rate',c.rate,'number')}${money(result.costs[i].amount)}${money(result.costs[i].running)}${remove('costs',i)}`); - $('panel').innerHTML = card('원가계산서','기준 금액과 요율을 선택해 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','계산 금액','누계',''],[`직접공사비${money(result.directTotal)}${money(result.directTotal)}`,...rows]),'각 행은 기준 금액 × 요율 ÷ 100의 원 미만을 절사합니다. 누계는 앞선 항목까지의 합계입니다. 수식 편집 및 법정 요율은 제공하지 않습니다.'); + const rows = data.costs.map((c,i) => `${field('costs',i,'name',c.name)}${select('costs',i,'base',c.base,bases)}${money(result.costs[i].base)}${field('costs',i,'rate',c.rate,'number')}${field('costs',i,'formula',c.formula || '')}${money(result.costs[i].amount)}${money(result.costs[i].running)}${remove('costs',i)}`); + $('panel').innerHTML = card('원가계산서','기준 금액·요율 또는 수식을 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','수식','계산 금액','누계',''],[`직접공사비${money(result.directTotal)}${money(result.directTotal)}`,...rows]),`수식 예: [직접공사비]*0.1, [앞선 항목명]+[재료비]. 수식이 있으면 요율 대신 수식을 사용합니다. ${esc(result.errors.join(' / '))}`); } } function persist() { try { sessionStorage.setItem(key,JSON.stringify(data)); } catch { alert('임시 저장 공간을 사용할 수 없습니다. JSON 파일로 저장해 주세요.'); } } @@ -93,6 +106,8 @@ document.addEventListener('click', event => { if (k==='lines') data.units.find(u=>u.id===selected).lines.push({sourceId:data.sources[0]?.id || '',quantity:1}); if (k==='estimate') data.estimate.push({id:id(),name:'새 공종',unitId:data.units[0]?.id || '',quantity:1}); if (k==='quantities') { data.quantities ||= []; data.quantities.push({id:id(),name:'새 수량산출',estimateId:data.estimate[0]?.id || '',length:1,width:1,count:1}); } + if (k==='machines') { data.machines ||= []; data.machines.push({sourceId:'',purchase:0,rentalCoefficient:0,fuelPrice:0,fuelQuantity:0,suppliesRate:0,laborPrice:0,laborFactor:0,laborHours:0}); } + if (k==='escalation') { data.escalation ||= []; data.escalation.push({name:'새 품목',bid:0,contract:0,current:0,quantity:0,planned:0,actual:0,progress:0}); } if (k==='costs') data.costs.push({id:id(),name:'새 요율',base:'직접공사비',rate:0}); persist(); render(); } @@ -112,9 +127,9 @@ document.addEventListener('click', event => { } if (t.dataset.remove) { const k=t.dataset.remove, i=Number(t.dataset.index), arr=k==='lines' ? data.units.find(u=>u.id===selected).lines : data[k], item=arr[i]; - if (k==='sources' && data.units.some(u=>u.lines.some(l=>l.sourceId===item.id))) { alert('일위대가에서 사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; } + if (k==='sources' && (data.units.some(u=>u.lines.some(l=>l.sourceId===item.id)) || (data.machines || []).some(m=>m.sourceId===item.id))) { alert('사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; } if (k==='estimate' && ((data.quantities || []).some(q=>q.estimateId===item.id) || (data.progress || []).some(p=>p.estimateId===item.id))) { alert('수량산출 또는 기성고에서 사용 중인 공종입니다.'); return; } - if (k==='units' && data.estimate.some(e=>e.unitId===item.id)) { alert('내역에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; } + if (k==='units' && (data.estimate.some(e=>e.unitId===item.id) || data.units.some(u => u.id !== item.id && u.lines.some(l => l.type === '일위대가' && l.unitId === item.id)))) { alert('다른 항목에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; } arr.splice(i,1); persist(); render(); } }); @@ -126,7 +141,11 @@ document.addEventListener('change', event => { if (!el.dataset.collection) return; const arr=el.dataset.collection==='lines' ? data.units.find(u=>u.id===selected).lines : data[el.dataset.collection]; const value=el.type==='number' ? Number(el.value) : el.value; + if (el.dataset.collection==='machines' && el.dataset.field==='sourceId' && value && data.machines.some((m,i)=>i!==Number(el.dataset.index) && m.sourceId===value)) { alert('이미 다른 기계에 연결된 기초단가입니다.'); render(); return; } if (el.type==='number' && (el.value.trim()==='' || !Number.isFinite(value) || value<0 || value>1e12)) { alert('0 이상 1조 이하의 숫자를 입력하세요.'); render(); return; } + if (el.dataset.field==='digits' && (!Number.isInteger(value) || value>6)) { alert('자릿수는 0부터 6까지 입력하세요.'); render(); return; } + if (el.dataset.field==='priceIndex') { if (!Number.isInteger(value) || value<1 || value>6) { alert('선택 번호는 1부터 6까지 입력하세요.'); render(); return; } arr[Number(el.dataset.index)].priceIndex=value-1; persist(); render(); return; } + if (/^price[0-5]$/.test(el.dataset.field)) { const source=arr[Number(el.dataset.index)]; source.prices ||= [0,0,0,0,0,0]; source.prices[Number(el.dataset.field.slice(-1))]=value; persist(); render(); return; } arr[Number(el.dataset.index)][el.dataset.field]=value; persist(); render(); }); $('save').addEventListener('click', () => { const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'})); a.download='est-test.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); diff --git a/tools/est-test/calc.js b/tools/est-test/calc.js index d59bab468..7698321f5 100644 --- a/tools/est-test/calc.js +++ b/tools/est-test/calc.js @@ -1,43 +1,90 @@ -// EST 보고서의 성분별 행 절사와 원 미만 합계 절사를 참고한 테스트 계산. -// shortcut: 실제 EST의 모든 자릿수·비율 옵션은 미구현, 원본 대조 시 확장. +// EST Plus 2 REA 최종보고서 §3~7: 비목별 계산과 연결 순서. const EstCalc = (() => { - const kinds = ['재료비', '노무비', '경비']; - const trunc = n => Math.trunc((Number(n) || 0) * 100) / 100; + const kinds = ['재료비', '노무비', '경비', '실적']; + const blank = () => Object.fromEntries(kinds.map(k => [k, 0])); + const truncate = (n, digits = 0) => Math.trunc((n + Math.sign(n) * 2e-10) * 10 ** digits) / 10 ** digits; + function sourcePrice(source) { + if (!source) return 0; + const values = (source.prices || []).filter(v => Number.isFinite(v) && v > 0); + if (source.priceMode === '최저') return values.length ? Math.min(...values) : 0; + if (source.priceMode === '최고') return values.length ? Math.max(...values) : 0; + if (source.priceMode === '평균') return values.length ? Math.trunc(values.reduce((a,b) => a+b, 0) / values.length) : 0; + if (source.priceMode === '선택') return source.prices?.[source.priceIndex || 0] || 0; + return source.price || 0; + } + function machine(m) { + const digits = m.digits ?? 1; + const rental = truncate(m.purchase * m.rentalCoefficient * 1e-7, digits); + const fuel = truncate(m.fuelPrice * m.fuelQuantity, digits); + const supplies = truncate(fuel * m.suppliesRate / 100, digits); + const operator = truncate(m.laborPrice * m.laborFactor * m.laborHours, digits); + return { 재료비: Math.trunc(fuel + supplies), 노무비: Math.trunc(operator), 경비: Math.trunc(rental), 실적: 0 }; + } function calculate(data) { const prices = Object.fromEntries(data.sources.map(s => [s.id, s])); - const units = Object.fromEntries(data.units.map(u => { - const parts = Object.fromEntries(kinds.map(k => [k, 0])); + const records = Object.fromEntries(data.units.map(u => [u.id, u])); + const units = Object.create(null), visiting = new Set(), errors = []; + function calculateUnit(u) { + if (units[u.id]) return units[u.id]; + if (visiting.has(u.id)) { errors.push(`일위대가 순환 참조: ${u.name}`); return {parts: blank(), lines: [], total: 0}; } + visiting.add(u.id); + const parts = blank(); const lines = u.lines.map(line => { - const source = prices[line.sourceId]; - const amount = source ? trunc(source.price * line.quantity) : 0; - if (source) parts[source.kind] += amount; - return { source, amount }; + const source = Object.hasOwn(prices, line.sourceId) ? prices[line.sourceId] : null; + const unit = Object.hasOwn(records, line.unitId) ? records[line.unitId] : null; + let unitParts = blank(); + if (line.type === '일위대가') { + if (unit) unitParts = calculateUnit(unit).parts; + else errors.push(`${u.name}: 연결된 일위대가 없음`); + } + else if (line.type === '비율') { + const base = line.base || '합계'; + if (base === '합계') unitParts[line.kind || '경비'] = kinds.reduce((sum,k) => sum + parts[k], 0); + else if (base === '비목별') unitParts = { ...parts }; + else unitParts[line.kind || '경비'] = parts[base] || 0; + } else if (source) { + const machinery = (data.machines || []).find(m => m.sourceId === source.id); + if (machinery) unitParts = machine(machinery); + else unitParts[source.kind] = sourcePrice(source); + } else errors.push(`${u.name}: 연결된 기초단가 없음`); + const quantity = line.type === '비율' || line.unit === '%' ? line.quantity / 100 : line.quantity; + const components = Object.fromEntries(kinds.map(k => [k, truncate(unitParts[k] * quantity, line.digits ?? 0)])); + const amount = kinds.reduce((sum,k) => sum + components[k], 0); + if (line.calcIndex !== 2 && line.calcIndex !== 3) for (const k of kinds) parts[k] += components[k]; + return { source, unit, components, amount }; }); for (const k of kinds) parts[k] = Math.trunc(parts[k]); - return [u.id, { parts, lines, total: kinds.reduce((sum, k) => sum + parts[k], 0) }]; - })); - const direct = Object.fromEntries(kinds.map(k => [k, 0])); + visiting.delete(u.id); + return units[u.id] = { parts, lines, total: kinds.reduce((sum, k) => sum + parts[k], 0) }; + } + for (const u of data.units) calculateUnit(u); + const direct = blank(); const rows = data.estimate.map(row => { - const unit = units[row.unitId]; + const unit = Object.hasOwn(units, row.unitId) ? units[row.unitId] : null; const parts = Object.fromEntries(kinds.map(k => [k, unit ? Math.trunc(unit.parts[k] * row.quantity) : 0])); for (const k of kinds) direct[k] += parts[k]; return { parts, total: kinds.reduce((sum, k) => sum + parts[k], 0) }; }); const directTotal = kinds.reduce((sum, k) => sum + direct[k], 0); let running = directTotal; + const references = Object.assign(Object.create(null), direct, { 직접공사비: directTotal }); const costs = data.costs.map(row => { const base = row.base === '누계' ? running : row.base === '직접공사비' ? directTotal : direct[row.base] || 0; - const amount = Math.trunc(base * row.rate / 100); + references['누계'] = running; + let amount; + try { amount = row.formula ? Math.trunc(EstFormula.evaluate(row.formula, references)) : Math.trunc(base * row.rate / 100); } + catch (error) { errors.push(`${row.name}: ${error.message}`); amount = 0; } running += amount; + references[row.name] = amount; return { base, amount, running }; }); - return { units, rows, direct, directTotal, costs, total: running }; + return { units, rows, direct, directTotal, costs, total: running, errors }; } function progress(unitPrice, previous, current, approved, useApproved) { const accepted = useApproved ? approved : current; const cumulative = previous + accepted; return { currentAmount: Math.trunc(unitPrice * current), approvedAmount: Math.trunc(unitPrice * approved), cumulative, cumulativeAmount: Math.trunc(unitPrice * cumulative) }; } - return { kinds, calculate, progress }; + return { kinds, calculate, progress, sourcePrice, truncate, machine }; })(); if (typeof module !== 'undefined') module.exports = EstCalc; diff --git a/tools/est-test/formula.js b/tools/est-test/formula.js new file mode 100644 index 000000000..6523bfb05 --- /dev/null +++ b/tools/est-test/formula.js @@ -0,0 +1,33 @@ +// 원가 및 단가산출의 사칙연산·괄호·[항목명] 참조를 평가한다. +const EstFormula = (() => { + function evaluate(expression, values) { + const tokens = expression.match(/\s+|\d+(?:\.\d+)?|\[[^\]]+\]|[()+*/-]/g) || []; + if (tokens.join('') !== expression) throw Error('지원하지 않는 수식 문자'); + const items = tokens.filter(t => !/^\s+$/.test(t)); + let i = 0; + function primary() { + const token = items[i++]; + if (token === '(') { const n = sum(); if (items[i++] !== ')') throw Error('괄호 오류'); return n; } + if (token === '-') return -primary(); + if (token === '+') return primary(); + if (/^\d/.test(token || '')) return Number(token); + if (token?.startsWith('[')) { const key=token.slice(1,-1); if (!Object.hasOwn(values,key)) throw Error(`참조 항목 없음: ${key}`); return values[key]; } + throw Error('수식 오류'); + } + function product() { + let n = primary(); + while (items[i] === '*' || items[i] === '/') { const op=items[i++], v=primary(); if (op === '/' && v === 0) throw Error('0으로 나눌 수 없음'); n=op === '*' ? n*v : n/v; } + return n; + } + function sum() { + let n = product(); + while (items[i] === '+' || items[i] === '-') { const op=items[i++], v=product(); n=op === '+' ? n+v : n-v; } + return n; + } + const result=sum(); + if (i !== items.length || !Number.isFinite(result)) throw Error('수식 오류'); + return result; + } + return { evaluate }; +})(); +if (typeof module !== 'undefined') module.exports = EstFormula; diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 247d125c7..351b802c0 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -3,7 +3,7 @@ EST 업무 테스트 - +
@@ -12,7 +12,7 @@
공사명
- +
구현 중 · 상단 메뉴는 배치 참고용이며 왼쪽 작업화면과 도구 버튼만 동작합니다. 예제 수치는 임의 값이며 원본 EST와의 동등성은 검증되지 않았습니다.
● 준비저장: JSON 파일 · 탭을 닫기 전에 저장하세요
diff --git a/tools/est-test/workflows.js b/tools/est-test/workflows.js new file mode 100644 index 000000000..d0236fa67 --- /dev/null +++ b/tools/est-test/workflows.js @@ -0,0 +1,33 @@ +// EST Plus 2 REA 최종보고서 §6-11, §6-12의 계산 경로. +const EstWorkflows = (() => { + function escalation(bid, contract, current, quantity) { + if (!bid || !current) return { rate: 0, difference: 0, price: contract, amount: Math.trunc(contract * quantity) }; + const rate = Math.trunc(((current - bid) / bid) * 10000) / 100; + let difference = 0; + if (bid >= contract && current >= bid) difference = Math.trunc(contract * rate / 100); + else if (bid < contract && current >= contract) difference = Math.trunc(current - contract); + const price = contract + difference; + return { rate, difference, price, amount: Math.trunc(price * quantity) }; + } + function excluded(planned, actual, progress) { return Math.max(planned, actual, progress); } + function resources(data) { + const sources = Object.fromEntries(data.sources.map(s => [s.id,s])); + const units = Object.fromEntries(data.units.map(u => [u.id,u])); + const quantities = Object.create(null), errors = []; + function expand(unitId, multiplier, path) { + const unit = Object.hasOwn(units, unitId) ? units[unitId] : null; + if (!unit) return; + if (path.includes(unitId)) { errors.push(`순환 참조: ${unit.name}`); return; } + for (const line of unit.lines) { + if (line.calcIndex === 2 || line.calcIndex === 3 || line.type === '비율') continue; + const quantity = multiplier * line.quantity; + if (line.type === '일위대가') expand(line.unitId,quantity,[...path,unitId]); + else if (Object.hasOwn(sources,line.sourceId)) quantities[line.sourceId] = (quantities[line.sourceId] || 0) + quantity; + } + } + for (const row of data.estimate) expand(row.unitId,row.quantity,[]); + return { rows: Object.entries(quantities).map(([id,quantity]) => ({source:sources[id],quantity})), errors }; + } + return { escalation, excluded, resources }; +})(); +if (typeof module !== 'undefined') module.exports = EstWorkflows; diff --git a/tools/stmate-test/README.md b/tools/stmate-test/README.md index 1fc154232..1b846aab4 100644 --- a/tools/stmate-test/README.md +++ b/tools/stmate-test/README.md @@ -4,10 +4,18 @@ 경쟁사 설계예산 프로그램 **STMATE**(경영정보(주))에 대한 **REA 6.7.0 / Ghidra 12.1.4 정적 분석** 및 지식DB(`resources/knowledge/original/경쟁사 프로그램/STmate 분석/`) 결과를 바탕으로, 주요 기능과 계산 사슬·제어 옵션을 브라우저에서 독립적으로 확인하고 조작할 수 있도록 만든 테스트 웹앱입니다. -- **완전 분리 동작**: Aislo 본체 서버·DB·로그인 세션과 무관하게 `index.html` 단독으로 동작합니다. -- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**를 지원합니다. +- **완전 분리 동작**: Aislo 본체 서버·공용 MariaDB·로그인 세션과 무관하게 동작합니다. +- **PC 내 STMATE 원본 DBF · STC · JB_R 직접 읽기/쓰기 연동 (`server.py`)**: + - 실행 명령: `.\venv\Scripts\python.exe tools\stmate-test\server.py` (포트 `8095`, `http://127.0.0.1:8095`) + - PC의 `C:\경영정보(주)\STmate 자료\설계자료\실습(맛보기)` 원본 DBF 폴더, `C:\경영정보(주)\System\ST_BSC\*.JB_R` 제비율 기준 DB(2015~2026년 41종), 및 `resources/knowledge/original/실무문서/*.STC` 실무 프로젝트 DB를 직접 읽어 시뮬레이터에 동기화하고, 수정된 단가·수량·제비율 결과를 CP949 고정폭 바이너리 DBF(`COSTN.DBF`, `BOQ11/12.DBF`, `PRJLST.DBF/.SMT`) 및 `.STC` 패키지로 직접 기록합니다. +- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**도 함께 지원합니다. -## 주요 확인 및 제어 기능 (6개 탭) +## 주요 확인 및 제어 기능 (7개 탭) + +0. **00 원본 DBF · STC 연동 (`server.py`, `db_bridge.js`)** + - PC 내 STMATE 원본 DBF 폴더 및 `.STC` 패키지 선택·불러오기 (`COSTN.DBF`, `BOQ11/12.DBF`, `PRJLST.DBF`, `PRJLST.SMT`) + - `C:\경영정보(주)\System\ST_BSC\*.JB_R` 제비율 기준 DBF(행20·45·46 좌표) 로드 및 원가계산 요율 즉시 적용 + - `COSTN.DBF` 단가 직접 편집 및 시뮬레이터 계산 결과의 **원본 호환 DBF / `.STC` 파일 직접 쓰기(`tools/stmate-test/work_dbf/`)** 1. **01 기초자원 · 노임환산 (`ST_Sys.CPM`, `COSTN.DBF`)** - 자재·노무·경비 기초자원의 5개 가격지 후보(`AMT_11..51`: 조달·물자·물정·거래·견적) 입력 diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js index 5d6e7d200..c60ca00a1 100644 --- a/tools/stmate-test/app.js +++ b/tools/stmate-test/app.js @@ -256,7 +256,8 @@ function renderAll() { }); const panel = $("tabPanel"); - if (activeTab === "resources") panel.innerHTML = renderResourcesTab(res); + if (activeTab === "db") panel.innerHTML = StmateDbBridge.renderDbTab(state, res); + else if (activeTab === "resources") panel.innerHTML = renderResourcesTab(res); else if (activeTab === "machines") panel.innerHTML = renderMachinesTab(res); else if (activeTab === "ilwi") panel.innerHTML = renderIlwiTab(res); else if (activeTab === "boq") panel.innerHTML = StmateCostViews.renderBoqTab(state, res); @@ -681,4 +682,17 @@ $("fileOpen").addEventListener("change", async (e) => { } }); +if (typeof StmateDbBridge !== "undefined") { + StmateDbBridge.initEvents( + () => state, + (newSt) => { + state = newSt; + selectedIlwiId = state.ilwiItems[0]?.id || ""; + }, + () => { + saveState(); + renderAll(); + }, + ); +} renderAll(); diff --git a/tools/stmate-test/db_bridge.js b/tools/stmate-test/db_bridge.js new file mode 100644 index 000000000..4dfafaf29 --- /dev/null +++ b/tools/stmate-test/db_bridge.js @@ -0,0 +1,345 @@ +/* -*- coding: utf-8 -*- */ +/** + * STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 컨트롤러 (db_bridge.js) + */ +const StmateDbBridge = (() => { + const API_BASE = "http://127.0.0.1:8095"; + const esc = (s) => + String(s ?? "").replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + const fmt = (n, maxDec = 2) => + new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0); + + const dbCtx = { + connected: false, + sources: { projects: [], jbr_files: [], work_root: "" }, + selectedProjectPath: "", + selectedJbrPath: "", + targetFolderName: "stmate_edited", + loadedDbf: null, + loadedJbr: null, + lastSaveResult: null, + statusMsg: "로컬 DB 브리지(127.0.0.1:8095) 연결 확인 중...", + }; + + async function checkAndLoadSources(onUpdate) { + try { + const resp = await fetch(`${API_BASE}/api/sources`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + dbCtx.connected = true; + dbCtx.sources = data; + if (!dbCtx.selectedProjectPath && data.projects.length > 0) { + dbCtx.selectedProjectPath = data.projects[0].path; + } + const onlyJbr = data.jbr_files.filter((f) => f.ext === ".JB_R"); + if (!dbCtx.selectedJbrPath && onlyJbr.length > 0) { + dbCtx.selectedJbrPath = onlyJbr[onlyJbr.length - 1].path; + } + dbCtx.statusMsg = `연결됨 — PC 프로젝트 DB ${data.projects.length}건 · ST_BSC 제비율 DB ${data.jbr_files.length}건 감지`; + } catch (_) { + dbCtx.connected = false; + dbCtx.statusMsg = + "오프라인 — 터미널에서 .\\venv\\Scripts\\python.exe tools\\stmate-test\\server.py 실행 시 DBF 직접 읽기/쓰기 활성화"; + } + if (onUpdate) onUpdate(); + } + + async function loadProjectDbf(applyStateCallback, onUpdate) { + if (!dbCtx.selectedProjectPath) return; + try { + dbCtx.statusMsg = `DBF/STC 읽는 중: ${dbCtx.selectedProjectPath} ...`; + if (onUpdate) onUpdate(); + const resp = await fetch(`${API_BASE}/api/load`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_path: dbCtx.selectedProjectPath }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + dbCtx.loadedDbf = data; + dbCtx.lastSaveResult = null; + dbCtx.statusMsg = `로드 완료: ${data.prjlst?.PNAME || data.source_path} (COSTN ${data.costn_count}건, ${data.boq_table_name} ${data.boq_count}건)`; + if (data.sim_state && applyStateCallback) { + applyStateCallback(data.sim_state); + } + } catch (err) { + dbCtx.statusMsg = `DBF 로드 실패: ${err.message || err}`; + if (onUpdate) onUpdate(); + } + } + + async function loadAndApplyJbr(state, saveAndRender) { + if (!dbCtx.selectedJbrPath) return; + try { + const resp = await fetch(`${API_BASE}/api/load-jbr`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jbr_path: dbCtx.selectedJbrPath }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + dbCtx.loadedJbr = data; + const r = data.extracted_rates; + Object.assign(state.kanjubConfig.rates, { + KANNO: r.KANNO || state.kanjubConfig.rates.KANNO, + KITA: r.KITA || state.kanjubConfig.rates.KITA, + ILBAN: r.ILBAN || state.kanjubConfig.rates.ILBAN, + SANBO: r.SANBO, + KOBO: r.KOBO, + KUNBO: r.KUNBO, + YUNBO: r.YUNBO, + SLVBO: r.SLVBO, + IYUN: r.IYUN, + ANJUN: r.ANJUN, + }); + state.kanjubConfig.anjunBaseAdd = r.anjunBaseAdd; + dbCtx.statusMsg = `ST_BSC 제비율 DB(${data.name}) 요율 적용 완료`; + if (saveAndRender) saveAndRender(); + } catch (err) { + dbCtx.statusMsg = `JB_R 로드 실패: ${err.message || err}`; + if (saveAndRender) saveAndRender(); + } + } + + async function saveToDbf(state, calcRes, onUpdate) { + const srcPath = dbCtx.loadedDbf?.source_path || dbCtx.selectedProjectPath; + if (!srcPath) return; + try { + const k = calcRes.kanjubOrig; + const costnUpdates = (dbCtx.loadedDbf?.costn_rows || []).slice(0, 80).map((r) => ({ + CODE: r.CODE, + NAME: r.NAME, + TAMT1: r.TAMT1, + LAMT1: r.LAMT1, + MAMT1: r.MAMT1, + SAMT1: r.SAMT1, + })); + // 시뮬레이터 기초자원 단가 변경분도 COSTN 업데이트에 병합 + for (const resItem of calcRes.resources.list) { + costnUpdates.push({ + CODE: resItem.code, + NAME: resItem.name, + TAMT1: resItem.adoptedPrice, + LAMT1: resItem.kind === "노무비" ? resItem.adoptedPrice : 0, + MAMT1: resItem.kind === "재료비" ? resItem.adoptedPrice : 0, + SAMT1: resItem.kind === "경비" ? resItem.adoptedPrice : 0, + }); + } + + const boqUpdates = calcRes.boqOrig.rows + .filter((r) => r._recno !== undefined) + .map((r) => ({ + _recno: r._recno, + NAME1: r.name, + QTY1: r.qty, + MAMT1: r.unitInfo.M, + LAMT1: r.unitInfo.L, + SAMT1: r.unitInfo.S, + TAMT1: r.unitInfo.T, + })); + + const prjlstUpdates = { + MCOST1: k.dM, + LCOST1: k.dL, + SCOST1: k.dS, + TCOST1: k.dT, + COSTW1: k.grandTotal, + }; + const rates = state.kanjubConfig.rates; + const smtUpdates = { + "1_R_KANNO": rates.KANNO, + "1_R_SANBO": rates.SANBO, + "1_R_KOBO": rates.KOBO, + "1_R_KUNBO": rates.KUNBO, + "1_R_SLVBO": rates.SLVBO, + "1_R_YUNBO": rates.YUNBO, + "1_R_ANJUN": rates.ANJUN, + "1_R_T_KITA": rates.KITA, + "1_R_HWAN": rates.HWAN, + "1_R_EQIPSU": rates.EQIP, + "1_R_ILBAN": rates.ILBAN, + "1_R_IYUN": rates.IYUN, + "1_C_KANNO": k.kanno, + "1_C_ILBAN": k.ilbanAmt, + "1_C_IYUN_M": k.pretaxProfitAdj, + "1_C_KONGSA_J": k.contractBeforeAdj, + "1_C_UPDNKWN": k.govFinal, + }; + + const resp = await fetch(`${API_BASE}/api/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source_path: srcPath, + target_folder_name: dbCtx.targetFolderName || "stmate_edited", + costn_updates: costnUpdates, + boq_updates: boqUpdates, + prjlst_updates: prjlstUpdates, + smt_updates: smtUpdates, + }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const saved = await resp.json(); + dbCtx.lastSaveResult = saved; + dbCtx.statusMsg = `DBF·STC 저장 완료: ${saved.saved_stc} (${fmt(saved.stc_size, 0)} bytes)`; + await checkAndLoadSources(); + } catch (err) { + dbCtx.statusMsg = `DBF 저장 실패: ${err.message || err}`; + } + if (onUpdate) onUpdate(); + } + + function renderDbTab(state, calcRes) { + const projOpts = dbCtx.sources.projects + .map( + (p) => + ``, + ) + .join(""); + const jbrOpts = dbCtx.sources.jbr_files + .map( + (f) => + ``, + ) + .join(""); + + let dbfPreviewHtml = + '
상단에서 PC 내 STMATE 폴더 또는 .STC 파일을 선택하고 [원본 DBF/STC 불러오기]를 실행하세요.
'; + if (dbCtx.loadedDbf) { + const d = dbCtx.loadedDbf; + const costnRows = (d.costn_rows || []) + .slice(0, 25) + .map( + (r, i) => ` + + ${esc(r.CODE)} + + ${esc(r.SIZE)} + ${esc(r.UNIT)} + + + + + ${fmt(r.AMT_61, 0)} + `, + ) + .join(""); + + dbfPreviewHtml = ` +
+ 현재 연결 원본: ${esc(d.prjlst?.PNAME || d.source_path)} · 수록 테이블: ${esc(d.file_list.join(", "))} + 원장 COSTN.DBF ${d.costn_count}건 · 내역 ${esc(d.boq_table_name)} ${d.boq_count}건 · PRJLST.SMT 설정키 ${Object.keys(d.prjlst_smt || {}).length}개 +
+
+ + + ${costnRows} +
CODE품명(NAME)규격(SIZE)단위재료단가(MAMT1)노무단가(LAMT1)경비단가(SAMT1)합계단가(TAMT1)채택6칸(AMT_61)
+
`; + } + + const saveAlert = dbCtx.lastSaveResult + ? `
최근 쓰기 완료 폴더: ${esc(dbCtx.lastSaveResult.saved_dir)}생성된 패키지: ${esc(dbCtx.lastSaveResult.saved_stc)} (${fmt(dbCtx.lastSaveResult.stc_size, 0)} bytes, 파일 ${dbCtx.lastSaveResult.written_count}종)
` + : ""; + + return ` +
+
+
+

PC 내 STMATE 원본 DBF · SMT · STC · JB_R 직접 읽기/쓰기 연동

+

C:\\경영정보(주) 설치 폴더의 프로젝트 DBF(COSTN.DBF, BOQ11.DBF, PRJLST.DBF/.SMT), System\\ST_BSC\\*.JB_R 제비율 기준 DB, 그리고 실무 .STC 패키지를 직접 읽고 CP949 고정폭 바이너리 DBF로 다시 기록합니다.

+
+
+ +
+
+
+ 상태: ${esc(dbCtx.statusMsg)} + 브리지 주소: ${API_BASE} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ${saveAlert} +
+
+
+
+

원본 COSTN.DBF 레코드 직접 조회 및 편집 (상위 25건 미리보기)

+

여기서 수정한 단가와 01~06 탭에서 조작한 수량·요율·원가 결과가 [DBF 및 .STC로 직접 쓰기] 실행 시 COSTN.DBF, BOQ11/12.DBF, PRJLST.DBF/.SMT에 기록됩니다.

+
+
+ ${dbfPreviewHtml} +
`; + } + + function initEvents(getState, setState, saveAndRender) { + document.addEventListener("click", (e) => { + const btn = e.target.closest("[data-db-action]"); + if (!btn) return; + const act = btn.dataset.dbAction; + if (act === "refresh-sources") { + checkAndLoadSources(saveAndRender); + } else if (act === "load-project") { + loadProjectDbf((newState) => { + setState(newState); + saveAndRender(); + }, saveAndRender); + } else if (act === "load-jbr") { + loadAndApplyJbr(getState(), saveAndRender); + } else if (act === "save-dbf") { + const st = getState(); + saveToDbf(st, StmateCalc.calculateAll(st), saveAndRender); + } + }); + + document.addEventListener("change", (e) => { + const el = e.target; + if (el.id === "selDbProject") dbCtx.selectedProjectPath = el.value; + else if (el.id === "selDbJbr") dbCtx.selectedJbrPath = el.value; + else if (el.id === "inpTargetFolder") dbCtx.targetFolderName = el.value; + else if (el.dataset.dbf === "costn" && dbCtx.loadedDbf?.costn_rows) { + const idx = Number(el.dataset.idx); + const fld = el.dataset.field; + if (dbCtx.loadedDbf.costn_rows[idx]) { + dbCtx.loadedDbf.costn_rows[idx][fld] = el.value; + } + } + }); + + checkAndLoadSources(saveAndRender); + } + + return { + dbCtx, + initEvents, + checkAndLoadSources, + loadProjectDbf, + loadAndApplyJbr, + saveToDbf, + renderDbTab, + }; +})(); diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html index 54f84b72b..9c1c832d3 100644 --- a/tools/stmate-test/index.html +++ b/tools/stmate-test/index.html @@ -7,6 +7,7 @@ + @@ -47,6 +48,7 @@