diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index c6997996a..41309a762 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -1,39 +1,44 @@ -"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3). +"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7). +[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 — +`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`. +몸이 비면 400(확인 없이 들어가는 길 없음). +서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = +돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · +호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · -상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} · -만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만: - 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값, - 수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조 - (같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 · - 아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조 - (순환 = 빠진 줄). -같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도). -빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가. +상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], +확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). + 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값, + 수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄 + 사전조합으로 감싸 참조(같은 로직 두 번 금지) · + 자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조. +같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 +(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). +빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). """ from __future__ import annotations import json +from datetime import datetime from decimal import Decimal from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Match as match +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger 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 ORIGIN = "실무자료" -WHY = { - "%": "% 줄 — 우리 로직 덧줄이 셈", - "빼기": "뺌(확정)", - "후보": "못 맞춤 — 후보 확정 필요", - "없음": "못 맞춤", -} +LINES_MAX = 500 +UNCHECKED = "확인 안 함" +PERCENT = "% 줄 — 우리 로직 덧줄이 셈" +WHOLE = "호표 통째로 대신" def mark(key: str, no: int, at: int | None = None) -> str: @@ -42,25 +47,46 @@ def mark(key: str, no: int, at: int | None = None) -> str: return head if at is None else f"{head} {at + 1}째 줄" -def _found(origin: str) -> tuple[str, str] | None: - """지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판).""" - for path in sorted((store.root() / "precombo").glob("SC-*.json")): +def _origins(folder: Path): + """층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판).""" + for path in sorted((Path(folder) / "precombo").glob("SC-*.json")): try: - doc = json.loads(path.read_bytes()) + raw = path.read_bytes() + doc = json.loads(raw) except (OSError, ValueError): continue got = doc.get("본뜬") if isinstance(doc, dict) else None - if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin: - return path.stem, store.version_of(path.read_bytes()) - return None + if isinstance(got, dict) and got.get("층") == ORIGIN: + yield path.stem, got, raw -def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]: - """(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키.""" +def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool: + """본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음.""" + have = got.get("원본") + return source is None or not have or tuple(have) == tuple(source) + + +def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]: + """층에 확인된 실무 사전조합 `{본뜬 키: SC 키}`(`source` = 파일 층 (층, 주인)으로 거름).""" + return { + got["키"]: key + for key, got, _ in _origins(Path(folder)) + if got.get("확인") and _same_source(got, source) + } + + +def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키.""" at = refs.here() - have = _found(doc["본뜬"]["키"]) - if have and not fresh: - return {"층": at[0], "주인": at[1], "키": have[0]}, True + want = doc["본뜬"] + have = next( + ( + (key, store.version_of(raw)) + for key, got, raw in _origins(store.root()) + if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"])) + ), + None, + ) if have: store.write("precombo", have[0], have[1], doc) key = have[0] @@ -74,10 +100,11 @@ def _num(value: Decimal) -> int | float: return int(value) if value == value.to_integral_value() else float(value) -def _memo(line: dict[str, Any], times: Any) -> str: +def _memo(line: dict[str, Any], one: dict[str, Any]) -> str: qty = line.get("수량") spec = str(line.get("규격") or "").strip() - head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}" + head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {one['배']}" + head += f"(몫 {one['몫']})" if one.get("몫") is not None else "" return f"{head} · {spec}" if spec else head @@ -90,96 +117,178 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: } +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + class Builder: - """한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억.""" + """한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀.""" - def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None: + def __init__(self, folder: Path, source: Path, key: str, no: int, who: Any) -> None: + self.folder = Path(folder) self.doc = hopyo_store.read(source, key) - self.key, self.fresh = key, fresh - self.memo, self.units = match.memories(folder), match.units_of(self.doc) - self.got = match.targets() - self.made: dict[int, dict[str, Any]] = {} - self.missing: dict[int, list[dict[str, Any]]] = {} + self.key, self.no, self.who = key, int(no), who + self.source = refs.where(source) + self.done = confirmed(folder, self.source) + self.known = ledger._known() + self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) + if self.hopyo is None: + raise _bad(f"없는 호표 「제{no}호표」", 404) - def hopyo(self, no: int) -> dict[str, Any]: - found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None) - if found is None: - raise store.StoreError(404, f"없는 호표 「제{no}호표」") - return found + # ── 재검사 ─────────────────────────────────────────────────────── + def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]: + if raw.get("호표") is None: + return ledger.check_pair( + {k: v for k, v in raw.items() if k != "호표"}, where, self.known + ) + if raw.get("로직") or raw.get("자재") or raw.get("입력값"): + raise _bad(f"호표 대상은 호표 · 배만 「{where}」") + child = int(raw["호표"]) + if child == self.no: + raise _bad(f"호표가 자기를 담음 「{where}」") + if mark(self.key, child) not in self.done: + raise _bad(f"제{child}호표 먼저 확인", 409) + times, part = ledger.times_of(raw, where) + return {"호표": child, "배": times, "몫": part} - def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]: - """호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄).""" - if no in self.made: - return self.made[no] - hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got) - lines, mats, missing = [], {}, [] - used: set[str] = set() - for line in hopyo["줄"]: - got = line["맞춤"] - if not got["들어감"]: - conv = got.get("환산") or {} - why = WHY.get(got["상태"]) or ( - "추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요" - ) - missing.append(_missing(line, why)) - continue - times = got["환산"]["배"] - qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times)) - target = got["대상"] - alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None - memo = _memo(line, times) - if "자재" in target: - code = target["자재"] + def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]: + """몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409.""" + if whole is not None and lines: + raise _bad("통째 · 줄 중 하나만") + if not lines and not (whole or {}).get("대상"): + raise _bad("확인한 줄이 없음") + if len(lines) > LINES_MAX: + raise _bad(f"줄은 {LINES_MAX} 개 안") + have = self.hopyo.get("줄") or [] + out: dict[int, dict[str, Any]] = {} + for raw in lines: + at = int(raw.get("차례", -1)) + if not 0 <= at < len(have) or at in out: + raise _bad(f"없는 줄 · 겹친 줄 「제{self.no}호표 {at}」") + where = f"제{self.no}호표 {at + 1}째 줄" + targets = raw.get("대상") or [] + if bool(raw.get("뺌")) == bool(targets): + raise _bad(f"대상 · 뺌 중 하나만 「{where}」") + if raw.get("뺌") and raw["뺌"] not in ledger.KINDS[1:]: + raise _bad(f"뺌 까닭이 틀림 「{where}」") + if len(targets) > 20: + raise _bad(f"대상은 스물 이하 「{where}」") + out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else { + "대상": [self._target(t, where) for t in targets] + } # fmt: skip + pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []] + if any("호표" in p for p in pairs) or len(pairs) > 20: + raise _bad("통째 대상은 로직 · 자재만") + return out, pairs + + # ── 쓰기 ───────────────────────────────────────────────────────── + def _origin(self, origin: str, stamp: str) -> dict[str, Any]: + sure = {"누가": self.who, "때": stamp} + file = self.doc.get("파일") + return { + "층": ORIGIN, + "주인": None, + "키": origin, + "파일": file, + "원본": list(self.source), + "확인": sure, + } + + def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: + stamp = datetime.now().isoformat(timespec="seconds") + hopyo, file = self.hopyo, self.doc.get("파일") or "" + lines, mats, missing, used = [], {}, [], set() + + def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: + qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) + memo = _memo(line, one) + if "자재" in one: apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" - have = mats.get(code) + have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty - mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart} - continue - if "호표" in target: - child = int(target["호표"]) - if child in (*trail, no): - missing.append(_missing(line, "순환 — 호표가 서로 담음")) - continue - ref = self.build(child, (*trail, no)) + mats[one["자재"]] = { + "코드": one["자재"], + "수량": {"손": _num(hand)}, + "별도계상": apart, + } + return + if "호표" in one: + ref = { + "층": refs.here()[0], + "주인": refs.here()[1], + "키": self.done[mark(self.key, one["호표"])], + } lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) - continue - one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo} - if target["로직"] not in used: - used.add(target["로직"]) - lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}}) - continue + return + row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} + 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('이름')}", - "단위": target.get("단위"), + "단위": line.get("단위") or None, "구분": ORIGIN, - "상세구분": self.doc.get("파일") or "", - "담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}], + "상세구분": file, + "담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}], "자재": [], - "본뜬": self._origin(mark(self.key, no, line["차례"])), + "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } - ref, _ = _save(wrap, self.fresh) + ref, _ = _save(wrap) lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + + book: dict[str, dict[str, Any]] = {} + if whole: + for one in whole: + put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None) + book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole) + for at, line in enumerate(hopyo.get("줄") or []): + got = checked.get(at) + if line.get("갈래") == "%": + missing.append(_missing(line, PERCENT)) + continue + if whole or got is None: + missing.append(_missing(line, WHOLE if whole else UNCHECKED)) + continue + if got.get("뺌"): + missing.append(_missing(line, f"뺌 — {got['뺌']}")) + book[ledger.key(line)] = self._row(line, got["뺌"], []) + continue + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + for one in got["대상"]: + put(line, one, alias) + pairs = [p for p in got["대상"] if "호표" not in p] + if pairs: + book[ledger.key(line)] = self._row(line, "짝", pairs) name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) doc = { "이름": name, "단위": hopyo.get("단위") or None, "구분": ORIGIN, - "상세구분": self.doc.get("파일") or "", + "상세구분": file, "담은로직": lines, "자재": list(mats.values()), - "본뜬": self._origin(mark(self.key, no)), + "본뜬": self._origin(mark(self.key, self.no), stamp), } - ref, again = _save(doc, self.fresh) - self.made[no] = {**ref, "다시씀": again} - self.missing[no] = missing - return self.made[no] + ref, again = _save(doc) + wrote = ledger.write(self.folder, book, self.who, file or None) + return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} - def _origin(self, origin: str) -> dict[str, Any]: - return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")} + def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]: + """장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면).""" + origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")} + clauses = (line.get("근거") or {}).get("출처") or [] + return { + "원래": {k: str(v or "") for k, v in origin.items()}, + "갈래": kind, + "짝": pairs, + "엑셀조항": clauses if isinstance(clauses, list) else [], + } def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: - """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가.""" + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님).""" got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) base = got.get("기준") or {} ours = float(base["계"]) if "계" in base else None @@ -188,17 +297,18 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")} -def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]: - """실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 — - `{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`).""" - maker = Builder(source, folder, key, fresh) - made = maker.build(int(no)) - hopyo = maker.hopyo(int(no)) - ref = {k: made[k] for k in ("층", "주인", "키")} - return { - "참조": ref, - "이름": hopyo.get("이름"), - "다시씀": made["다시씀"], - "빠진줄": maker.missing.get(int(no), []), - "비교": compare(ref, hopyo), - } +def build( + folder: Path, + source: Path, + key: str, + no: int, + lines: list[dict[str, Any]] | None = None, + whole: dict[str, Any] | None = None, + who: Any = None, +) -> dict[str, Any]: + """실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 · + 별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`).""" + maker = Builder(folder, source, key, no, who) + checked, pairs = maker.check(list(lines or []), whole) + made = maker.build(checked, pairs) + return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)} diff --git a/M02_MasterTemplete/M02_Hopyo_Ledger.py b/M02_MasterTemplete/M02_Hopyo_Ledger.py index 0b97c4b1c..560292915 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ledger.py +++ b/M02_MasterTemplete/M02_Hopyo_Ledger.py @@ -178,9 +178,21 @@ def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[s values = raw.get("입력값") or {} if not isinstance(values, dict) or (mat and values): raise _bad(f"입력값이 틀림 「{where}」") - unknown = set(values) - input_names(code) if code else set() + unknown = set(values) - input_names(code) if code and values else set() if unknown: raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」") + times, part = times_of(raw, where) + head = {"로직": code} if code else {"자재": mat} + return { + **head, + "입력값": values, + "배": times, + "몫": part, + } + + +def times_of(raw: dict[str, Any], where: str) -> tuple[int | float, int | float | None]: + """(배, 몫) 재검사 — 배 0 넘는 수 · 몫 없음 또는 (0, 1].""" times = _number(raw.get("배", 1)) if times is None or times <= 0: raise _bad(f"배는 0 넘는 수 「{where}」") @@ -188,13 +200,7 @@ def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[s part = None if share is None else _number(share) if share is not None and (part is None or not 0 < part <= 1): raise _bad(f"몫은 0 넘고 1 이하 「{where}」") - head = {"로직": code} if code else {"자재": mat} - return { - **head, - "입력값": values, - "배": _json(times), - "몫": None if part is None else _json(part), - } + return _json(times), None if part is None else _json(part) def check(full: str, raw: Any, known: tuple[set[str], set[str]] | None = None) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 55739aa3a..f528dedf1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -4,23 +4,28 @@ 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + 사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + -일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기). +일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · +338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · +올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations +import importlib +import inspect from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field 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_Grid as hopyo_grid +from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -168,10 +173,27 @@ def new_material( return _call(folder, own.add, folder, body) -# ── 일위대가 실무자료(324) ───────────────────────────────────────────── +# ── 일위대가 실무자료(324 · 338) ─────────────────────────────────────── HOPYO_MAX_BYTES = 20 * 1024 * 1024 +def _later(module: str, name: str): + """맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로).""" + try: + got = importlib.import_module(f"M02_MasterTemplete.{module}") + except ImportError: + return None + return getattr(got, name, None) + + +if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None: + _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]: try: parsed = hopyo_excel.read_book(name, data) @@ -181,7 +203,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = hopyo_grid.read(name, data, parsed) except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None - return hopyo_store.save(store.root(), name, parsed, who, grid) + row = hopyo_store.save(store.root(), name, parsed, who, grid) + if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: + ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) + return row @router.get("/layers/{layer}/hopyo") @@ -198,9 +223,20 @@ def hopyo_picks( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 - `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억).""" + `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합).""" folder = _root(session, layer, owner) - return {"줄": _call(folder, hopyo_match.picks, folder)} + 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") @@ -217,8 +253,7 @@ def hopyo_upload( if len(data) > HOPYO_MAX_BYTES: raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)") name = Path(file.filename or "실무자료").name[:120] - who = {"id": session.get("user_id"), "이름": session.get("name")} - return _call(folder, _upload, name, data, who) + return _call(folder, _upload, name, data, _who(session)) @router.get("/layers/{layer}/hopyo/{key}") @@ -246,11 +281,17 @@ def hopyo_one( key: str, no: int, owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5).""" + """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · + 호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만).""" folder = _root(session, layer, owner) - return _call(folder, hopyo_match.view, folder, key, no) + if to is None or "to" not in inspect.signature(hopyo_match.view).parameters: + return _call(folder, hopyo_match.view, folder, key, no) + target = _root(session, to, to_owner) + return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target)) class GuessBody(BaseModel): @@ -274,33 +315,44 @@ def hopyo_guess( return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상) -class MemoryBody(BaseModel): - """`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}.""" - - 줄: dict[str, Any] +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈) -@router.post("/layers/{layer}/hopyo-memory") -def hopyo_memory( - layer: Layer, - body: MemoryBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동.""" - folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_match.confirm, folder, body.줄) +class BuildTarget(_Strict): + """대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫.""" + + 로직: str | None = None + 자재: str | None = None + 호표: int | None = None + 입력값: dict[str, Any] = Field(default_factory=dict) + 배: float = 1 + 몫: float | None = None -class BuildBody(BaseModel): - """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · - `새로` = 있던 것을 덮음.""" +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 새로: bool = False + 줄: list[BuildLine] = Field(default_factory=list, max_length=500) + 통째: BuildWhole | None = None @router.post("/layers/{layer}/hopyo-build") @@ -310,11 +362,43 @@ def hopyo_make( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) — - `{참조, 이름, 다시씀, 빠진줄, 비교}`.""" + """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → + `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409.""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) - return _call(folder, hopyo_build.build, folder, source, body.키, 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.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}") diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 1284a544c..06b15d287 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -1,11 +1,13 @@ -"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 · -수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 · -같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 · -빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더. +"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 · +몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 · +재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 · +`본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 · +master_data 원본 그대로. 층은 임시 폴더. """ import json +from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_Hopyo_Store as hs from M02_MasterTemplete import M02_Template_Layers as layers @@ -16,18 +18,14 @@ from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_has DIG = "구조물터파기(인력100%)|토사|m3" FILL = "구조물되메우기(인력100%)||m3" SOIL = "잔토처리(인력100%)||m3" -STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3" -MEMORY = "/api/m02/layers/{}/hopyo-memory" BUILD = "/api/m02/layers/{}/hopyo-build" +LEDGER = "/api/m02/layers/{}/hopyo-ledger" -def _remember(client, layer="personal", **lines): - res = client.post(MEMORY.format(layer), json={"줄": lines}) - assert res.status_code == 200, res.text - - -def _build(client, no, layer="personal", want=200, **extra): +def _build(client, no, lines=None, layer="personal", want=200, **extra): body = {"층": "personal", "키": "HP-000001", "번": no, **extra} + if lines is not None: + body["줄"] = lines res = client.post(BUILD.format(layer), json=body) assert res.status_code == want, res.text return res.json() @@ -37,22 +35,30 @@ def _doc(layer_dir, key): return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes()) -def test_만들기_별칭_수량_메모_빠진_줄_비교(client): +def _book(layer_dir): + return json.loads((layer_dir / hs.DIRNAME / f"{hs.MEMORY}.json").read_bytes())["줄"] + + +def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): before = master_hash() assert _post(client).status_code == 200 - _remember( - client, - **{ - DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, - FILL: {"로직": "GF000113", "환산": 2}, - }, - ) - got = _build(client, 3) + lines = [ + {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]}, + {"차례": 2, "대상": [{"로직": "GF000113", "배": 2}]}, + {"차례": 3, "뺌": "우리에 없음"}, + ] + got = _build(client, 3, lines) mine = layers.personal_dir(7, 42) doc = _doc(mine, got["참조"]["키"]) assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료") assert doc["상세구분"].endswith("(공통단가).xls") - assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표" + origin = doc["본뜬"] + assert (origin["층"], origin["키"], origin["원본"]) == ( + "실무자료", + "HP-000001 제3호표", + ["personal", "7/42"], + ) + assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"] assert doc["담은로직"] == [ { "로직": "GF000101", @@ -65,88 +71,167 @@ def test_만들기_별칭_수량_메모_빠진_줄_비교(client): "로직": "GF000113", "입력값": {}, "별칭": "구조물되메우기(인력100%)", - "수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2 + "수량": {"손": 0.1}, # 엑셀 0.05 × 배 2 "메모": "실무 0.05㎥ × 2", }, ] - assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [ - ("임도표지석(받침,글새김포함)", 1400000), - ("잔토처리(인력100%)", 3097.1), + assert doc["자재"] == [] # 표지석 = 몸에 없음 → 안 들어감 + assert [(x["이름"], x["금액"], x["까닭"]) for x in got["빠진줄"]] == [ + ("임도표지석(받침,글새김포함)", 1400000, "확인 안 함"), + ("잔토처리(인력100%)", 3097.1, "뺌 — 우리에 없음"), ] assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 - assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1) + assert got["다시씀"] is False and got["장부"] == {"씀": 3} + book = _book(mine) + assert book[DIG]["갈래"] == "짝" and book[DIG]["짝"] == [ + {"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1, "몫": None} + ] + assert book[DIG]["처음파일"].endswith("(공통단가).xls") + assert book[DIG]["원래"] == {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"} + assert book[SOIL]["갈래"] == "우리에 없음" and book[FILL]["짝"][0]["배"] == 2 + assert hb.confirmed(mine) == {"HP-000001 제3호표": got["참조"]["키"]} + assert hb.confirmed(mine, ("system", None)) == {} # 다른 파일 층 = 따로 assert master_hash() == before -def test_자재_같은_로직_감쌈_다시씀_새로(client): +def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): assert _post(client).status_code == 200 - _remember( - client, - **{ - DIG: {"로직": "GF000101", "환산": 1}, - SOIL: {"로직": "GF000101", "환산": 1}, - STONE: {"자재": "MT004258", "환산": 1}, - }, - ) - got = _build(client, 3) + lines = [ + {"차례": 0, "대상": [{"자재": "MT004258", "배": 1}]}, + {"차례": 1, "대상": [{"로직": "GF000101"}, {"로직": "GF000113", "배": 0.1, "몫": 0.1}]}, + {"차례": 3, "대상": [{"로직": "GF000101"}]}, + ] + got = _build(client, 3, lines) mine = layers.personal_dir(7, 42) doc = _doc(mine, got["참조"]["키"]) - first, wrapped = doc["담은로직"] - assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)" + first, second, wrapped = doc["담은로직"] + assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭 + assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사" assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09} inner = _doc(mine, wrapped["참조"]["키"]) - assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄" + assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄 GF000101" assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1} assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [ ("MT004258", {"손": 1}, "직접계산") ] + stone = next(v for k, v in _book(mine).items() if k.startswith("임도표지석(받침")) + assert stone["짝"][0]["자재"] == "MT004258" files = sorted(p.name for p in (mine / "precombo").glob("*.json")) - again = _build(client, 3) + again = _build(client, 3, lines[:1]) # 다시 확인 = 같은 본뜬 덮음 assert again["다시씀"] is True and again["참조"] == got["참조"] - assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files - _remember(client, **{SOIL: {"빼기": True}}) - fresh = _build(client, 3, 새로=True) - assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True - assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음 + assert _doc(mine, got["참조"]["키"])["담은로직"] == [] assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files -def test_일위N_참조_먼저_만듦(client): +def test_몸_없음_재검사_400(client): assert _post(client).status_code == 200 - got = _build(client, 45) + one = {"로직": "GF000101", "배": 1} + _build(client, 3, want=400) # 옛 몸(줄 없음) = 확인한 줄이 없음 + _build(client, 3, [], want=400) + bad = [ + [{"차례": 9, "대상": [one]}], # 호표 밖 줄 + [{"차례": 1, "대상": [one]}, {"차례": 1, "대상": [one]}], # 겹친 줄 + [{"차례": 1, "대상": []}], # 대상 · 뺌 없음 + [{"차례": 1, "대상": [one], "뺌": "안 씀"}], + [{"차례": 1, "대상": [{"로직": "XX"}]}], + [{"차례": 1, "대상": [{"로직": "GC000422"}]}], # 돈 아닌 로직 + [{"차례": 1, "대상": [{**one, "입력값": {"없는칸": 1}}]}], + [{"차례": 1, "대상": [{**one, "배": 0}]}], + [{"차례": 1, "대상": [{**one, "배": -1}]}], + [{"차례": 1, "대상": [{**one, "몫": 1.5}]}], + [{"차례": 1, "대상": [{**one, "자재": "MT004258"}]}], + [{"차례": 1, "대상": [{"자재": "MT000000"}]}], + [{"차례": 1, "대상": [{"호표": 3}]}], # 자기를 담음 + ] + for lines in bad: + _build(client, 3, lines, want=400) + for extra in ({"금액": 100}, {"품": {"보통인부": 1}}): # 금액 · 품 칸 = 받지 않음 + assert ( + client.post( + BUILD.format("personal"), + json={ + "층": "personal", + "키": "HP-000001", + "번": 3, + "줄": [{"차례": 1, "대상": [{**one, **extra}]}], + }, + ).status_code + == 422 + ) + assert client.post(BUILD.format("personal"), json={ + "층": "personal", "키": "HP-000001", "번": 3, "금액": 1, + "줄": [{"차례": 1, "대상": [one]}]}).status_code == 422 # fmt: skip + assert not (layers.personal_dir(7, 42) / "precombo").exists() or not list( + (layers.personal_dir(7, 42) / "precombo").glob("SC-*.json") + ) # 틀린 몸은 아무것도 안 씀 + assert client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {}}).status_code in ( + 404, + 405, + ) # 옛 즉시 확정 길 없음 + + +def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): + assert _post(client).status_code == 200 + ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}] + assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"] + child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}]) + assert any(x["까닭"].startswith("% 줄") for x in child["빠진줄"]) + got = _build(client, 45, ref) mine = layers.personal_dir(7, 42) - lines = _doc(mine, got["참조"]["키"])["담은로직"] - ref = lines[0] - assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3} - child = _doc(mine, ref["참조"]["키"]) - assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표" - assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"]) + line = _doc(mine, got["참조"]["키"])["담은로직"][0] + assert line["참조"] == child["참조"] and line["별칭"] == "고화매트재료비" + assert line["수량"] == {"손": 2.3} + assert "고화매트재료비||m2" not in _book(mine) # 호표 참조 줄 = 장부 안 씀 + whole = _build(client, 3, 통째={"대상": [{"로직": "GF000101", "배": 1}]}) + doc = _doc(mine, whole["참조"]["키"]) + assert [x["로직"] for x in doc["담은로직"]] == ["GF000101"] + assert doc["담은로직"][0]["수량"] == {"손": 1} + assert {x["까닭"] for x in whole["빠진줄"]} == {"호표 통째로 대신"} + head = next(v for k, v in _book(mine).items() if k.startswith("호표|임도표지석|")) + assert head["짝"][0]["로직"] == "GF000101" and head["원래"]["품명"] == "임도표지석" + _build(client, 3, [ref[0] | {"차례": 1}], 통째={"대상": [{"로직": "GF000101"}]}, want=400) -def test_순환은_빠진_줄(client): - folder = layers.personal_dir(7, 42) - _post(client) # 층 뼈대 - line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"} - money = {"계": 1, "노무비": 1, "재료비": None, "경비": None} - hopyos = [ - {"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [ - {**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]}, - {"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [ - {**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]}, - ] # fmt: skip - key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"] - got = _build(client, 1, 키=key) - assert got["빠진줄"] == [] - inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"]) - assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐 - - -def test_길_권한과_위_층_파일(client): +def test_길_권한과_위_층_파일_picks_확인(client): client.who.update(role="SYSTEM_ADMIN") assert _post(client, "system").status_code == 200 client.who.update(role="USER") - _build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자 - got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합 + lines = [{"차례": 1, "대상": [{"로직": "GF000101"}]}] + _build(client, 3, lines, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자 + got = _build(client, 3, lines, 층="system") # 마스터 파일 → 내 층 사전조합 assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42" - assert _build(client, 99, 층="system", want=404) + _build(client, 99, lines, 층="system", want=404) assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만 + rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] + three = next(r for r in rows if r["번"] == 3) + assert three["확인"] == {"층": "personal", "주인": "7/42", "키": got["참조"]["키"]} + assert next(r for r in rows if r["번"] == 4)["확인"] is None + + +def test_장부_길_보기_고치기_권한(client): + before = master_hash() + assert _post(client).status_code == 200 + _build(client, 3, [{"차례": 1, "대상": [{"로직": "GF000101"}]}]) + rows = client.get(LEDGER.format("personal")).json()["줄"] + assert [(r["열쇠"], r["층"], r["고침"]) for r in rows] == [(DIG, "personal", True)] + put = { + "줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}} + } + res = client.put(LEDGER.format("personal"), json=put) + assert res.status_code == 200, res.text + assert res.json()["씀"] == 2 + book = _book(layers.personal_dir(7, 42)) + assert book[DIG]["짝"][0] == {"로직": "GF000103", "입력값": {}, "배": 2, "몫": None} + assert book[DIG]["처음파일"].endswith("(공통단가).xls") # 처음파일 둠 + bad = {"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "XX"}]}}} + assert client.put(LEDGER.format("personal"), json=bad).status_code == 400 + assert client.put(LEDGER.format("personal"), json={"줄": {DIG: None}}).status_code == 200 + assert DIG not in _book(layers.personal_dir(7, 42)) + assert client.put(LEDGER.format("system"), json=put).status_code == 403 # 위 층 = 읽기만 + client.who.update(role="SYSTEM_ADMIN") + assert client.put(LEDGER.format("system"), json=put).status_code == 200 + client.who.update(role="USER") + rows = client.get(LEDGER.format("personal")).json()["줄"] + assert {(r["층"], r["고침"]) for r in rows} == {("personal", True), ("system", False)} + assert client.get(LEDGER.format("system")).json()["줄"][0]["고침"] is False + assert master_hash() == before