diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 4ae39fd85..2d5b09871 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -72,6 +72,9 @@ export interface SpreadsheetOptions { conditions?: ConditionOptions; /** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */ figureSrc?: FigureSrc; + /** 칸 칠(요구 621-5) — 시트 id · 행 · 열(0 부터) → 꼴 이름 `success|warning|info|danger|muted`(흐림) + `mine`(왼쪽 띠) · + * 공백으로 여럿 · 칸 `data-tone` 으로 나감 · 보이는 칸만 부름 · 안 주면 화면 그대로 */ + 칠?: (시트: string, r: number, c: number) => string | null | undefined; } export interface SpreadsheetHandle { @@ -93,6 +96,8 @@ export interface SpreadsheetHandle { /** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) · * `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */ pasteTable(html: string, check?: PasteCheck): Promise; + /** 보이는 칸만 다시 그림(`칠` 결과가 바뀐 뒤) — 문서는 안 읽음 */ + repaint(): void; /** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) · * 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */ select(범위: string | string[], 시트?: string): boolean; @@ -138,6 +143,7 @@ export function createSpreadsheet( root, book, readOnly, + fill: opts.칠, engine: createCalcEngine(book), history: createHistory(), selection: selectCell(firstSheet(book), 0, 0), @@ -369,6 +375,7 @@ export function createSpreadsheet( return 이름; }, pasteTable: (html, check) => pasteTableAt(ctx, html, check), + repaint: () => grid.render(), select(range, sheetKey) { const target = sheetKey ? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey) diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index d4c8eeb13..d3b141962 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -276,3 +276,40 @@ html[data-touch="on"] .aislo-grid__fill-handle { touch-action: pan-x pan-y; } } + +/* 칸 칠(옵션 `칠` · 요구 621-5) — 칸 원래 채움(인라인 background-color) 위에 겹침 · 어두움은 --color-* 가 따라감. */ +.aislo-cell[data-tone~="success"] { + background-image: linear-gradient( + color-mix(in srgb, var(--color-success) 18%, transparent), + color-mix(in srgb, var(--color-success) 18%, transparent) + ); +} +.aislo-cell[data-tone~="warning"] { + background-image: linear-gradient( + color-mix(in srgb, var(--color-warning) 18%, transparent), + color-mix(in srgb, var(--color-warning) 18%, transparent) + ); +} +.aislo-cell[data-tone~="info"] { + background-image: linear-gradient( + color-mix(in srgb, var(--color-info) 18%, transparent), + color-mix(in srgb, var(--color-info) 18%, transparent) + ); +} +.aislo-cell[data-tone~="danger"] { + background-image: linear-gradient( + color-mix(in srgb, var(--color-danger) 18%, transparent), + color-mix(in srgb, var(--color-danger) 18%, transparent) + ); +} +.aislo-cell[data-tone~="muted"] { + color: var(--color-text-muted) !important; +} +.aislo-cell[data-tone~="mine"] { + box-shadow: inset 3px 0 0 var(--color-accent); +} + +/* 읽기만 — 시트 탭 「+」 숨김 */ +.ss--readonly .ss-tabs__add { + display: none; +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts index 21160b525..f7aa3b203 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts @@ -319,6 +319,9 @@ export function renderCells( if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align }); } } + const tone = ctx.fill?.(sheet.id, r0, c0); + if (tone) cellEl.dataset.tone = tone; + else delete cellEl.dataset.tone; // 재사용 div — 앞 칸 꼴을 지움 cellEl.classList.toggle("is-error", display.isError); cellEl.classList.toggle("has-comment", !!comment); cellEl.classList.toggle( diff --git a/A00_Common/spreadsheet/spreadsheet_view_types.ts b/A00_Common/spreadsheet/spreadsheet_view_types.ts index 6158bbf9c..a88aa2811 100644 --- a/A00_Common/spreadsheet/spreadsheet_view_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_view_types.ts @@ -94,6 +94,8 @@ export interface SpreadsheetContext { /** 지금 문서 — 명령만이 바꿈(직접 고치지 말 것) */ book: Workbook; readOnly: boolean; + /** 칸 칠(옵션 `칠`) — 시트 id · 행 · 열 → 꼴 이름(공백으로 여럿 · 없으면 칠 없음) · 보이는 칸만 부름 */ + fill?: (시트: string, r: number, c: number) => string | null | undefined; engine: CalcEngine; history: History; selection: Selection; diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 0dc19505c..89ce8a19f 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -5,7 +5,7 @@ 몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]). 서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = 돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · -호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). +호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 저장 때 하위 먼저 함께 만듦 · `M02_Hopyo_Kids.first` · 777-1). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], 확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). @@ -18,7 +18,8 @@ 「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만 받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 · 지역은 프로젝트에서) · 장부엔 안 남김. -같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 +같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 없으면 같은 내용 SC 재사용(`재사용` · `M02_Hopyo_Kids.save`) · +`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자 그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 · @@ -31,13 +32,12 @@ `{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409 `{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음). 수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수}, -자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `compare` — 하위 호표 것까지 `excel_given`). +자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`). """ from __future__ import annotations -import json -from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal +from decimal import Decimal from pathlib import Path from typing import Any @@ -45,6 +45,7 @@ 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_Finger as finger from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Kids as hkids from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Material as hmat @@ -52,7 +53,12 @@ from M02_MasterTemplete import M02_Hopyo_SkLink as sklink 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_Library_Same as lsame from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Hopyo_Compare import ( # noqa: F401 — 부르는 쪽 이름 그대로 + compare, + excel_given, +) ORIGIN = "실무자료" LINES_MAX = 500 @@ -69,23 +75,8 @@ def mark(key: str, no: int, at: int | None = None) -> str: return head if at is None else f"{head} {at + 1}째 줄" -def _origins(folder: Path): - """층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판).""" - for path in sorted((Path(folder) / "precombo").glob("SC-*.json")): - try: - 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: - yield path.stem, got, raw - - -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) +_origins = hkids.entries # B09 가 빌려 씀(또 칸까지) +_same_source = hkids.same_source def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]: @@ -97,31 +88,6 @@ def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, } -def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]: - """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키 · 엑셀 수량 = 저장(742-1).""" - hands = { - "줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")}, - "자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")}, - } - doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}} - at = refs.here() - 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] - else: - key = refs.create("precombo", doc)["이름"] - return {"층": at[0], "주인": at[1], "키": key}, bool(have) - - def _num(value: Decimal) -> int | float: value = value.normalize() return int(value) if value == value.to_integral_value() else float(value) @@ -163,10 +129,12 @@ class Builder: self.done = confirmed(folder, self.source) self.known = ledger._known() self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]} + self.units = {int(h["번"]) for h in self.doc.get("호표") or []} 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) self.src, self.dry, self._sk, self._maker = Path(source), False, None, None + self.same, self.reused = lsame.Same(), False # 같은 내용 SC 찾기(한 요청 메모) self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓) self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝 @@ -185,8 +153,10 @@ class Builder: 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) + if mark(self.key, child) not in self.done and not self.dry: + raise _bad( + f"제{child}호표 먼저 확인", 409 + ) # 저장 길은 하위 먼저 만든 뒤(`hkids.first`) times, part = ledger.times_of(raw, where) return {"호표": child, "배": times, "몫": part} @@ -311,7 +281,7 @@ class Builder: ref = { "층": refs.here()[0], "주인": refs.here()[1], - "키": self.done[mark(self.key, one["호표"])], + "키": self.done.get(mark(self.key, one["호표"]), "SC-미리"), } lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) return @@ -336,7 +306,9 @@ class Builder: "자재": [], "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } - ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음 + ref = ( + {"키": "SC-미리"} if self.dry else hkids.save(wrap, self.same)[0] + ) # 미리 보기 = 쓰기 없음 lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) book: dict[str, dict[str, Any]] = {} @@ -395,13 +367,15 @@ class Builder: count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n} count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} count["단가산출"] = sum(1 for x in lines if _is_sk(x)) + sub = [{"번": n, "키": self.done.get(mark(self.key, n))} for n in sorted(kids)] if not save: ledger.write(self.folder, book, self.who, file or None, self.doc_id) - head = {"문서": doc, "빠진줄": missing, "남은줄": left} + head = {"문서": doc, "빠진줄": missing, "남은줄": left, "하위": sub} return {**head, "줄수": {**count, "이음후보": linker.near}} - ref, again = _save(doc) + ref, again, self.reused = hkids.save(doc, self.same) wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id) - out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} + out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub} + out["장부"] = {"씀": wrote} return {**out, "줄수": {**count, "이음후보": linker.near}} def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list: @@ -418,7 +392,9 @@ class Builder: if finger.kind_of(line) != "호표" and ref.get("종류") != "일위": return None no = int(ref.get("번") or 0) - made = self.done.get(mark(self.key, no)) + made = self.done.get(mark(self.key, no)) or ( + "SC-미리" if self.dry and no in self.units else None + ) unit = next((h.get("단위") for h in self.doc["호표"] if int(h["번"]) == no), None) times = hopyo_match.factor(line.get("단위"), unit, line.get("규격"))["배"] if not made or no in kids or no == self.no or times != 1: @@ -457,59 +433,6 @@ class Builder: } -# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 · -# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로) -CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False)) - - -def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None: - """엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 · - 합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None.""" - parts = dict.fromkeys(labor.COSTS, Decimal(0)) - for one in got.get("안") or []: - if one.get("기준") is None: - return None - cut = 100 if (one.get("고정") or {}).get("비율") else 1 - for k in labor.COSTS: - unit = Decimal(one["기준"][k]) - unit = unit.to_integral_value(how) if "참조" in one else unit - parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how) - if by_line: - return sum(parts.values()).to_integral_value(ROUND_FLOOR) - return sum(v.to_integral_value(how) for v in parts.values()) - - -def excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]: - """`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용.""" - doc = refs.read_ref(ref) if depth <= refs.DEPTH else None - if not isinstance(doc, dict): - return {} - rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {} - out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()} - out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()} - for one in refs.refs_in(doc): - out |= excel_given(one, depth + 1) - return out - - -def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: - """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · - 반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1).""" - head = {k: ref[k] for k in ("층", "주인", "키")} - got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head) - base = got.get("기준") or {} - excel = (hopyo.get("금액") or {}).get("계") - cuts = [_excel_round(got, *one) for one in CUTS] if base else [] - cuts = [c for c in cuts if c is not None] - cut = next( - (c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None - ) - ours = float(cut if cut is not None else base["계"]) if "계" in base else None - gap = round(ours - excel, 1) if ours is not None and excel is not None else None - same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0 - return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} - - def unlinked( folder: Path, source: Path, @@ -520,6 +443,7 @@ def unlinked( ) -> dict[str, Any]: """안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음).""" maker = Builder(folder, source, key, no, None) + maker.dry = True # 하위 확인 전 호표 대상 = 저장 때 함께 만듦(409 아님) checked, pairs = maker.check(list(lines or []), whole) left = maker.unlinked(checked, pairs) return {"남은줄": left, "수": len(left)} @@ -535,10 +459,16 @@ def build( who: Any = None, ) -> dict[str, Any]: """실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 · - 별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`).""" + 별칭 장부 — `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}`(부르는 쪽이 `store.at(folder)`) · + 확인 안 된 하위 호표 = 먼저 서버 체크 제안대로 함께 만듦(777-1 · 실패 = 409 「하위 제N호표 실패」).""" + kids = ( + hkids.first(folder, source, key, no, who, lines) if whole is None else [] + ) # 통째 = 하위 줄 안 씀 + new = {x["번"] for x in kids} maker = Builder(folder, source, key, no, who) checked, pairs = maker.check(list(lines or []), whole) made = maker.build(checked, pairs) + made["하위"] = [{**x, "새로": x["번"] in new} for x in made["하위"]] return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)} @@ -581,12 +511,16 @@ def build_all( "실패": [], "만듦": [], } + shared, failed = lsame.Same(), set() for no in _order(units): out["줄"] += len(units[no].get("줄") or []) + bad = next((k for k in hkids.kids_of(units[no]) if k in failed), None) try: maker = Builder(folder, source, key, no, who) - maker.strict = strict + maker.strict, maker.same = strict, shared have = maker.done.get(mark(key, no)) + if bad is not None and not have: # 777-1 — 하위 실패 연쇄 까닭 따로 + raise store.StoreError(409, {"까닭": f"하위 제{bad}호표 실패", "하위": bad}) if have: out["그대로"] += 1 ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have} @@ -600,6 +534,7 @@ def build_all( except store.StoreError as e: why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} out["실패"].append({"번": no, **why}) + failed.add(no) continue if got["같음"]: out["같음"] += 1 diff --git a/M02_MasterTemplete/M02_Hopyo_Compare.py b/M02_MasterTemplete/M02_Hopyo_Compare.py new file mode 100644 index 000000000..501c8f629 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Compare.py @@ -0,0 +1,66 @@ +"""M02 실무 호표 만든 일위대가 ↔ 엑셀 호표 단가 대조(PLAN 705-3 · 723-1 · 742-1 · 777-1 `M02_Hopyo_Build` 에서 떼어 냄). + +참고 · 판정 아님 — 우리 = 엑셀 끝수 꼴(EST 비목 합 내림 · STmate 줄 반올림 · 계 = 줄 계 합 내림) 중 엑셀과 같은 쪽 · +수량 = 엑셀 수량(`본뜬.실무수량` · 하위 사전조합까지 `excel_given`). +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs + +# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 · +# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로) +CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False)) + + +def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None: + """엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 · + 합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None.""" + parts = dict.fromkeys(labor.COSTS, Decimal(0)) + for one in got.get("안") or []: + if one.get("기준") is None: + return None + cut = 100 if (one.get("고정") or {}).get("비율") else 1 + for k in labor.COSTS: + unit = Decimal(one["기준"][k]) + unit = unit.to_integral_value(how) if "참조" in one else unit + parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how) + if by_line: + return sum(parts.values()).to_integral_value(ROUND_FLOOR) + return sum(v.to_integral_value(how) for v in parts.values()) + + +def excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]: + """`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용.""" + doc = refs.read_ref(ref) if depth <= refs.DEPTH else None + if not isinstance(doc, dict): + return {} + rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {} + out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()} + out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()} + for one in refs.refs_in(doc): + out |= excel_given(one, depth + 1) + return out + + +def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · + 반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1).""" + head = {k: ref[k] for k in ("층", "주인", "키")} + got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head) + base = got.get("기준") or {} + excel = (hopyo.get("금액") or {}).get("계") + cuts = [_excel_round(got, *one) for one in CUTS] if base else [] + cuts = [c for c in cuts if c is not None] + cut = next( + (c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None + ) + ours = float(cut if cut is not None else base["계"]) if "계" in base else None + gap = round(ours - excel, 1) if ours is not None and excel is not None else None + same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0 + return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index f1c114f8c..a9c490808 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -80,9 +80,9 @@ def _out(value: Decimal | None) -> int | float | None: # ── 파일 → 시트 표(줄마다 값 목록) ──────────────────────────────────────── -def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: - """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음.""" - wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) +def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, list[list[Any]]]: + """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음(`wanted` = 더 읽을 시트).""" + keep = (SHEET, SUMMARY, hopyo_basis.SHEET, *wanted) if not data: raise ExcelError("빈 파일") if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE): @@ -93,11 +93,11 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]): - wanted = hopyo_stmate.SHEETS + keep = (*hopyo_stmate.SHEETS, *wanted) out = { ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] for ws in book.worksheets - if ws.title.strip() in wanted + if ws.title.strip() in keep } book.close() return out @@ -107,7 +107,7 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: return { sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)] for sh in book.sheets() - if sh.name.strip() in wanted + if sh.name.strip() in keep } except ExcelError: raise diff --git a/M02_MasterTemplete/M02_Hopyo_Forget.py b/M02_MasterTemplete/M02_Hopyo_Forget.py index adfca6def..52626f5e6 100644 --- a/M02_MasterTemplete/M02_Hopyo_Forget.py +++ b/M02_MasterTemplete/M02_Hopyo_Forget.py @@ -5,7 +5,7 @@ HP · 격자 · 맞춤셈 캐시 지움 · 그 HP 로 만든 이 층 일위대가(SC) · 단가산출(SK) 엑셀 사본 걷음 (`본뜬` → 출처 `{층 「실무자료」, 파일, 때, 누가}` + 전국자재 · 복사원본 · SC 줄 메모 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 저장 `수량.손` 과 같을 때만 - 걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK `본뜬.또` = 그 HP 몫만 뺌 · + 걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK · SC `본뜬.또` = 그 HP 몫만 뺌 · 문서 몸(줄 · 덮개 · 더한줄 · 별칭 · 구분)과 별칭 장부는 그대로 · 끝낸 기록 없음(같은 엑셀 다시 올림 됨 · 키 번은 `불러옴.json` 번으로 다시 안 씀). 옛 파일 정리 `tidy`(한 번 · 층 폴더마다 · 미리 보기 = 표만) — 옛 HP(지문 없음) · 하루 지난 HP 끝냄 · @@ -113,7 +113,11 @@ def forget(folder: str | Path, gone: Callable[[str], bool], apply: bool = True) got = doc["본뜬"] if not any(hit(x) for x in [got, *(got.get("또") or [])] if isinstance(x, dict)): continue - new = strip_sc(doc) if kind == "precombo" else strip_sk(doc, hit) + new = strip_sk(doc, hit) if kind == "sangeun" or not hit(got) else strip_sc(doc) + if kind == "precombo" and ( + also := [x for x in got.get("또") or [] if isinstance(x, dict) and not hit(x)] + ): + new["본뜬"]["또"] = also # 777-1 같은 내용 SC 를 나눠 쓴 다른 불러오기 몫은 그대로 if new != doc: out.append(path.stem) if apply: diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index 4c63b1d4a..0820a4326 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -8,12 +8,18 @@ xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). xlsx = 값 · 열 폭(``) · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. -결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. +결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}], 보기, 원본, 목록, 줄}` — +행 = 엑셀 1부터 [시작, 끝]. +세 보기(779-1) = 탭 차례 일위대가표 · 단가산출표(목록표 — 엑셀꼴 단가산출총괄표 · STmate +단가산출근거목록표) · 단가산출상세 · `보기 {역할: 시트 id}` · `원본 {역할: 원본 시트 이름}` · +`목록 {호표: [{번, 행}], 단산: [{번, 행}]}`(목록 시트의 행 1부터) · +`줄 [{번, 차례, 행, 단산?: {번, 행: [시작, 끝]}, 일위?: {번, 행: [시작, 끝]}}]`. """ from __future__ import annotations import io +import re import zipfile from typing import Any @@ -24,7 +30,23 @@ from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate -SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC) +LIST_E, LIST_S, HOPYO_S = "단가산출총괄표", hopyo_stmate.LISTS[0], "일위대가목록표" +SHEETS = ( + hopyo_excel.SHEET, + LIST_E, + hopyo_basis.SHEET, + hopyo_stmate.SHEET, + LIST_S, + hopyo_stmate.CALC, +) +ROLES = { + "일위대가표": (hopyo_excel.SHEET, hopyo_stmate.SHEET), + "단가산출표": (LIST_E, LIST_S), + "단가산출상세": (hopyo_basis.SHEET, hopyo_stmate.CALC), +} +_B, _D, _NO = re.compile(r"(B\d{5}|제\d+호표)"), re.compile(r"(D\d{5})"), re.compile(r"(\d+)") +_HOPYO = hopyo_excel._HOPYO +EXTRA = (LIST_E, hopyo_excel.SUMMARY, HOPYO_S) # 격자 몫으로 더 읽는 시트 HEAD_ROWS = 4 _HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} _VALIGN = {0: "top", 1: "center"} @@ -213,9 +235,11 @@ def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]: return out -def _xlsx(name: str, data: bytes) -> dict[str, Any]: +def _xlsx( + name: str, data: bytes, found: dict[str, list[list[Any]]] | None = None +) -> dict[str, Any]: # ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 - found = hopyo_excel._sheets(name, data) + found = found or hopyo_excel._sheets(name, data, EXTRA) widths = _xlsx_widths(data) sheets = [] for at, title in enumerate((n for n in SHEETS if n in found), 1): @@ -244,14 +268,69 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]: return {"서식": [{}], "시트": sheets} +def _pairs( + rows: list[list[Any]] | None, col: int, rx: re.Pattern[str], hidden: bool = False +) -> list[tuple[str, int]]: + """목록 시트 → [(열쇠, 행)] — `col` 칸 글이 `rx` 에 꼭 맞는 줄(엑셀 1부터) · `hidden` = + STmate 숨은 칸 「COD|…」 코드 먼저(목록 차례 번 ≠ 진짜 번).""" + out = [] + for at, row in enumerate(rows or [], 1): + meta = hopyo_stmate._META.search(" ".join(map(str, row))) if hidden else None + cell = hopyo_excel._flat(hopyo_excel._text(row[col] if col < len(row) else None)) + if meta: + out.append((meta.group(1), at)) + elif hit := rx.fullmatch(cell): + out.append((hit.group(1), at)) + return out + + +def _lines(parsed: dict[str, Any]) -> list[dict[str, Any]]: + """일위대가 줄 → 엑셀 행 · 단산 줄 → 블록 행 · 일위 줄 → 하위 호표 행.""" + block = {b["번"]: b["행"] for b in parsed.get("단산") or []} + hopyo = {h["번"]: h["행"] for h in parsed.get("호표") or []} + out = [] + for h in parsed.get("호표") or []: + for line in h.get("줄") or []: + one: dict[str, Any] = {"번": h["번"], "차례": line["차례"], "행": line.get("행")} + ref = line.get("참조") or {} + for key, rows in (("단산", block), ("일위", hopyo)): + if ref.get("종류") == key and ref.get("번") in rows: + one[key] = {"번": ref["번"], "행": rows[ref["번"]]} + out.append(one) + return out + + +def _lists(rows: dict[str, Any], parsed: dict[str, Any]) -> dict[str, list[dict[str, int]]]: + """목록 시트 행 ↔ 호표 번 · 단산(블록) 번.""" + if parsed.get("꼴") == "STmate": # 숨은 코드(없으면 A 열) — 호표 = 코드 → 번 · 단산 = 코드 숫자 + code = {h["코드"]: h["번"] for h in parsed.get("호표") or [] if h.get("코드")} + hopyo = [ + (int(k[1:-2]) if k[0] == "제" else code.get(k), at) + for k, at in _pairs(rows.get(HOPYO_S), 0, _B, True) + ] + calc = [(int(k[1:]), at) for k, at in _pairs(rows.get(LIST_S), 0, _D, True)] + else: # B 열 「제N호표」 · 「No」 + hopyo = [(int(k), at) for k, at in _pairs(rows.get(hopyo_excel.SUMMARY), 1, _HOPYO)] + calc = [(int(k), at) for k, at in _pairs(rows.get(LIST_E), 1, _NO)] + pair = lambda got: [{"번": k, "행": at} for k, at in got if k is not None] # noqa: E731 + return {"호표": pair(hopyo), "단산": pair(calc)} + + def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]: - """엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과 - (행 범위).""" + """엑셀 → `{책: Workbook, 호표, 단산, 보기, 원본, 목록, 줄}` — `parsed` = + `M02_Hopyo_Excel.read_book` 결과(행 범위).""" xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx") - book = _xlsx(name, data) if xlsx else _xls(data) + rows = hopyo_excel._sheets(name, data, EXTRA) # ponytail: xls 는 서식 없이 한 번 더 읽음 + book = _xlsx(name, data, rows) if xlsx else _xls(data) keep = ("번", "이름", "행") + ids = {s["이름"]: s["id"] for s in book["시트"]} + on = {role: next((n for n in names if n in ids), None) for role, names in ROLES.items()} return { "책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"}, "호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []], "단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []], + "보기": {role: ids[n] for role, n in on.items() if n}, + "원본": {role: n for role, n in on.items() if n}, + "목록": _lists(rows, parsed), + "줄": _lines(parsed), } diff --git a/M02_MasterTemplete/M02_Hopyo_Item.py b/M02_MasterTemplete/M02_Hopyo_Item.py index f1b0fb1cf..519a24168 100644 --- a/M02_MasterTemplete/M02_Hopyo_Item.py +++ b/M02_MasterTemplete/M02_Hopyo_Item.py @@ -3,6 +3,7 @@ [구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` · 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item` · 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`). +확인 안 된 하위 호표 = 먼저 일위대가로 함께 만듦(777-1 `M02_Hopyo_Kids.first` · 줄 = 하위 사본 · 못 만들면 고정 줄). 규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) → 한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 · 열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표. @@ -15,6 +16,7 @@ from pathlib import Path from typing import Any from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Kids as hkids from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Item_Personal as personal from M02_MasterTemplete import M02_Labor_Spec as spec @@ -82,6 +84,10 @@ def siblings(source: Path, key: str, no: int) -> dict[str, Any]: def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict: + try: # 777-1 — 하위 호표 먼저(엑셀 단가 한 줄로 납작 안 함) · 못 만들면 지금처럼 고정 줄 + hkids.first(folder, source, key, no, who, lines) if whole is None else None + except store.StoreError: + pass maker = hb.Builder(folder, source, key, no, who) checked, pairs = maker.check(list(lines or []), whole) return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo} diff --git a/M02_MasterTemplete/M02_Hopyo_Kids.py b/M02_MasterTemplete/M02_Hopyo_Kids.py new file mode 100644 index 000000000..94bd4c66a --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Kids.py @@ -0,0 +1,247 @@ +"""M02 실무 호표가 다른 호표를 부를 때 = 일위대가 둘(PLAN 777-1 · 요구 621-4 · 요구 581 「SK · SC 서로 독립」). + +하위 함께 만들기 `first` — 부모 저장(`M02_Hopyo_Build.build` · [구조물 조합으로])에서 확인 안 된 하위 호표(호표 줄 · + 호표 대상 · 깊이 끝까지)를 하위 먼저 그 하위의 서버 체크 제안 몸(`proposal`)으로 만듦(409 「먼저 확인」 대신) · + 부모 줄 = 하위 사본(740-1 그대로) · 하위 실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위 N, 하위남은줄}`. + 화면 캐시 고름은 화면이 하위를 먼저 보냄(`M02_MasterTemplete_Hopyo_Kids.ts`). +`proposal` 짝 = 화면 `bodyFor` · `wholeFor`(`M02_MasterTemplete_Hopyo_Kids.ts` · 캐시 빈 것) — 체크 제안 줄 · 대상 꼴 같음. +같은 내용 일위대가 재사용 `save` — 같은 본뜬(키 · 원본)이면 그 SC(혼자 쓰면 덮음 · 나눠 쓰면 내용 같을 때만) · + 아니면 이 층 같은 이름 · 같은 내용 SC(`M02_Library_Same.find_same` + 수량까지) 의 `본뜬.또` 에 출처를 더해 씀 + (SK 꼴 `M02_Hopyo_Sangeun.Maker.write`) · 없으면 새로 → 다시 불러와도 SC 가 안 늚. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +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 entries(folder: str | Path): + """층 사전조합 실무자료 출처 → (키, 본뜬 · 또 한 칸, 판) — 또 = 같은 내용으로 이 SC 를 쓴 뒤 출처.""" + for path in sorted((Path(folder) / KIND).glob("SC-*.json")): + try: + 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: + for one in [got, *(x for x in got.get("또") or [] if isinstance(x, dict))]: + yield path.stem, one, raw + + +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 _bare(value: Any) -> Any: + """사본 블록 `주인`(몸 지문 · 메모까지 셈) 뺌 — 셈 내용은 `몸` 이 다 가짐.""" + if isinstance(value, list): + return [_bare(x) for x in value] + if not isinstance(value, dict): + return value + return {k: _bare(x) for k, x in value.items() if not (k == "주인" and "몸" in value)} + + +def _rows(doc: dict[str, Any], part: str) -> list[str]: + return sorted(json.dumps(lsame.norm(_bare(x)), ensure_ascii=False, sort_keys=True) for x in doc.get(part) or []) # fmt: skip + + +def body(doc: dict[str, Any]) -> list: + """셈 내용 그대로(수량까지 · 하위 = 펼친 사본 몸) — `find_same` 지문은 SC 수량을 안 봄.""" + flat = lflat.flat_doc(KIND, doc) + head = [lsame.unit(flat.get("단위")), lsame.norm(flat.get("규격"))] + return [*head, _rows(flat, "담은로직"), _rows(flat, "자재")] + + +def _entry(doc: dict[str, Any]) -> dict[str, Any]: + return {**doc["본뜬"], "이름": doc["이름"]} + + +def _split(old: dict[str, Any], mine: dict[str, Any]) -> tuple[dict, list]: + got = old.get("본뜬") or {} + own = {k: v for k, v in got.items() if k != "또"} + rest = [x for x in got.get("또") or [] if isinstance(x, dict) and not _same_mark(x, mine)] + return own, rest + + +def _put(key: str, raw: bytes, old: dict, own: dict, also: list, doc: dict | None = None) -> None: + new = {**(doc or old), "본뜬": {**own, "또": also} if also else own} + if new != old: + store.write(KIND, key, store.version_of(raw), new) + + +def _empty(root: Path, doc: dict[str, Any]) -> list[str]: + """빈 호표(줄 · 자재 없음 = 지문 없음) — 이름 · 단위 같은 실무자료 SC.""" + if doc.get("담은로직") or doc.get("자재"): + return [] + out = [] + for path in sorted((root / KIND).glob("SC-*.json")): + try: + got = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + same = (got.get("이름"), lsame.unit(got.get("단위"))) == (doc["이름"], lsame.unit(doc.get("단위"))) # fmt: skip + if same and (got.get("본뜬") or {}).get("층") == ORIGIN: + out.append(path.stem) + return out + + +def save(doc: dict[str, Any], same: lsame.Same | None = None) -> tuple[dict[str, Any], bool, bool]: + """(참조, 다시씀, 재사용) — 엑셀 수량 = 저장(742-1) · `same` = 한 요청 안 지문 메모.""" + hands = { + "줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")}, + "자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")}, + } + doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}} + same, root, mine = same or lsame.Same(), store.root(), doc["본뜬"] + here = refs.here() + ref = lambda key: {"층": here[0], "주인": here[1], "키": key} # noqa: E731 + have = next( + ( + (key, raw) + for key, got, raw in entries(root) + if got.get("키") == mine["키"] and same_source(got, tuple(mine["원본"])) + ), + None, + ) + if have: + key, raw = have + old = json.loads(raw) + own, rest = _split(old, mine) + owner, alike = _same_mark(own, mine), body(old) == body(doc) + if owner and (not rest or alike): # 혼자 · 내용 같음 = 제자리 덮음(나눈 출처 그대로) + _put(key, raw, old, mine, rest, doc) + same.add(root, KIND, key, doc) + return ref(key), True, False + if alike: # 남의 SC 를 같은 내용으로 씀 — 제 출처 칸만 + _put(key, raw, old, own, [*rest, _entry(doc)]) + return ref(key), True, False + head = {k: v for k, v in rest[0].items() if k != "이름"} if owner else own + _put(key, raw, old, head, rest[1:] if owner else rest) # 내용 달라짐 = 떼어 냄 + for key in same.find_same(root, KIND, doc) or _empty(root, doc): + raw = (root / KIND / f"{key}.json").read_bytes() + old = json.loads(raw) + if old.get("이름") == doc["이름"] and body(old) == body(doc): # 이름까지 = 같은 호표 + own, rest = _split(old, mine) + _put(key, raw, old, own, [*rest, _entry(doc)]) + return ref(key), False, True + key = refs.create(KIND, doc)["이름"] + same.add(root, KIND, key, doc) + return ref(key), False, False + + +# ── 하위 함께 만들기 ───────────────────────────────────────────────── +def kids_of(hopyo: dict[str, Any]) -> list[int]: + """호표 줄이 부르는 호표 번(줄 차례).""" + out = [] + for line in hopyo.get("줄") or []: + ref = line.get("참조") or {} + if finger.kind_of(line) == "호표" or ref.get("종류") == "일위": + out.append(int(ref.get("번") or 0)) + return out + + +def order(units: dict[int, dict[str, Any]], no: int, also: Any = ()) -> list[int]: + """`no` 아래 하위 호표(+ `also` 와 그 아래) — 하위 먼저 · `no` 뺌 · 고리는 끊음.""" + out: list[int] = [] + seen = {int(no)} + + def visit(at: int) -> None: + for kid in kids_of(units.get(at) or {}): + if kid not in seen and kid in units: + seen.add(kid) + visit(kid) + out.append(kid) + + visit(int(no)) + for kid in also: + if kid not in seen and kid in units: + seen.add(kid) + visit(kid) + out.append(kid) + return out + + +def _target(p: dict[str, Any]) -> dict[str, Any]: + """대상 → 만들기 몸 대상(화면 `toTarget` 짝).""" + times = 1 if p.get("배") is None else p["배"] + if p.get("호표") is not None: + return {"호표": p["호표"], "배": times} + link = next((k for k in ("노임", "기계", "비율") if p.get(k)), None) + if link: + return {link: p[link], "배": 1} + head = ( + {"로직": p["로직"], "입력값": p.get("입력값") or {}} + if p.get("로직") + else {"전국": True} + if p.get("전국") + else {"자재": p.get("자재")} + ) + return {**head, "배": times, **({"몫": p["몫"]} if p.get("몫") is not None else {})} + + +def proposal(hopyo: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + """서버 체크 제안 몸 (줄, 통째) — 보기(`M02_Hopyo_Line.hopyo_view`) 체크 제안 줄만(화면 `bodyFor` 짝).""" + whole = hopyo.get("통째") + if whole and whole.get("체크"): + return [], {"대상": [_target(p) for p in whole.get("대상") or []]} + lines = [] + for line in hopyo.get("줄") or []: + got = line.get("맞춤") or {} + parts = got.get("대상") if isinstance(got.get("대상"), list) else [] + if got.get("상태") == "%": + ok = bool(parts and parts[0].get("비율")) + else: + ok = bool(parts) and all((p.get("배") or 0) > 0 for p in parts) + if got.get("체크") and ok: + lines.append({"차례": line["차례"], "대상": [_target(p) for p in parts]}) + return lines, None + + +def first( + folder: Path, source: Path, key: str, no: int, who: Any, lines: Any = () +) -> list[dict[str, Any]]: + """확인 안 된 하위 호표(호표 줄 + 몸 `lines` 의 호표 대상)를 하위 먼저 서버 체크 제안대로 만듦 → + `[{번, 키}]`(새로 만든 것) · + 실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위, 하위남은줄}`(만든 하위는 남음).""" + from M02_MasterTemplete import M02_Hopyo_Build as hb + from M02_MasterTemplete import M02_Hopyo_Match as hm + + units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} + also = [ + t["호표"] for x in lines or [] for t in x.get("대상") or [] if t.get("호표") is not None + ] + todo = order(units, no, also) + done = hb.confirmed(folder, refs.where(source)) if todo else {} + made: list[dict[str, Any]] = [] + for kid in todo: + if hb.mark(key, kid) in done: + continue + try: + lines, whole = proposal(hm.view(source, key, kid, to=folder)["호표"]) + maker = hb.Builder(folder, source, key, kid, who) + got = maker.build(*maker.check(lines, whole)) + except store.StoreError as e: + why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} + left = why.get("남은줄") or [] + detail = {"까닭": f"하위 제{kid}호표 실패 — {why.get('까닭')}", "하위": kid} + raise store.StoreError(409, {**detail, "하위남은줄": left}) from e + done[hb.mark(key, kid)] = got["참조"]["키"] + made.append({"번": kid, "키": got["참조"]["키"]}) + return made diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 3ebb01590..c85ab4a69 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -256,7 +256,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}} target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"]) ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정" - text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인" + text = f"하위 일위대가 {made} 사본" if made else f"제{no}호표 — 저장 때 하위로 함께 만듦" return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text} if kind == "노임": return {**out, "상태": "노임", "글": "노임 · 그릇 없음"} diff --git a/M02_MasterTemplete/M02_Hopyo_Marks.py b/M02_MasterTemplete/M02_Hopyo_Marks.py new file mode 100644 index 000000000..aefdb82a1 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Marks.py @@ -0,0 +1,63 @@ +"""M02 불러오기 엑셀 보기 하이라이트 재료(PLAN 779-2 · 요구서 621 3장 5) — 읽기만(파일 · storage 변화 0). + +블록 `[{번, 판정, SK, 갈래, 못옮김}]` = 단가산출 만들기 결과(`Sangeun.made`) + 맞춤셈 캐시(`Fit.result`)를 읽기만. +- `판정` = 그 블록을 부르는 첫 줄의 맞춤셈 판정 상태(맞음 · 다른 점 · 후보 …) · 안 불리거나 셈 전이면 None +- `SK` = 만든 SK 키 · 없으면 None / `갈래` · `못옮김` = 그 SK 본뜬(출처별) · 없으면 None · [] +줄 상태는 `Match.view` 호표마다 `줄상태 [[차례, 상태, 체크]]` · `만든`(SC 키)로 실려 감. + +칠 꼴(화면 779-4 가 정함 · 상태 이름 맞춤표 — 「이어짐」 = 확인 창과 한 규칙 `체크`): +| 칠 | 줄 상태 (`줄상태`) | 블록 (`판정` · `SK` · `못옮김`) | +| 초록 이어짐 | 맞음 · 별칭 · 호표(`체크`) · 체크한 다른 점 | SK 있음 + 못옮김 [] (갈래 「기초」 포함) | +| 주황 엑셀과 다름 | 다른 점 + `체크` | SK 있음 + 갈래 「SK」(차이) | +| 파랑 고를 것 | 후보 · `체크` 아닌 다른 점 · 호표(`체크` 아님) | SK 없음 + 판정 후보 · 고름 대기 | +| 빨강 못 이음 | 없음 · 노임 · (안 이은 줄 = 마법사 몫) | SK 있음 + 못옮김 남음 | +| 흐림 밖 | % · 뺌 | 판정 None (안 불리는 블록) | +| 띠 내가 고름 | 화면 캐시(서버 아님) | — | +최종 판정은 화면(`SheetMap`)이 이 재료 + 캐시 고름으로 내림 — 여기는 상태 이름을 그대로 실음. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Ref as refs + + +def _origin(to: Path, sk: str, mark: str, here: refs.Here) -> dict[str, Any]: + try: + doc = json.loads((to / sangeun.KIND / f"{sk}.json").read_bytes()) + except (OSError, ValueError): + return {} + return sangeun.origin_of(doc, mark, here) + + +def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any]]: + """블록마다 `{번, 판정, SK, 갈래, 못옮김}` — `to` = 만든 SK 를 볼 층(없으면 파일 층).""" + folder, to = Path(folder), Path(to or folder) + hp = hopyo_store.read(folder, key) + here = refs.where(folder) + cache = (fit.result(folder, key) or {}).get("블록") or {} + have = sangeun.made(to, key, here) + all_, first = sangeun.calls(hp) + out = [] + for no in sorted(all_): + line = first.get(no) + entry = cache.get(fit.unit_key(line)) if line else None + mark = sangeun.mark(key, no) + sk = have.get(mark) + origin = _origin(to, sk, mark, here) if sk else {} + out.append( + { + "번": no, + "판정": ((entry or {}).get("판정") or {}).get("상태"), + "SK": sk, + "갈래": origin.get("갈래"), + "못옮김": origin.get("못옮김") or [], + } + ) + return out diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 2a2bf69c7..01579d082 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -447,7 +447,8 @@ def units_of(doc: dict[str, Any]) -> dict[int, str]: def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) -> dict[str, Any]: - """실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · 있으면 그 호표 줄마다 맞춤(3-3) + """실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · `줄상태 [[차례, 상태, 체크]]` · + `만든`(그 호표 SC 키 · 없으면 None) · 있으면 그 호표 줄마다 맞춤(3-3) · `to` = 만들 층(장부 · 확인된 사전조합 · 없으면 파일 층) · 맞춤셈이 없으면 뒤 셈을 넣음.""" lines = _line_mod() @@ -457,6 +458,11 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []] keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된") brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos] + for one, h in zip(brief, hopyos): # 779-2 · 이미 센 줄 상태를 버리지 않음(덧 셈 0) + one["줄상태"] = [ + [i, x["맞춤"]["상태"], x["맞춤"]["체크"]] for i, x in enumerate(h["줄"]) + ] + one["만든"] = ctx.done.get(lines.hopyo_build.mark(key, int(h["번"]))) ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc} links = {k: sum(h[k] for h in hopyos) for k in ("연결할", "연결된")} diff --git a/M02_MasterTemplete/M02_Hopyo_SkLink.py b/M02_MasterTemplete/M02_Hopyo_SkLink.py index 171e352a5..68e5e035f 100644 --- a/M02_MasterTemplete/M02_Hopyo_SkLink.py +++ b/M02_MasterTemplete/M02_Hopyo_SkLink.py @@ -29,7 +29,7 @@ WHY = { "별도계상": "자재 후보 여럿 · 없음", "노임": "같은 이름 노임 없음", "기계": "같은 기계(EQ) 없음 · 여럿", - "호표": "하위 호표 확인 전 · 단위 다름", + "호표": "하위 호표 단위 다름 · 같은 하위 둘째", "%": "밑수가 다른 줄 금액 합 아님", } diff --git a/M02_MasterTemplete/M02_Hopyo_SkPatch.py b/M02_MasterTemplete/M02_Hopyo_SkPatch.py index bfc446977..2d402d67e 100644 --- a/M02_MasterTemplete/M02_Hopyo_SkPatch.py +++ b/M02_MasterTemplete/M02_Hopyo_SkPatch.py @@ -9,7 +9,8 @@ 기계경비 로직(굴착기 GC000267 · 그 밖 GC000268 · 기계 = 옵션 거꾸로 찾기) · 남은 우리 = 뺀줄 자재 = 같은 이름 우리 줄 수량 덮개 · 옮기지 못한 차이 = `못옮김`(버리지 않고 본뜬에 적음) 못옮김 = `[{id, 갈래 노임|기계|자재, 이름, 규격, 단위, 수량(엑셀 1 단위 품), 글, 후보}]` · 사용자 `잇기` - `[{id, 노임|기계|자재: 키}]` = SK 더한줄 인력 · 기계 · 재료(엑셀 수량 그대로) · `{id, 뺌: 까닭}` = 놓음. + `[{id, 노임|기계|자재: 키}]` = SK 더한줄 인력 · 재료 · 기계(EQ = 기계경비 기초 로직 줄 · 778-2) + (엑셀 수량 그대로) · `{id, 뺌: 까닭}` = 놓음. SK 단위(728-2) = 엑셀 줄 단위 · 바탕 기준단위와 성격 다름 = 환산 계수 = 우리 품 ÷ 엑셀 품(인력 · 장비 둘 이상이 같은 비일 때만 · `환산` {계수}) · 못 구함 = 고름 대기(까닭 「단위 성격 다름 · 계수 필요」). 고름 대기 = 후보 · 숫자 없음 — SK 안 만듦 · 후보 다섯(사용자가 고른 기초 `pick` = 견줘 SK · 기초) · @@ -49,7 +50,7 @@ LOOKS = ("같음", "품다름", "엑셀만", "기초만") # 줄비교 꼴(746-1 COMPARE = 3 # 고름 대기 후보 앞 몇에 줄비교 LINK = { "노임": ("인력", "LB"), - "기계": ("기계", "EQ"), + "기계": ("로직", "EQ"), # EQ = 기계경비 기초 로직 줄 + 기계 입력(778-2) "자재": ("재료", "MT"), } # 잇기 칸 → 더한줄 · 키 머리 @@ -228,9 +229,17 @@ class _Plan: raise LinkError(f"「{got['id']}」 · 없는 키 「{key}」") from e self.lost.remove(got) name = str(elem.get("이름") or got["이름"]) + qty = _num(float(got["수량"]) * self.tb) + if kind == "로직": # EQ 키 바로 = 셈 못 함 → 기계경비 기초(기계 = 그 EQ) + kinds = mtypes(f"{elem.get('상세구분')} {elem.get('이름')}") + code = _mach_logics(self.whole, kinds, elem)[0] + name = f"{name} {elem.get('규격') or ''}".strip() + self.add({"종류": kind, "로직": code, "입력값": {MACH_IN: elem.get("원문번호")}, + "이름": name, "단위": got["단위"] or "시간", "수량": qty}) # fmt: skip + continue unit = got["단위"] or ("인" if kind == "인력" else str(elem.get("단위") or "")) self.add({"종류": kind, "요소": key, "이름": name, "단위": unit, - "수량": _num(float(got["수량"]) * self.tb)}) # fmt: skip + "수량": qty}) # fmt: skip def labor(self, excel: dict[str, float]) -> None: mine = [x for x in self.lines if x["갈래"] == "인력" and not x["cid"].startswith("add:")] diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 8705b2a01..c369283f1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -16,8 +16,6 @@ from __future__ import annotations -import importlib -import inspect from pathlib import Path from typing import Any, Literal @@ -27,11 +25,14 @@ 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_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 @@ -211,17 +212,7 @@ def new_material( 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() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 +hopyo_ours.warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 def _who(session: Session) -> dict[str, Any]: @@ -242,8 +233,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) - if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: - ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) + hopyo_fit.ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) return row @@ -254,9 +244,8 @@ def hopyo_list( """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" folder = _root(session, layer, owner) rows = hopyo_store.rows(folder) - if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: - for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) - ensure(folder, row["키"]) + for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) + hopyo_fit.ensure(folder, row["키"]) return {"줄": rows} @@ -328,11 +317,9 @@ def hopyo_one( session: Session = Depends(verify_session), ) -> dict: """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · - 호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만).""" + 호표 참조 줄 체크 · 만든).""" folder = _root(session, layer, owner) - 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) + 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)) @@ -395,13 +382,12 @@ class BuildWhole(_Strict): class BuildBody(_Strict): """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` · - 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀).""" + 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음.""" 층: Layer 주인: str | None = None 키: str 번: int - 새로: bool = False 줄: list[BuildLine] = Field(default_factory=list, max_length=500) 통째: BuildWhole | None = None @@ -414,7 +400,7 @@ def hopyo_make( session: Session = Depends(verify_session), ) -> dict: """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → - `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409.""" + `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}` · 하위 호표 확인 전 = 함께 만듦(실패 409).""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) lines = [one.model_dump(exclude_none=True) for one in body.줄] @@ -622,3 +608,18 @@ def hopyo_to_item( 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_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index e9a35acef..c999e5627 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -29,7 +29,6 @@ import { openModal } from "@ui/ui_template_modal"; import { t as L } from "@ui/ui_template_locale"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { - buildHopyo, guessHopyo, readHopyoOne, type BuildLine, @@ -41,6 +40,7 @@ import { type HopyoPart, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildWithKids as buildHopyo } from "./M02_MasterTemplete_Hopyo_Kids"; // 777-1 하위 함께 import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose"; import { checkTables, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Kids.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Kids.ts new file mode 100644 index 000000000..de3aa001a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Kids.ts @@ -0,0 +1,130 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Kids.ts + * 일위대가 실무자료 만들기 몸 · 하위 호표 함께(PLAN 777-1 · 요구 621-4) — 호표가 다른 호표를 부르면 일위대가 둘 + * (하위 일위대가 따로 + 부모 줄 = 하위 사본). + * · bodyFor · wholeFor = 확인 창 캐시 위 체크 제안(짝 = 서버 `M02_Hopyo_Kids.proposal` · 캐시 빈 것) + * · unmadeKids = 고른 호표 줄이 부르는 확인 안 된 하위 호표(깊이 끝까지 · 하위 먼저) — [저장]이 그 캐시대로 함께 만듦 + * · buildWithKids = 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 → 부모(실패 = 「하위 제N호표 실패」) + * · 캐시 없는 하위도 서버가 부모 저장 때 제안대로 함께 만듦(`M02_Hopyo_Kids.first`) — 화면은 캐시 고름을 살림 + * · kidsText = 결과 글 「· 하위 제4호표 SC-11(사본 담음)」 · leftAt = 하위 실패의 남은 줄 = 그 하위 [고르기] + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; +import { + buildHopyo, + readHopyoOne, + type HopyoBuilt, + type HopyoOne, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { bodyOf, draftKey, loadDraft, partsOf, toTarget } from "./M02_MasterTemplete_Hopyo_Check"; + +type Body = Parameters[2]; +type Whole = Parameters[3]; +type From = Parameters[1]; + +/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ +export function bodyFor(scope: HopyoScope, key: string, one: HopyoOne): Body { + const h = one.호표; + const d = loadDraft(draftKey(scope, key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); +} +export function wholeFor(scope: HopyoScope, key: string, one: HopyoOne): Whole { + const h = one.호표; + const d = loadDraft(draftKey(scope, key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; +} + +/** (줄, 통째) 몸 한 벌 */ +export const bodyPair = (scope: HopyoScope, key: string, one: HopyoOne): [Body, Whole] => [ + bodyFor(scope, key, one), + wholeFor(scope, key, one), +]; + +/** 호표 줄이 부르는 호표 번(체크 안 한 호표 줄 · 캐시에서 고른 호표 대상까지) */ +const kidNos = (scope: HopyoScope, key: string, one: HopyoOne): number[] => { + const d = loadDraft(draftKey(scope, key, one.호표.번)); + return one.호표.줄.flatMap((l) => + partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])), + ); +}; + +/** [저장] 할 호표 — 함께 만드는 하위 · 체크 줄(통째) 있음 · 하위를 부름(호표 줄 = 하위 사본) */ +export function readyFor(scope: HopyoScope, key: string, one: HopyoOne, picked: number[]): boolean { + const [lines, whole] = bodyPair(scope, key, one); + return ( + !picked.includes(one.호표.번) || !!lines.length || !!whole || !!kidNos(scope, key, one).length + ); +} + +/** 확인 안 된 하위 호표 보기 — 하위 먼저 · `ones` 와 이미 만든 하위(그 아래도) 뺌 */ +export async function unmadeKids( + scope: HopyoScope, + key: string, + target: HopyoScope, + ones: HopyoOne[], +): Promise { + const out: HopyoOne[] = []; + const seen = new Set(ones.map((one) => one.호표.번)); + async function visit(one: HopyoOne): Promise { + for (const no of kidNos(scope, key, one)) { + if (seen.has(no)) continue; + seen.add(no); + const kid = await readHopyoOne(scope, key, no, target); + if (kid.호표.만든) continue; + await visit(kid); + out.push(kid); + } + } + for (const one of ones) await visit(one); + return out; +} + +/** 하위 실패 — 까닭 「하위 제N호표 실패 — …」 · 남은 줄은 그 하위 몫(`leftAt`) */ +function kidFail(error: unknown, no: number): StaleError { + const left = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄; + const why = error instanceof Error ? error.message : String(error); + const text = `${L("M02_HopyoKidFail").replace("{n}", String(no))} — ${why}`; + return new StaleError(text, { 까닭: text, 하위: no, 하위남은줄: left ?? [] }); +} + +/** 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 만들고 부모 */ +export async function buildWithKids( + target: HopyoScope, + from: From, + 줄: Body, + 통째?: Whole, +): Promise { + const scope = { layer: from.층, owner: from.주인 }; + const me = await readHopyoOne(scope, from.키, from.번, target); + for (const kid of await unmadeKids(scope, from.키, target, [me])) { + const no = kid.호표.번; + const body = [bodyFor(scope, from.키, kid), wholeFor(scope, from.키, kid)] as const; + await buildHopyo(target, { ...from, 번: no }, ...body).catch((error: unknown) => { + throw kidFail(error, no); + }); + } + return buildHopyo(target, from, 줄, 통째); +} + +/** 하위 실패면 (그 하위 남은 줄 몸, 하위 번) · 아니면 그대로 — 결과 표 [고르기]가 그 하위를 엶 */ +export function leftAt(error: unknown, no: number): [unknown, number] { + const d = (error as { detail?: { 하위?: number; 하위남은줄?: unknown } } | null)?.detail; + return d?.하위 !== undefined ? [{ detail: { 남은줄: d.하위남은줄 } }, d.하위] : [error, no]; +} + +/** 결과 글 꼬리 — 같은 내용 재사용 · 하위 일위대가(사본 담음 · 함께 만듦) */ +export function kidsText(got: HopyoBuilt): string { + const more = got as HopyoBuilt & { + 재사용?: boolean; + 하위?: { 번: number; 키: string | null; 새로?: boolean }[]; + }; + const kids = (more.하위 ?? []).map((k) => { + const head = L("M02_HopyoKid") + .replace("{n}", String(k.번)) + .replace("{key}", k.키 ?? ""); + return ` · ${head}${k.새로 ? ` · ${L("M02_HopyoKidNew")}` : ""}`; + }); + return (more.재사용 ? ` · ${L("M02_HopyoReused")}` : "") + kids.join(""); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index b9316c596..be12de6ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -6,8 +6,8 @@ * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장]. * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 * · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로) - * · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` — - * 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 · + * · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 + * 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] · * 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) * · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 · * 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음 @@ -27,7 +27,6 @@ import { openModal } from "@ui/ui_template_modal"; import { t as L } from "@ui/ui_template_locale"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; import { buildHopyo, buildHopyoAll, @@ -45,7 +44,6 @@ import { type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; import { - bodyOf, draftKey, hasDraft, HOPYO_BUILT, @@ -53,8 +51,8 @@ import { openHopyoCheck, partsOf, POLL_MS, - toTarget, } from "./M02_MasterTemplete_Hopyo_Check"; +import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; @@ -83,7 +81,7 @@ export interface HopyoSheetOptions { } const ILWI = "일위대가"; -const DANSAN = "단가산출"; +const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate) const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => @@ -392,7 +390,8 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { poll(file); if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); await mountSheet(grid); - if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN); + const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행); + if (rows) DANSAN.some((name) => sheet?.select(rows, name)); else if (current !== null) focus(current); } catch (error) { if (!closed) { @@ -413,17 +412,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ], }); - /** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ - function bodyFor(one: HopyoOne): Parameters[2] { - const h = one.호표; - const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); - return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); - } - function wholeFor(one: HopyoOne): Parameters[3] { - const h = one.호표; - const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); - return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; - } + const body = (one: HopyoOne) => bodyPair(opt.scope, opt.key, one); const headCell = (no: number, name: string): HTMLElement => el("td", { text: `제${no}호표 ${name}`.trim() }); @@ -448,11 +437,12 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] })); } } + ones.push(...(await unmadeKids(opt.scope, opt.key, target, ones))); // 777-1 하위 함께 } finally { hideLoadingOverlay(); paintGo(); } - const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); + const ready = ones.filter((one) => readyFor(opt.scope, opt.key, one, nos)); // 하위 함께 for (const one of ones) if (!ready.includes(one)) skipped.push( @@ -488,7 +478,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const cell = headCell(no, h.이름); try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; - const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one)); + const got = await buildHopyo(target, from, ...body(one)); ok += 1; opt.onImport?.(got, h); rows.push( @@ -496,18 +486,16 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { attrs: { "data-built": got.참조.키 }, children: [ cell, - el("td", { text: `${got.참조.키} · ${builtWord(got)}` }), + el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }), el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), ], }), ); } catch (error) { - const left = leftButton(error, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: no }); // prettier-ignore - const text = - left || !(error instanceof StaleError) - ? `실패 — ${why(error)}` - : `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`; + const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄 + const left = leftButton(err, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: kid }); // prettier-ignore + const text = `실패 — ${why(error)}`; rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore } } diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 88dc29975..b011b4701 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -16,3 +16,8 @@ M02 의 엑셀 불러오기에서 경쟁사 2개의 프로그램의 출력을 4. 경쟁사 출력의 일위대가에서 다른 호표의 일위대가를 참조한경우 두개의 일위대가로 가져오는게 맞는것같음. 5. 엑셀을 보여줄때 선택되고 제대로 연결이 되었다면 엑셀보기에도 하이라이트 되는게 어떤지. 위 내용을 정밀하게 조사후 신규 계획서와 신규 요구서 작성 필요 + +## 요구 622 (11-04 · 사용자 · 요구서_621 4장 물음 답 · 778 다시 조사) + +1. (옮기는 기준) 너가 지금 오해를 한게 있는데 우리나 경쟁사의단가산출을 보면 결국 형태가 노무/경비/자재로 구분되어있고 각 그룹의 세부항목은 하나의 별도의 계산 방법이 있다는거지. 그게 모여서 하나의 단가산출이되는거고 이걸 구분해서 사용하면 고객이 넣은 단가산출이든 일위대가든 일위대가의 단가산출이든 쉽게 분석이된단는 생각이야. 다시 조사해봐 +2. (고름 대기 62) 옮기는 기준을 먼저 보고 판단 diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index ea8ddfa99..04e849bec 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -512,7 +512,7 @@ def fill_empty(row: dict, given: dict) -> dict: return {**given, **gap} -def _inputs(row: dict, given: dict) -> dict: +def _inputs(row: dict, given: dict, master=None) -> dict: env = {} for spec in row.get("입력", []): name = spec["이름"] @@ -529,8 +529,8 @@ def _inputs(row: dict, given: dict) -> dict: pass if isinstance(value, (int, float)): value = Decimal(str(value)) - value = mp.resolve(spec, value) # 기계 EQ 키 → 원문 분류번호 - if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value): + value = mp.resolve(spec, value, master) # 기계 EQ 키 → 원문 분류번호 + if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value, master): raise FormulaError(f"입력 「{name}」={value} 고르기 밖") if "범위" in spec: low, high = spec["범위"] @@ -567,7 +567,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: if isinstance(row.get("흐름"), dict): # 흐름 로직 — 깔림에 기대지 않고 스스로 깖 _flow_hooks() return FLOW_RUN(master, ref, row, given) - env = _inputs(row, given) + env = _inputs(row, given, master) for step in row.get("중간", []): try: env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth) diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index b28ff3453..6b9b38599 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -33,22 +33,27 @@ def choosing(picks: dict | None): CHOSEN.reset(token) -def _numbers() -> dict: - return EQ.get().numbers.get("EQ", {}) if EQ.get() else {} +def _eq(master=None): + """기계를 찾을 마스터 — 셈에 넘어온 것(기계 줄 있음) › 지은 문맥의 것(`EQ` · 다른 스레드는 빔).""" + return master if (getattr(master, "numbers", None) or {}).get("EQ") else EQ.get() -def resolve(spec: dict, value): +def _numbers(master=None) -> dict: + return _eq(master).numbers.get("EQ", {}) if _eq(master) else {} + + +def resolve(spec: dict, value, master=None): """기계 입력이 EQ 키로 오면 원문 분류번호로 풂 — 고르기 목록(원문번호)과 같은 꼴.""" - if not (isinstance(value, str) and value.strip()[:2] == "EQ" and EQ.get()): + if not (isinstance(value, str) and value.strip()[:2] == "EQ" and _eq(master)): return value - row = EQ.get().index.get("EQ", {}).get(value.strip()) + row = _eq(master).index.get("EQ", {}).get(value.strip()) number = str(row.get("원문번호") or "") if row else "" - return number if number and machine(spec, number) else value + return number if number and machine(spec, number, master) else value -def machine(spec: dict, value) -> bool: +def machine(spec: dict, value, master=None) -> bool: """기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음.""" - known = _numbers() + known = _numbers(master) picks = spec.get("고르기") or [] return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks) diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index 7030cff19..bf8af6ca4 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -28,7 +28,11 @@ def test_이어서_끝내기_지움_겹침_막음(client): assert _keys(client) == ["HP-000001"] made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json() n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄) - assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"]) + assert ( + n + and n + len(made["실패"]) == 68 + and all(x.get("남은줄") or x.get("하위") for x in made["실패"]) + ) hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음 cache = fit.cache_path(folder, "HP-000001") cache.write_text("{}", encoding="utf-8") diff --git a/resources/tester/test_777_1_hopyo_kids.py b/resources/tester/test_777_1_hopyo_kids.py new file mode 100644 index 000000000..7b3a17a25 --- /dev/null +++ b/resources/tester/test_777_1_hopyo_kids.py @@ -0,0 +1,191 @@ +"""777-1 호표가 다른 호표를 부를 때 = 일위대가 둘(요구 621-4 · `M02_Hopyo_Kids`). + +- 하위 확인 전 부모 저장 = 409 없이 하위 · 부모 둘(하위 = 서버 체크 제안대로 · 부모 줄 = 하위 사본 · 답 `하위`) +- 하위가 남은 줄로 못 만들면 = 409 「하위 제N호표 실패」 · `하위` · `하위남은줄`(부모 · 하위 안 씀) +- 같은 엑셀 두 번([끝내기] 뒤 다시 올림) = 같은 내용 SC 재사용(`본뜬.또`) · SC 안 늚 · 끝내면 그 몫만 뺌 +- 깊이 3 사슬 = 손자 → 자식 → 부모 차례로 · 부모 안 사본 안에 사본 +- 서버 체크 제안 몸(`proposal`) = 화면 `bodyFor` 꼴(체크 줄만 · % = 밑수 고른 것만 · 통째) +층은 임시 폴더(공용 storage 안 씀). +""" + +import json + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Forget as forget +from M02_MasterTemplete import M02_Hopyo_Kids as hk +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_705_3_hopyo_fixed import DOCS, FILES +from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401 + +BUILD = "/api/m02/layers/personal/hopyo-build" +LEFT = "/api/m02/layers/personal/hopyo-unlinked" +GEO = DOCS / FILES["거창"] + + +def _mine(): + return layers.personal_dir(7, 42) + + +def _scs(): + return sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) + + +def _doc(key): + return json.loads((_mine() / "precombo" / f"{key}.json").read_bytes()) + + +def _decided(client, body): + left = client.post(LEFT, json=body).json()["남은줄"] + return {**body, "줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)]} + + +def _geo(client, key="HP-000001"): + assert _post(client, data=GEO.read_bytes(), name=GEO.name).status_code == 200 + fit.run_job(_mine(), key) # 숫자 판정 · 단가산출 맞춤셈(서버 체크 제안이 씀) + + +def test_하위_확인_전_부모_저장_둘_생김(client): + before = master_hash() + _geo(client) + body = _decided(client, {"층": "personal", "키": "HP-000001", "번": 3, "줄": []}) + res = client.post(BUILD, json=body) + assert res.status_code == 200, res.text # 409 「제4호표 먼저 확인」 없음 + got = res.json() + kid = hb.confirmed(_mine())["HP-000001 제4호표"] + assert got["하위"] == [{"번": 4, "키": kid, "새로": True}] + assert hb.confirmed(_mine())["HP-000001 제3호표"] == got["참조"]["키"] != kid + copy = next(x for x in _doc(got["참조"]["키"])["담은로직"] if "참조" in x) + assert copy["참조"]["층"] == "사본" and copy["참조"]["키"] == kid # 부모 줄 = 하위 사본(740-1) + again = client.post(BUILD, json=body).json() # 다시 저장 = 하위 그대로 · 부모 덮음 + assert again["하위"] == [{"번": 4, "키": kid, "새로": False}] and again["다시씀"] is True + assert master_hash() == before + + +def test_하위_실패_까닭_따로_아무것도_안_씀(client): + assert _post(client).status_code == 200 # 북부청 임도 제45호표 → 제46호표(남은 줄 셋) + body = {"층": "personal", "키": "HP-000001", "번": 45, "줄": []} + res = client.post(BUILD, json=body) + assert res.status_code == 409, res.text + why = res.json()["detail"] + assert why["까닭"].startswith("하위 제46호표 실패 — ") and why["하위"] == 46 + assert why["하위남은줄"] and "남은줄" not in why # 부모 [고르기]로 잘못 안 엶 — 하위 몫 + assert hb.confirmed(_mine()) == {} + left = client.post(LEFT, json=body).json()["남은줄"] # 미리 보기 = 하위 호표 줄은 함께 만듦 + assert all(x["갈래"] != "호표" for x in left) + + +def test_같은_엑셀_두_번_SC_안_늚(client): + assert _post(client).status_code == 200 + folder = _mine() + with store.at(folder): + first = hb.build_all(folder, folder, "HP-000001") + made = _scs() + assert first["실패"] == [] and len(made) <= first["호표"] + 3 + keys = {x["번"]: x["키"] for x in first["만듦"]} + forget.finish(folder, "HP-000001") + assert hb.confirmed(folder) == {} # 끝낸 뒤 = 출처만(키 뺌) + up = _post(client).json() + assert up["키"] == "HP-000002" and not up.get("이어서") + with store.at(folder): + second = hb.build_all(folder, folder, "HP-000002") + assert _scs() == made # 같은 내용 = 재사용 · 안 늚 + assert {x["번"]: x["키"] for x in second["만듦"]} == keys + assert (second["같음"], second["다름"]) == (first["같음"], first["다름"]) + shared = [k for k in made if any(x["키"].startswith("HP-000002 ") for x in _doc(k)["본뜬"].get("또", []))] # fmt: skip + assert shared and set(hb.confirmed(folder)) == {hb.mark("HP-000002", n) for n in keys} + forget.finish(folder, "HP-000002") # 그 몫만 뺌 + assert _scs() == made and not any(_doc(k)["본뜬"].get("또") for k in made) + + +def test_깊이_3_사슬(client, monkeypatch): + _geo(client) + folder = _mine() + units = {int(h["번"]): h for h in hs.read(folder, "HP-000001")["호표"]} + deep = { + no: hk.order(units, no) + for no in units + if any(hk.kids_of(units[k]) for k in hk.kids_of(units[no]) if k in units) + } + top = min(deep) + plain = hk.proposal + + def decided(hopyo): # 하위 남은 줄 = 「우리에 없음」(화면 [고르기] 몫을 시험이 대신) + lines, _ = plain({**hopyo, "통째": None}) # 줄로(통째면 하위 줄이 빠짐 — 사슬을 보려고) + body = {"층": "personal", "키": "HP-000001", "번": hopyo["번"], "줄": lines} + return _decided(client, body)["줄"], None + + monkeypatch.setattr(hk, "proposal", decided) + body = _decided(client, {"층": "personal", "키": "HP-000001", "번": top, "줄": []}) + res = client.post(BUILD, json=body) + assert res.status_code == 200, res.text + done = hb.confirmed(folder) + assert all(hb.mark("HP-000001", k) in done for k in deep[top]) # 손자까지 다 만듦 + new = [x["번"] for x in res.json()["하위"] if x["새로"]] + assert new and set(new) <= set(deep[top]) + + def depth(doc): + inner = [ + x["참조"]["몸"] for x in doc.get("담은로직") or [] if (x.get("참조") or {}).get("몸") + ] + return 1 + max((depth(x) for x in inner), default=0) + + assert depth(_doc(done[hb.mark("HP-000001", top)])) == 3 # 부모 안 사본 안에 사본 + + +@pytest.mark.parametrize( + ("view", "want"), + [ + ( + { + "줄": [ + { + "차례": 0, + "맞춤": { + "체크": True, + "상태": "로직", + "대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}], + }, + }, # fmt: skip + { + "차례": 1, + "맞춤": {"체크": False, "상태": "로직", "대상": [{"로직": "GF2", "배": 1}]}, + }, # fmt: skip + {"차례": 2, "맞춤": {"체크": True, "상태": "%", "대상": [{"비율": None}]}}, + { + "차례": 3, + "맞춤": {"체크": True, "상태": "호표", "대상": [{"호표": 4, "배": None}]}, + }, # fmt: skip + { + "차례": 4, + "맞춤": {"체크": True, "상태": "자재", "대상": [{"자재": "MT1", "배": 0}]}, + }, # fmt: skip + { + "차례": 5, + "맞춤": {"체크": True, "상태": "자재", "대상": [{"전국": True, "배": 3}]}, + }, # fmt: skip + ] + }, + ( + [ + {"차례": 0, "대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}]}, + {"차례": 5, "대상": [{"전국": True, "배": 3}]}, + ], + None, + ), + ), + ( + {"줄": [], "통째": {"체크": True, "대상": [{"로직": "GF1", "배": 1}]}}, + ([], {"대상": [{"로직": "GF1", "입력값": {}, "배": 1}]}), + ), + ], +) +def test_서버_체크_제안_몸_화면_짝(view, want): + assert hk.proposal(view) == want diff --git a/resources/tester/test_778_2_hopyo_machine_link.py b/resources/tester/test_778_2_hopyo_machine_link.py new file mode 100644 index 000000000..4fa102c4a --- /dev/null +++ b/resources/tester/test_778_2_hopyo_machine_link.py @@ -0,0 +1,74 @@ +"""못옮김 기계 잇기 = 기계경비 기초 로직 줄(PLAN 778-2 · 분석 621-B 4-2). + +EQ 키 바로 더한줄은 셈 오류 → 사용자 잇기 `{id, 기계: EQ…}` = SK 더한줄 +`{종류: 로직, 로직: 기계경비 기초, 입력값: {기계: 기계 번호}}` · SK 셈 됨(그 기계 시간당 기계경비 × +엑셀 시간 × 배) · 갈래 다른 키 · 없는 키 = LinkError. 고르기 목록 밖 기계 = 서버 둘째 요청 · +지은 문맥 밖 스레드에도 셈 됨(마스터를 셈에 넘김). master 읽기만. +""" + +import threading + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Combine as combine +from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router +from resources.tester.test_m02_hopyo_excel import client # noqa: F401 + +BASE = "GC000267" # 바탕 아무 기초(시간 단위) — 셈만 봄 + + +def _plan(whole) -> skpatch._Plan: + doc = {"키": "SK-000000", "이름": "견줌", "단위": "시간", "바탕": {"로직": BASE}, + "입력값": {}, "덮개": {}, "뺀줄": [], "자재표시": {}, "더한줄": []} # fmt: skip + work = skpatch._Plan(doc, whole) + one = {"id": "기계:굴착기", "갈래": "기계", "이름": "굴착기", "규격": "0.6", "단위": "시간", + "수량": 0.25, "글": "엑셀만 장비 — 기계 못 찾음", "후보": []} # fmt: skip + work.lost = [one, {**one, "id": "기계:굴착기#1"}] + return work + + +def test_기계_잇기_뒤_SK_셈_됨(): + whole = m01.loaded()[1] + work = _plan(whole) + work.link([{"id": "기계:굴착기", "기계": "EQ000026"}]) + add = work.doc["더한줄"][-1] + assert add["종류"] == "로직" and add["로직"] in combine.MACHINE_SET - { + combine.MACHINE["굴착기"] + } + assert add["입력값"] == {skpatch.MACH_IN: "0211-0060"} and "요소" not in add + assert add["수량"] == pytest.approx(0.25 * work.tb) and add["단위"] == "시간" + ran = calc.run(work.doc, whole) # 전 = 「Decimal × dict」 셈 오류 + line = next(x for x in ran["줄"] if x.get("cid") == "add:#0") + assert float(line["금액"]) > 0 + one = {"종류": "로직", "로직": add["로직"], "입력값": add["입력값"], "수량": 1} + hour = calc.run({**work.doc, "바탕": None, "더한줄": [one]}, whole)["기준"] + assert float(hour["경비"]) == 27120 # 굴착기(타이어) 0.6㎥ 시간당 경비(엑셀 14-6 과 같음) + # 무한궤도 = 굴착기(무한궤도) 기계경비 · 갈래 다른 키 · 없는 키 = 틀림 + work.link([{"id": "기계:굴착기#1", "기계": "EQ000019"}]) + assert work.doc["더한줄"][-1]["로직"] == combine.MACHINE["굴착기"] + work.lost = _plan(whole).lost + with pytest.raises(skpatch.LinkError): + work.link([{"id": "기계:굴착기", "기계": "MT000010"}]) + with pytest.raises(skpatch.LinkError): + work.link([{"id": "기계:굴착기", "기계": "EQ999999"}]) + + +def test_목록_밖_기계_서버_둘째_요청에도_셈(client): # noqa: F811 + m01.loaded() # 마스터 = 이 스레드에서 지음 · 요청 = 다른 스레드(문맥 안 따라감) + client.app.include_router(sangeun_router.router) + one = {"종류": "로직", "로직": "GC000268", "입력값": {skpatch.MACH_IN: "0211-0060"}, + "이름": "굴착기(타이어) 0.6㎥", "단위": "시간", "수량": 1} # fmt: skip + doc = {"키": "SK-000000", "이름": "t", "단위": "시간", "바탕": None, "입력값": {}, "덮개": {}, + "뺀줄": [], "자재표시": {}, "더한줄": [one]} # fmt: skip + for _ in range(2): + got = client.post("/api/m02/layers/personal/sangeun/calc", json={"문서": doc}) + assert got.status_code == 200, got.text + assert got.json()["기준"]["경비"] == 27120, got.json().get("까닭") + whole, out = m01.loaded()[1], {} # 지은 문맥 밖 스레드(서버 요청 스레드 풀과 같음) + run = threading.Thread(target=lambda: out.update(calc.run(doc, whole))) + run.start() + run.join() + assert out["기준"] and float(out["기준"]["경비"]) == 27120, out.get("까닭") diff --git a/resources/tester/test_779_2_hopyo_marks.py b/resources/tester/test_779_2_hopyo_marks.py new file mode 100644 index 000000000..2aa2643ea --- /dev/null +++ b/resources/tester/test_779_2_hopyo_marks.py @@ -0,0 +1,78 @@ +"""779-2 불러오기 엑셀 하이라이트 재료 — 목록 `줄상태` 수 = 줄 수 · 상태 = 확인 창 상태 · +`hopyo-marks` 길 읽기만(층 폴더 파일 변화 0) · 블록 판정 = 맞춤셈 캐시 · SK = 만든 것.""" + +import hashlib + +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_hopyo_excel import NORTH, URL, _post, client # noqa: F401 + +# ruff: noqa: F811 +KEY = "HP-000001" + + +def _tree(root) -> dict[str, str]: + return { + str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + +def _ready(client): + res = _post(client, data=NORTH.read_bytes(), name=NORTH.name) + assert res.status_code == 200 and res.json()["키"] == KEY + folder = layers.personal_dir(7, 42) + fit.run_job(folder, KEY) + return folder + + +def test_줄상태_수_줄_수_확인창_같음(client): + _ready(client) + base = URL.format("personal") + whole = client.get(f"{base}/{KEY}").json() + assert whole["호표"] + diff = {} + for h in whole["호표"]: + one = client.get(f"{base}/{KEY}/{h['번']}").json()["호표"] + assert len(h["줄상태"]) == len(one["줄"]) == h["맞춤"][1], h["번"] + for (i, state, check), line in zip(h["줄상태"], one["줄"]): + full = line["맞춤"]["상태"] + if state != full: + diff[(state, full)] = diff.get((state, full), 0) + 1 + assert check == line["맞춤"]["체크"] + assert h["만든"] is None # 확인된 사전조합 전 + assert [x[0] for x in h["줄상태"]] == list(range(len(h["줄상태"]))) + # 목록 = 줄 맞춤 `full=False`(덧 셈 0) — 이름 후보 안 찾은 줄(없음)은 「후보」로 옴 + assert set(diff) <= {("후보", "없음")}, diff + + +def test_marks_읽기만_판정_캐시_같음_SK(client): + folder = _ready(client) + base = URL.format("personal") + before = _tree(folder) + got = client.get(f"{base}-marks/{KEY}") + assert got.status_code == 200, got.text + rows = got.json()["블록"] + assert _tree(folder) == before # 읽기만 — 파일 변화 0 + cache = fit.result(folder, KEY)["블록"] + hp = client.get(f"{base}-grid/{KEY}") # 길 살아 있음(블록 수는 파일에서) + assert hp.status_code == 200 + assert rows and [r["번"] for r in rows] == sorted(r["번"] for r in rows) + judged = [r for r in rows if r["판정"]] + assert judged and all(r["판정"] in ("맞음", "다른 점", "후보", "없음") for r in judged) + assert {r["판정"] for r in judged} <= {e["판정"]["상태"] for e in cache.values()} + assert all(r["SK"] is None and r["갈래"] is None and r["못옮김"] == [] for r in rows) + # [단가산출 만들기] 뒤 = SK · 갈래 · 못옮김 실림 + made = client.post(f"{base}/{KEY}/sangeun") + assert made.status_code == 200, made.text + after = {r["번"]: r for r in client.get(f"{base}-marks/{KEY}").json()["블록"]} + for one in made.json()["만듦"]: + row = after[one["번"]] + assert (row["SK"], row["갈래"], row["못옮김"]) == (one["키"], one["갈래"], one["못옮김"]) + assert sangeun.made(folder, KEY) + snap = _tree(folder) + client.get(f"{base}-marks/{KEY}") + assert _tree(folder) == snap + assert client.get(f"{base}-marks/HP-999999").status_code in (404, 400) diff --git a/resources/tester/test_779_3_grid_fill.py b/resources/tester/test_779_3_grid_fill.py new file mode 100644 index 000000000..92356721f --- /dev/null +++ b/resources/tester/test_779_3_grid_fill.py @@ -0,0 +1,37 @@ +"""779-3 지킴 — 공용 격자 「칠」 옵션(요구 621-5): 안 주면 data-tone 없음 · 주면 보이는 칸만 · 색은 --color-* 만. +화면 수치 판정은 tmp 의 playwright 일회 검증 · 여기서는 길이 빠지지 않았는지만 지킴.""" + +import re +from pathlib import Path + +SS = Path(__file__).parents[2] / "A00_Common/spreadsheet" + + +def read(name: str) -> str: + return (SS / name).read_text(encoding="utf-8") + + +def test_fill_option_flows_option_to_ctx_to_cell(): + assert "칠?:" in read("spreadsheet.ts") and "fill: opts.칠" in read("spreadsheet.ts") + assert "fill?:" in read("spreadsheet_view_types.ts") + + +def test_tone_set_only_in_visible_cell_loop_and_cleared_when_absent(): + cells = read("spreadsheet_grid_cells.ts") + assert cells.count("ctx.fill?.(") == 1 # 칸 그리는 반복(보이는 칸) 안 한 곳뿐 + assert "cellEl.dataset.tone = tone" in cells + assert "delete cellEl.dataset.tone" in cells # 재사용 div — 안 주면 data-tone 없음 + + +def test_repaint_handle_only_rerenders_grid(): + assert re.search(r"repaint:\s*\(\)\s*=>\s*grid\.render\(\)", read("spreadsheet.ts")) + + +def test_css_tones_use_color_tokens_only_and_readonly_hides_add(): + css = read("spreadsheet_grid.css") + tone = css[css.index('[data-tone~="success"]') :] + for name in ("success", "warning", "info", "danger"): + assert f"var(--color-{name}) 18%" in tone + assert "--draw-" not in tone + assert "var(--color-text-muted)" in tone and "var(--color-accent)" in tone + assert ".ss--readonly .ss-tabs__add" in css diff --git a/resources/tester/test_779_grid_views.py b/resources/tester/test_779_grid_views.py new file mode 100644 index 000000000..42a7bed80 --- /dev/null +++ b/resources/tester/test_779_grid_views.py @@ -0,0 +1,74 @@ +"""779-1 격자 세 보기 — 여덟 실무 파일: 보기 셋(두 꼴 같은 역할) · 목록 짝 100 % · 줄 행 대응. + +`M02_Hopyo_Grid.read` 응답 `보기 · 원본 · 목록 · 줄` — 서버 몫만(화면 없음). +""" + +import warnings + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Grid as hg +from resources.tester.helper_m02_hopyo_golden import FILES + +warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") +ROLES = {"일위대가표", "단가산출표", "단가산출상세"} + + +@pytest.fixture(scope="module", params=sorted(FILES)) +def one(request): + path = FILES[request.param] + data = path.read_bytes() + parsed = hx.read_book(path.name, data) + return parsed, hg.read(path.name, data, parsed) + + +def test_보기_셋(one): + parsed, grid = one + assert set(grid["보기"]) == set(grid["원본"]) == ROLES + names = {s["id"]: s["이름"] for s in grid["책"]["시트"]} + assert [names[grid["보기"][r]] for r in ("일위대가표", "단가산출표", "단가산출상세")] == [ + grid["원본"][r] for r in ("일위대가표", "단가산출표", "단가산출상세") + ] + assert list(names) == [grid["보기"][r] for r in ("일위대가표", "단가산출표", "단가산출상세")] + assert len(grid["책"]["시트"]) == 3 + assert parsed["꼴"] in ("엑셀꼴", "STmate") + + +def test_목록_짝_100(one): + parsed, grid = one + for key in ("호표", "단산"): + got = grid["목록"][key] + assert sorted(x["번"] for x in got) == sorted(x["번"] for x in parsed[key]), key + rows = [x["행"] for x in got] + assert len(set(rows)) == len(rows) and min(rows) >= 4 + + +def test_줄_행_대응(one): + parsed, grid = one + lines = [(h["번"], x) for h in parsed["호표"] for x in h["줄"]] + assert [(x["번"], x["차례"], x["행"]) for x in grid["줄"]] == [ + (n, x["차례"], x["행"]) for n, x in lines + ] + span = {h["번"]: h["행"] for h in parsed["호표"]} + block = {b["번"]: b["행"] for b in parsed["단산"]} + for one_, (no, line) in zip(grid["줄"], lines, strict=True): + assert span[no][0] < one_["행"] <= span[no][1] + ref = line.get("참조") or {} + if ref.get("종류") == "단산" and ref["번"] in block: + assert one_["단산"] == {"번": ref["번"], "행": block[ref["번"]]} + else: + assert "단산" not in one_ + if ref.get("종류") == "일위" and ref.get("번") in span: + assert one_["일위"] == {"번": ref["번"], "행": span[ref["번"]]} + else: + assert "일위" not in one_ + assert any("단산" in x for x in grid["줄"]) + + +def test_두_꼴_같은_역할_셋(): + """역할마다 엑셀꼴 · STmate 시트 이름 하나씩 · 탭 차례 = 역할 차례(꼴별로).""" + assert {r: len(n) for r, n in hg.ROLES.items()} == dict.fromkeys(ROLES, 2) + first = [hg.SHEETS.index(n[0]) for n in hg.ROLES.values()] + second = [hg.SHEETS.index(n[1]) for n in hg.ROLES.values()] + assert first == sorted(first) and second == sorted(second) diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index c37f6dc43..10843575f 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -1,6 +1,6 @@ """일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 · 몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 · -재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 · +재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 실패 409(777-1) → 확인 뒤 참조 · 장부 씀 · `본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 · master_data 원본 그대로. 층은 임시 폴더. """ @@ -244,7 +244,10 @@ def test_몸_없음_재검사_400(client): def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): assert _post(client).status_code == 200 ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}] - assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"] + why = _build(client, 45, ref, want=409)[ + "detail" + ] # 777-1 — 하위 먼저 함께(제안대로) · 남은 줄로 실패 + assert why["까닭"].startswith("하위 제46호표 실패") and why["하위"] == 46 and why["하위남은줄"] child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}]) assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3) rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"] diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 39ee22da2..a21a29eb4 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -38,7 +38,7 @@ def _sheet(grid: dict, name: str) -> dict: def test_격자_모양(north): book = north["책"] assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1") - assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"] + assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출총괄표", "단가산출"] sheet = _sheet(north, "일위대가") assert len(sheet["병합"]) == 11 and "B1:O2" in sheet["병합"] assert len(sheet["열"]) == 15 and sheet["열"]["C"]["폭"] == round(7208 / 256, 2) @@ -143,7 +143,11 @@ def test_STmate_격자(): data = path.read_bytes() parsed = hx.read_book(path.name, data) grid = hg.read(path.name, data, parsed) - assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"] + assert [s["이름"] for s in grid["책"]["시트"]] == [ + "일위대가표", + "단가산출근거목록표", + "단가산출근거", + ] cells = _sheet(grid, "일위대가표")["칸"] one = grid["호표"][0] assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]} diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index e2cc9988c..529c15b3e 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -140,7 +140,11 @@ def test_새_상태_체크_제안(client): assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5 assert "특별인부 엑셀 0.3542 · 우리 0.3704" in dig["꼬리표"] ref = _hopyo(client, 45)["고화매트재료비"] - assert ref["상태"] == "호표" and ref["체크"] is False and ref["글"] == "제46호표 먼저 확인" + assert ( + ref["상태"] == "호표" + and ref["체크"] is False + and ref["글"] == "제46호표 — 저장 때 하위로 함께 만듦" + ) # 777-1 assert ref["대상"][0]["호표"] == 46 and ref["대상"][0]["단위"] == "㎡" pct = _hopyo(client, 46)["잡재료비"] assert pct["상태"] == "%" and pct["체크"] is False diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 8d49cad8a..63c0a456a 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -319,7 +319,9 @@ def test_옛_즉시_확정과_바로_만들기_없음(): save = sheet.split("async function save(")[1].split("async function makeAll(")[0] assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save - assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내 + assert ( + "buildOrder(" in sheet and "unmadeKids(" in sheet and "leftAt(" in sheet + ) # 하위 먼저 · 함께(777-1) edit = sheet.split("const editButton =")[1].split("function paintList(")[0] assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit row = sheet.split("function paintList(")[1] diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 7c9678d3c..b3c8429c7 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -297,6 +297,10 @@ export const ui_locales_m2 = { M02_LeftMakeSk: ["SK 만들기", "Make SK"], M02_LeftSkNone: ["만든 단가산출 없음 — 고름 대기 그대로", "Nothing made — still waiting"], M02_LeftChoose: ["고르기", "Pick"], + M02_HopyoKid: ["하위 제{n}호표 {key}(사본 담음)", "Sub sheet {n} {key} (copy inside)"], + M02_HopyoKidNew: ["함께 만듦", "made together"], + M02_HopyoKidFail: ["하위 제{n}호표 실패", "Sub sheet {n} failed"], + M02_HopyoReused: ["같은 내용 일위대가 다시 씀", "Same unit-price reused"], M02_LeftFind: ["찾기", "Find"], M02_LeftMachine: ["기계", "Machine"], M02_LeftNoneTip: [