From 9c43296145f9eb9a3e0dea09d1a51443cb35ac28 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 08:04:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(804-4):=20=EC=A4=84=20=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=20=EB=8B=A4=EB=A5=B8=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=ED=95=B4=20=ED=8E=BC=EC=B9=A8=20=E2=80=94=20M01=20?= =?UTF-8?q?=EC=9D=80=20M01=20=EC=A0=84=EC=B2=B4=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02 단가산출 줄 쌓기 [+ 줄] 「다른 단가산출 · 일위대가 복사」 = 찾기 창 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 막음) - 고름 = 그 문서 전체를 복사해 이 단가산출 항목 · 줄로 펼침(참조 줄 없음 · 원본 연결 없음 · 머리 글 「○○에서 복사」 · 옵션 = 고른 값으로 굳힘 · 기호 겹침 = 새 이름) - 일위대가 = 로직 줄 → M01 문서 × 수량 ÷ 배 · 참조 SK → 그 SK 문서 × 수량 ÷ 배 · 고정 노임 · 자재 · 기계 · 손 단가 · 율(비목 기준) · 못 옮김 = 「⚠」 글 줄 - 율 줄 밑 = 그 조각 줄만(금액번지 복사N) — 앞 줄 · 다른 조각 안 섞임 - 서버 조각 길 GET …/sangeun/{키}/doc/take?src= (M02_Doc_Take) · 화면 넣기 common_util_line_stack_copy.ts - 빈 SK(바탕 · 줄 없음)에 수식 길로 저장한 문서가 빈 씨로 덮이던 것 고침(M02_Doc_Sk.settle 차례) - [단가 갱신] = 사본 값 줄 자원 단가만 지금 M01(구조 · 품 · 식 그대로) - 시험 test_804_4_line_stack_take 6 · helper_804_2 「복사」 할일 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_edit.ts | 12 +- A00_Common/line_stack_pick.ts | 40 ++- A00_Common/line_stack_screen.ts | 19 ++ A00_Common/line_stack_text.ts | 3 + M02_MasterTemplete/M02_Doc_Screen.py | 17 ++ M02_MasterTemplete/M02_Doc_Sk.py | 7 +- M02_MasterTemplete/M02_Doc_Take.py | 259 ++++++++++++++++++ .../M02_MasterTemplete_Sangeun_Tabs.ts | 1 + common_util/common_util_line_stack.ts | 2 +- common_util/common_util_line_stack_copy.ts | 197 +++++++++++++ resources/tester/helper_804_2_line_stack.cjs | 29 ++ .../tester/test_804_4_line_stack_take.py | 202 ++++++++++++++ 12 files changed, 771 insertions(+), 17 deletions(-) create mode 100644 M02_MasterTemplete/M02_Doc_Take.py create mode 100644 common_util/common_util_line_stack_copy.ts create mode 100644 resources/tester/test_804_4_line_stack_take.py diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 3b3e7ed54..ac9904e5e 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -26,7 +26,7 @@ import { type StackItem, } from "../common_util/common_util_line_stack"; import { addPicked, repick } from "../common_util/common_util_line_stack_pick"; -import { openStackPick } from "./line_stack_pick"; +import { openStackPick, type StackTake } from "./line_stack_pick"; import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows"; import { lt, type LsKey } from "./line_stack_text"; @@ -45,6 +45,8 @@ export interface EditHooks { /** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */ pair: (loc: string) => void; saveTpl: (tpl: Tpl) => void; + /** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */ + take?: StackTake; } const field = ( @@ -141,8 +143,10 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { /** [+ 줄] 고르개 값 → 새 줄 */ function addLine(h: EditHooks, at: number, kind: string): void { if (["인력", "재료", "기계", "단가산출"].includes(kind)) - return openStackPick(kind as MoneyKind, (p) => - h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)), + return openStackPick( + kind as MoneyKind, + (p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)), + h.take, ); if (kind.startsWith("tpl:")) { const t = h.templates().find((x) => x.id === kind.slice(4)); @@ -197,7 +201,7 @@ export function buildEdit(h: EditHooks): HTMLElement { { value: "", text: `${lt("Add_Line")} ▾` }, ...(["인력", "재료", "기계", "단가산출", "율"] as MoneyKind[]).map((k) => ({ value: k, - text: lt(KIND_KEY[k]), + text: lt(k === "단가산출" && h.take ? "K_복사" : KIND_KEY[k]), })), { value: "공식", text: lt("K_새공식") }, { value: "틀", text: lt("K_틀") }, diff --git a/A00_Common/line_stack_pick.ts b/A00_Common/line_stack_pick.ts index 4a68d8449..419001e02 100644 --- a/A00_Common/line_stack_pick.ts +++ b/A00_Common/line_stack_pick.ts @@ -1,9 +1,11 @@ /* ============================================================================= * line_stack_pick.ts - * 줄 쌓기 고르기 창(PLAN 804-3 · 요구 678-1 · 678-3) + * 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681) * 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만 * 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직) - * M02 단가산출 · 일위대가 탭은 안 보임 — 수식 문서 연결은 M01 키만 셈 + * M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1) + * M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) — + * 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681) * 표 고르기 = 품셈 표(조건 칸 + 값 칸) · 값 칸 누름 = 그 줄 · 칸 · 지금 닿는 칸 강조 · * 옵션에 매인 조건 = 그 줄만 고를 수 있음(다른 줄은 막힘 + 「위 옵션에서 고름」 안내) * ========================================================================== */ @@ -17,6 +19,8 @@ import { createText } from "@ui/ui_template_text"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import type { MoneyKind } from "../common_util/common_util_line_stack"; import type { Picked } from "../common_util/common_util_line_stack_pick"; +import { showToast } from "@ui/ui_template_elements"; +import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { openFindPicker, type FindKind } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; import { lt } from "./line_stack_text"; @@ -27,17 +31,35 @@ const KINDS: Partial> = { 단가산출: "logic", }; -/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 */ -export function openStackPick(kind: MoneyKind, done: (p: Picked) => void): void { +/** M02 단가산출 복사 길(804-4) — 그 SK 층 · 키 · 복사해 넣기 */ +export interface StackTake { + layer: Layer; + owner: string | null; + me: string; + take: (src: string) => void; +} + +/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 · `m02` 있고 다른 단가산출 = 복사해 펼침 */ +export function openStackPick(kind: MoneyKind, done: (p: Picked) => void, m02?: StackTake): void { const want = KINDS[kind]; if (!want) return; const spec = (e: { 규격: string }) => (e.규격 ? { 규격: e.규격 } : {}); + const copy = kind === "단가산출" && m02 ? m02 : null; openFindPicker({ - layer: "system", - owner: null, - title: `${lt("Find_Title")} — ${lt(`K_${kind}` as never)}`, - kinds: [want], - logic: (x) => done({ 키: x.키, 이름: x.이름, 단위: x.결과단위.replace(/^원\/?/, "") }), + layer: copy?.layer ?? "system", + owner: copy?.owner ?? null, + title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`, + kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1) + ref: copy + ? (r) => + r.키 === copy.me && r.층 === copy.layer && (!copy.owner || r.주인 === copy.owner) + ? showToast(lt("Self_Take"), "warning") + : copy.take(`참조|${r.층}|${r.주인 ?? ""}|${r.키}`) + : undefined, + logic: (x) => + copy + ? copy.take(x.키) + : done({ 키: x.키, 이름: x.이름, 단위: x.결과단위.replace(/^원\/?/, "") }), element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...spec(e) }), machine: (e) => e.로직 && e.원문번호 diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 70ec29ea6..11c7dcaf3 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -35,6 +35,8 @@ import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy" import { docFoot } from "./formula_doc_foot"; import { buildOptions } from "./formula_doc_options"; import { ft } from "./formula_doc_text"; +import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy"; +import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { buildEdit } from "./line_stack_edit"; import { buildOut } from "./line_stack_out"; import type { Tpl } from "./line_stack_rows"; @@ -49,6 +51,8 @@ export interface StackScreenOptions { /** sessionStorage 열쇠 — 수식 탭과 같게(같은 문서 고침을 이어 받음) */ cache: string; copy?: CopyOptions; + /** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */ + scope?: { layer: Layer; owner: string | null }; } type Docs = Record; @@ -230,6 +234,20 @@ export function createStackScreen(opt: StackScreenOptions) { if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }; + /** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [수식 저장] 때 서버가 다시 셈) */ + const takeIn = async (src: string): Promise => { + try { + const got = await call(`/take?src=${encodeURIComponent(src)}`); + change((d, items) => addTaken(d, items, got)); + } catch (e) { + showToast(`${lt("Take_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); + } + }; + const takeCtx = () => + cp && opt.scope && key + ? { ...opt.scope, me: key, take: (src: string) => void takeIn(src) } + : undefined; + // ── 그림 ── const edit = (): boolean => opt.admin && (!!key || !cp); const draw = (): void => { @@ -308,6 +326,7 @@ export function createStackScreen(opt: StackScreenOptions) { templates, pair: (loc) => pair(loc, "left"), saveTpl: (t) => localStorage.setItem(TPL_KEY, JSON.stringify([...templates(), t])), + take: takeCtx(), }), ] : []), diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index c63557a88..e791e236d 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -23,6 +23,9 @@ const TEXT = { K_재료: ["재료", "Material"], K_기계: ["기계", "Machine"], K_단가산출: ["다른 단가산출", "Unit price"], + K_복사: ["다른 단가산출 · 일위대가 복사", "Copy unit price · combo"], + Self_Take: ["지금 단가산출 자신은 못 가져옴", "Cannot copy itself"], + Take_Failed: ["복사 못 함", "Copy failed"], K_율: ["율 · 식", "Rate"], K_공식: ["공식", "Formula"], K_새공식: ["공식 — 새 공식(빈 묶음)", "Formula — new"], diff --git a/M02_MasterTemplete/M02_Doc_Screen.py b/M02_MasterTemplete/M02_Doc_Screen.py index e6ad52a01..c1801479e 100644 --- a/M02_MasterTemplete/M02_Doc_Screen.py +++ b/M02_MasterTemplete/M02_Doc_Screen.py @@ -5,11 +5,14 @@ - 연결 후보 · 표 하나 = M01 수식 문서 화면 것(`M01_MasterData_Doc`) 그대로 — 로그인 · 층 읽기 권한 POST /api/m02/layers/{층}/sangeun/{키}/doc/links {갈래, 검색, 분류} → {후보, 더있음} GET /api/m02/layers/{층}/sangeun/{키}/doc/table?key=<표 키> → 표(조건 · 칸)(M01 길 꼴) +- 줄 쌓기 복사(804-4) — M01 단가산출 · 위 층 SK · SC 를 복사할 조각(`M02_Doc_Take.take`) + GET /api/m02/layers/{층}/sangeun/{키}/doc/take?src= → {이름, 출처, 조각, 계?} ⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). """ from __future__ import annotations +import json from typing import Any from fastapi import APIRouter, Depends @@ -67,3 +70,17 @@ def doc_table( session: Session = Depends(verify_session), ) -> dict: return _call(_root(session, layer, owner), _m01_doc().table, key) + + +@router.get("/layers/{layer}/sangeun/{sk}/doc/take") +def doc_take( + layer: Layer, + sk: str, + src: str, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + from M02_MasterTemplete import M02_Doc_Take as take + + got = _call(_root(session, layer, owner), take.take, src) + return json.loads(json.dumps(dc.ml._norm(got), ensure_ascii=False, default=str)) diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py index 59a56c352..bf114de29 100644 --- a/M02_MasterTemplete/M02_Doc_Sk.py +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -162,9 +162,6 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: out = {k: v for k, v in out.items() if k != KEY} if not own(out): return out - if not skc.has(out): # 빈 SK(바탕 · 줄 없음) = 빈 문서 — 금액 「—」(요구서_664 0 장) - empty = seed(out) - return {**out, KEY: norm(empty)} if empty else out raw = raw if isinstance(raw, dict) else {} mine = SAVING.get() if mine is not None and mine.get("키") == out.get("키"): # 수식 길 저장 — 서버가 지은 사본 @@ -179,6 +176,10 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: ) copy = dc.refresh(have)[0] if job == skc.PRICE else have return {**out, KEY: norm(copy)} + # 빈 SK(바탕 · 줄 없음) = 빈 문서(요구서_664 0 장) · 수식 길로 고친 빈 SK = 위 따로(804-4) + if not skc.has(out): + empty = seed(out) + return {**out, KEY: norm(empty)} if empty else out box: list = [] calc.run(out, whole, catch=box) calls = {} if job == skc.REGEN else (have or {}).get("부름") or {} diff --git a/M02_MasterTemplete/M02_Doc_Take.py b/M02_MasterTemplete/M02_Doc_Take.py new file mode 100644 index 000000000..71693255b --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Take.py @@ -0,0 +1,259 @@ +"""M02 줄 쌓기 — 다른 단가산출 · 일위대가 복사해 펼침(PLAN 804-4 · 요구 680-1 · 681) — 서버 단독. + +M02 단가산출(SK) 줄 쌓기에서 M01 단가산출 · M02 단가산출 · M02 일위대가(SC)를 고름 = 그 문서 전체를 복사해 +이 단가산출의 항목 · 줄로 펼침(참조 줄 「단산 N 참조」 없음 · 원본과 연결 없음) — 이 길은 복사할 조각만 줌, +문서에 넣는 것은 화면(`common_util_line_stack_copy.ts` · 옵션 = 고른 값으로 굳힘 · 기호 겹침 = 새 이름). +조각(`take`) — {갈래: 문서, 문서, 입력(옵션 값), 곱(수량 식 · 없으면 그대로), 이름} | + {갈래: 인력 · 재료 · 기계, 고른, 수량} | {갈래: 율 · 손, 줄} | {갈래: 글, 글}(못 옮김 = 「⚠」) + M01 단가산출 = 지금 M01 문서 · SK = 그 SK 수식 사본 문서(`M02_Doc_Sk_Path.current`) · + SC = 담은로직 줄마다(로직 = M01 문서 × 수량 ÷ 배 · 참조 SK = 그 SK 문서 × 수량 ÷ 그 배 · + 참조 SC = 그 안 조각 × 수량 · 고정 = 인력(노임) · 재료(자재 · 유가) · 기계(시간당 경비 로직) · 손 단가 · + 율(비목 기준 · 끝에 — 밑 = 앞 줄 누계)) + 자재 = 재료 줄. +M01 단가산출은 원천 기준값 — M01 줄 쌓기는 이 길이 없음(M01 찾기 창 = M01 전체만). +[단가 갱신] = 사본 값 줄(자원 단가)만 지금 M01(`M02_Doc_Copy.refresh` 그대로 · 구조 · 품 · 식 그대로). +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +COSTS = ("노무비", "재료비", "경비") +_LAYERS = ("system", "company", "personal") +DEPTH = refs.DEPTH + + +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + +def parse(src: Any) -> dict | None: + """`참조|층|주인|키`(`refs.ref_id` 꼴 · SK · SC) → {층, 주인, 키} · 아니면 None(M01 키).""" + parts = str(src or "").split("|") + if len(parts) != 4 or parts[0] != "참조" or parts[1] not in _LAYERS: + return None + _, layer, owner, code = parts + if not refs._KEY.match(code) or code[:2] not in ("SK", "SC"): + return None + if layer != "system" and not refs._OWNER[layer].match(owner): + return None + return {"층": layer, "주인": owner or None, "키": code} + + +def _text(v: Any) -> str: + return format(Decimal(str(v)).normalize(), "f") + + +def _times(q: str | None, more: str | None) -> str | None: + """수량 식 곱 — 없음 · 1 = 그대로.""" + q, more = (None if x in (None, "1") else x for x in (q, more)) + if q is None or more is None: + return q if more is None else more + return f"({q}) × ({more})" + + +def _warn(text: str) -> dict: + return {"갈래": "글", "글": f"⚠ {text}"} + + +def _plain(given: dict) -> dict: + out = {} + for k, v in (given or {}).items(): + if isinstance(v, Decimal): + v = int(v) if v == v.to_integral_value() else float(v) + out[str(k)] = v + return out + + +# ── 문서 하나 ───────────────────────────────────────────────────────── +def _m01(code: str, given: Any = None, factor: str | None = None) -> dict: + from M02_MasterTemplete import M02_Doc_Copy as dc + + whole = dc.skc.whole_() + doc = dc.doc_of(whole, code) + if doc is None: + return _warn(f"수식 문서 없는 단가산출 「{code}」") + values = dc.given_of(whole, code, given) if given is not None else {} + piece = {"갈래": "문서", "문서": doc, "입력": _plain(values), "이름": doc.get("이름") or code} + factor = _times(factor, None) + return {**piece, "곱": factor} if factor else piece + + +def _sk(ref: dict, factor: str | None = None, per_unit: bool = False) -> list[dict]: + from M02_MasterTemplete import M02_Doc_Sk_Path as sp + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + raw = refs.read_ref(ref) + if not isinstance(raw, dict): + return [_warn(f"없는 단가산출 「{ref['키']}」")] + try: + with store.at(refs.folder_of(ref["층"], ref.get("주인"))): + copy = sp.current(raw) + if per_unit: # 일위대가 안 SK = 1 단위(기준 ÷ 배) + clean, whole = sp._settled(raw) + times = Decimal(str(calc.run(clean, whole).get("배") or 1)) + if times != 1: + factor = _times(factor, f"1 ÷ {_text(times)}") + except Exception as e: # noqa: BLE001 — 가져온 식 · 바탕 참조 SK 등 = 못 복사(까닭 글) + why = getattr(e, "detail", None) or str(e) + return [_warn(f"단가산출 「{raw.get('이름') or ref['키']}」 못 복사 — {why}")] + doc = copy["문서"] + out = [{"갈래": "문서", "문서": doc, "입력": {}, "이름": raw.get("이름") or ref["키"]}] + factor = _times(factor, None) + if factor: + out[0]["곱"] = factor + if copy.get("끊음"): + out.append(_warn(f"「{raw.get('이름')}」 안에서 끊어 고친 부름 문서는 지금 M01 판으로 셈")) + return out + + +# ── 일위대가(SC) ────────────────────────────────────────────────────── +def _pick(kind: str, qty: str | None, **picked: Any) -> list[dict]: + one = {"갈래": kind, "고른": {k: v for k, v in picked.items() if v not in (None, "", {})}} + if qty is None: + return [{**one, "수량": "0"}, _warn(f"{picked.get('이름')} — 수량 없음(0 으로 넣음)")] + return [{**one, "수량": qty}] + + +def _fixed(fx: dict, qty: str | None, rates: list[dict]) -> list[dict]: + from M02_MasterTemplete import M02_Machine_Hourly as mh + + name, spec, unit = fx.get("이름") or "", fx.get("규격") or "", fx.get("단위") or "" + rate, link = fx.get("비율"), fx.get("이음") or {} + if rate: + if rate.get("기준") not in COSTS: + return [ + _warn(f"율 줄 「{name}」 — 기준 「{rate.get('기준')}」 는 줄 쌓기 율로 못 옮김") + ] + line = {"이름": name, "단위": "식", "비목": rate.get("비목") or "경비"} + rates.append({"갈래": "율", "줄": {**line, "단가": f"{rate['기준']} × {qty or 0} %"}}) + return [] + if link.get("노임"): + return _pick("인력", qty, 키=link["노임"], 이름=name, 규격=spec, 단위="인") + if link.get("자재") or link.get("유가"): + code = link.get("자재") or link.get("유가") + return _pick("재료", qty, 키=code, 이름=name, 규격=spec, 단위=unit) + if link.get("기계"): + got = mh.hourly(str(link["기계"])) + if not got.get("ok") or not got.get("로직"): + return [_warn(f"기계 「{name}」 — {got.get('reason') or '시간당 경비 못 셈'}")] + machine = {"기계": f"'{got['원문번호']}'"} + return _pick("기계", qty, 키=got["로직"], 이름=name, 규격=spec, 입력=machine) + price = fx.get("단가") or {} + costs = [c for c in COSTS if Decimal(str(price.get(c) or 0))] or ["재료비"] + return [ + { + "갈래": "손", + "줄": { + "이름": name, + **({"규격": spec} if spec else {}), + "단위": unit, + "비목": c, + "수량": qty or "0", + "단가": _text(price.get(c) or 0), + }, + } # fmt: skip + for c in costs + ] + + +def _scaled(pieces: list[dict], factor: str | None) -> list[dict]: + """안 SC 조각 × 수량 — 문서 = 곱 · 줄 = 수량 식 · 율 = 그대로(밑이 이미 곱해짐).""" + if factor is None: + return pieces + out = [] + for p in pieces: + if p["갈래"] == "문서": + out.append({**p, "곱": _times(p.get("곱"), factor)}) + elif p["갈래"] in ("인력", "재료", "기계"): + out.append({**p, "수량": _times(p["수량"], factor)}) + elif p["갈래"] == "손": + out.append({**p, "줄": {**p["줄"], "수량": _times(p["줄"]["수량"], factor)}}) + else: + out.append(p) + return out + + +def _combo(ref: dict, trail: tuple[str, ...] = ()) -> list[dict]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + rid = refs.ref_id(ref) + if rid in trail or len(trail) >= DEPTH: + return [_warn(f"일위대가 참조 돌고 돎 · 너무 깊음 「{ref['키']}」")] + doc = refs.read_ref(ref) + if not isinstance(doc, dict): + return [_warn(f"없는 일위대가 「{ref['키']}」")] + with store.at(refs.folder_of(ref["층"], ref.get("주인"))): + ans = labor.calc(doc, {}) + out, rates = [], [] + for one, got in zip(doc.get("담은로직") or [], ans.get("줄") or []): + qty = None if got.get("수량") is None else _text(got["수량"]) + if isinstance(one.get("고정"), dict): + out += _fixed(one["고정"], qty, rates) + elif isinstance(one.get("참조"), dict): + sub = one["참조"] + if qty is None: + out.append(_warn(f"「{got.get('이름') or sub.get('키')}」 — 수량 없음(안 옮김)")) + elif str(sub.get("키", "")).startswith("SK"): + out += _sk(sub, qty, per_unit=True) + elif str(sub.get("키", "")).startswith("SC"): + out += _scaled(_combo(sub, (*trail, rid)), qty) + else: + out.append(_warn(f"옛 단가승격 「{sub.get('키')}」 — 줄 쌓기로 못 옮김")) + elif one.get("패치"): + out.append(_warn(f"줄 덮개로 고친 로직 「{one.get('로직')}」 — 줄 쌓기로 못 옮김")) + else: + times, r = Decimal(str(got.get("배") or 1)), one.get("켜쌓기율") + q = qty if qty is None or times == 1 else f"{qty} ÷ {_text(times)}" + if q is not None and r not in (None, 100): + q = f"({q}) × {r} ÷ 100" + if q is None: + out.append(_warn(f"로직 「{one.get('로직')}」 — 수량 없음(안 옮김)")) + continue + out.append(_m01(str(one.get("로직") or ""), one.get("입력값") or {}, q)) + shown = (one.get("자재표시") or {}).values() + if one.get("고름") or any(labor.apart(x) for x in shown): + out.append(_warn(f"「{one.get('로직')}」 고른 자재 · 별도계상 표시는 안 옮김")) + whole = labor.m01.loaded()[1] + for one, got in zip(doc.get("자재") or [], ans.get("자재") or []): + code = str(one.get("코드") or "") + try: + row = whole.get(code) or {} + except Exception: # noqa: BLE001 — 지워진 자재 = 코드만 + row = {} + name = row.get("이름") or code + if labor.apart(one): + out.append(_warn(f"별도계상 자재 「{name}」 — 합에서 빠지는 줄이라 안 옮김")) + continue + qty = None if got.get("수량") is None else _text(got["수량"]) + unit = one.get("단위") or row.get("단위") + out += _pick("재료", qty, 키=code, 이름=name, 규격=row.get("규격"), 단위=unit) + return out + rates + + +# ── 길 ──────────────────────────────────────────────────────────────── +def take(src: str) -> dict[str, Any]: + """복사할 조각 — {이름, 출처, 조각, 계?(일위대가 반영합 · 대조)}.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + ref = parse(src) + if ref is None: # M01 단가산출 + piece = _m01(str(src or "")) + if piece["갈래"] != "문서": + raise _bad(piece["글"][2:], 404) + return {"이름": piece["이름"], "출처": f"M01 {src}", "조각": [piece]} + if not refs.readable(refs.here(), ref): + raise _bad("위 층(마스터 · 내 회사 · 나) 문서만 복사", 403) + doc = refs.read_ref(ref) + if not isinstance(doc, dict): + raise _bad(f"없는 문서 「{ref['키']}」", 404) + layer = {"system": "마스터", "company": "회사", "personal": "개인"}[ref["층"]] + head = {"이름": str(doc.get("이름") or ref["키"]), "출처": f"{layer} {ref['키']}"} + if ref["키"].startswith("SK"): + return {**head, "조각": _sk(ref)} + with store.at(refs.folder_of(ref["층"], ref.get("주인"))): + total = (labor.calc(doc, {}).get("반영합") or {}).get("계") + return {**head, "단위": doc.get("단위"), "조각": _combo(ref), "계": total} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 2e56b5060..9e076a896 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -57,6 +57,7 @@ export function createSangeunTabs( admin: opt.edit, cache: "m02_sk_doc_drafts", copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved }, + scope: opt.scope, // 804-4 다른 단가산출 · 일위대가 복사 }); let key = ""; let view: View = "equation"; diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index e81459634..107d19070 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -287,7 +287,7 @@ export function notesOf(doc: FormulaDoc): string[] { if (doc.단계?.length) out.push("단계"); if (doc.조정 && Object.keys(doc.조정).length) out.push("조정"); const money = doc.금액 ?? []; - if (money.some((l) => l.번지)) out.push("번지"); + if (money.some((l) => l.번지 && !/^복사\d+$/.test(l.번지))) out.push("번지"); // 복사N = 복사해 펼친 조각(804-4) if (money.some((l) => l.합밖)) out.push("합밖"); if (money.some((l) => l.자리)) out.push("줄 자리"); if (money.some((l) => l.기호)) out.push("금액 줄 기호"); diff --git a/common_util/common_util_line_stack_copy.ts b/common_util/common_util_line_stack_copy.ts new file mode 100644 index 000000000..c883d2bb2 --- /dev/null +++ b/common_util/common_util_line_stack_copy.ts @@ -0,0 +1,197 @@ +/* ============================================================================= + * common_util_line_stack_copy.ts + * 줄 쌓기 복사해 펼침(PLAN 804-4 · 요구 680-1 · 681) — 화면 · DOM · fetch 없음(시험이 Node 로 돌림) + * M02 단가산출 줄 쌓기에서 M01 단가산출 · M02 단가산출 · M02 일위대가를 고름 = 서버가 준 조각 + * (`M02_Doc_Take.take`)을 이 문서의 항목 · 줄로 넣음 — 원본과 연결 없음 · 머리 글 「○○에서 복사」 + * 문서 조각 = 그 문서 변수 · 식 · 금액 · 차림 전체(기호 겹침 = 아래첨자 끝 수 · 옵션 = 고른 값으로 굳힘 · + * 곱 = 금액 줄 수량 × 곱(율 줄 = 그대로 — 밑이 곱해짐)) + * 율 줄 밑(`노무비` 등 앞 줄 누계)은 그 조각 줄만 — 조각마다 금액번지 `복사N` 에 쌓고 `번지.복사N.노무비` 로 바꿈 + * (앞에 있던 줄 · 다른 조각이 섞이지 않음) · 일위대가 율 줄 밑 = 그 일위대가 조각 번지 합 + * 줄 조각 = 인력 · 재료 · 기계(`addPicked` + 수량) · 손 단가 · 율 = 금액 줄 · 글 = 글 줄(못 옮김 「⚠」) + * ========================================================================== */ + +import { + names, + parse, + textOf, + varId, + type DocVar, + type FormulaDoc, + type MoneyLine, + type Node, +} from "./common_util_formula_doc"; +import { + isOption, + nodeOf, + renameSym, + stackOf, + symbols, + type MoneyKind, + type StackDoc, + type StackItem, + type StackLine, +} from "./common_util_line_stack"; +import { addPicked, type Picked } from "./common_util_line_stack_pick"; +import { splitId, symId } from "./common_util_formula_doc"; + +export type TakePiece = + | { 갈래: "문서"; 문서: FormulaDoc; 입력?: Record; 곱?: string; 이름?: string } + | { 갈래: "인력" | "재료" | "기계"; 고른: Picked; 수량: string } + | { 갈래: "율" | "손"; 줄: MoneyLine & { 규격?: string } } + | { 갈래: "글"; 글: string }; +export interface Taken { + 이름: string; + 출처: string; + 조각: TakePiece[]; + 계?: number | null; +} + +const COSTS = ["노무비", "재료비", "경비"]; +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +const PLACE = "복사"; + +/** 앞 줄 누계(노무비 · 재료비 · 경비) → 그 번지들 합 */ +function scoped(n: Node, places: string[]): Node { + if (n[0] === "var" && COSTS.includes(n[1])) { + const one = (p: string): Node => ["field", ["field", ["var", "번지"], p], n[1]] as Node; + return places.slice(1).reduce((a, p) => ["bin", "+", a, one(p)] as Node, one(places[0])); + } + if (!Array.isArray(n)) return n; + return n.map((x) => (Array.isArray(x) ? scoped(x as Node, places) : x)) as unknown as Node; +} +const usesRunning = (t: unknown): boolean => { + const n = nodeOf(t); + return !!n && names(n).some((x) => COSTS.includes(x)); +}; +const rescope = (t: string | null | undefined, places: string[]): string | null | undefined => { + if (!places.length || !usesRunning(t)) return t; + return textOf(scoped(parse(t), places)); +}; + +function freshPlace(doc: FormulaDoc): string { + const have = new Set((doc.금액 ?? []).map((l) => String(l.번지 ?? ""))); + for (let n = 1; ; n++) if (!have.has(`${PLACE}${n}`)) return `${PLACE}${n}`; +} + +/** 옵션 → 고른 값으로 굳힘(열쇠 지움 · 수 = 숫자 · 글 = 식 「'글'」) */ +function freeze(s: FormulaDoc, given: Record): void { + for (const v of s.변수 ?? []) { + if (!isOption(v)) continue; + const link = v.연결 ?? {}; + const key = String(v.열쇠); + let val: unknown = key in given ? given[key] : link.값; + const list = (link.목록 ?? []) as unknown[]; + if (link.갈래 === "선택") { + const hit = list.find((x) => String(x) === String(val)); + val = hit !== undefined ? hit : val === undefined || val === null ? list[0] : val; + } + delete (v as DocVar & { 열쇠?: string }).열쇠; + if (typeof val === "number") v.연결 = { 갈래: "숫자", 값: val }; + else if (val === undefined || val === null || val === "") v.연결 = { 갈래: "숫자", 값: "" }; + else { + v.연결 = { 갈래: "식" }; + (s.식 ??= []).push({ 기호: varId(v), 식: `'${String(val).replace(/'/g, "")}'` }); + } + } +} + +/** 문서 조각 하나 → 이 문서에 붙임 · 돌려줌 = 그 조각 항목들(금액 차례 = 이 문서) · 번지 이름 */ +export function copyDoc( + doc: StackDoc, + src: FormulaDoc, + given: Record = {}, + factor?: string, +): { items: StackItem[]; place: string | null } { + const s = clone(src) as StackDoc; + const mine = new Set([ + ...symbols(doc), + ...(doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean), + ]); + const taken = new Set([ + ...mine, + ...symbols(s), + ...(s.금액 ?? []).map((l) => String(l.기호 ?? "")), + ]); + const ids = [...symbols(s), ...(s.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)]; + for (const id of ids) { + if (!mine.has(id)) continue; + const [sg, lo, up] = splitId(id); + let n = 2; + while (taken.has(symId(sg, `${lo}${n}`, up))) n++; + renameSym(s, id, symId(sg, `${lo}${n}`, up)); + taken.add(symId(sg, `${lo}${n}`, up)); + } + freeze(s, given); + const money = s.금액 ?? []; + const place = money.length ? freshPlace(doc) : null; + for (const l of money) { + const running = usesRunning(l.단가) || usesRunning(l.수량); + if (place) { + l.단가 = rescope(l.단가, [place]) as string; + l.수량 = rescope(l.수량, [place]); + l.번지 = place; + } + if (factor && !running) l.수량 = l.수량 ? `(${l.수량}) × (${factor})` : factor; + } + const items = stackOf({ ...s, 금액: money }); // 원본 차림(쌓기 · 묶음) 그대로 — 금액 차례 = s + const at = (doc.금액 ??= []).length; + doc.금액.push(...money); + (doc.변수 ??= []).push(...(s.변수 ?? [])); + (doc.식 ??= []).push(...(s.식 ?? [])); + const shift = (l: StackLine): StackLine => (l.꼴 === "금액" ? { 꼴: "금액", i: l.i + at } : l); + return { items: items.map((it) => ({ ...it, 줄: it.줄.map(shift) })), place }; +} + +/** 받은 조각 전부 → 새 항목(제목 = 이름 · 첫 줄 = 「출처에서 복사」) · 제목 있는 조각 항목 = 가나다 */ +export function addTaken(doc: StackDoc, items: StackItem[], t: Taken): void { + const head: StackItem = { + 제목: t.이름, + 줄: [{ 꼴: "글", 글: `${t.출처} 「${t.이름}」에서 복사` }], + }; + items.push(head); + let cur = head; + let inDoc = false; // 지금 항목 = 문서 조각 항목(뒤 줄 조각은 새 항목) + const places: string[] = []; + let lone: string | null = null; // 줄 조각(인력 · 재료 · 기계 · 손) 번지 — 율 밑에 듦 + const pushMoney = (line: MoneyLine): void => { + const n = (doc.금액 ??= []).length; + doc.금액.push(line); + cur.줄.push({ 꼴: "금액", i: n }); + }; + const rates: MoneyLine[] = []; + for (const p of t.조각) { + if (p.갈래 === "문서") { + const got = copyDoc(doc, p.문서, p.입력 ?? {}, p.곱); + if (got.place) places.push(got.place); + const many = got.items.length > 1 || got.items.some((it) => it.제목); + if (t.조각.filter((x) => x.갈래 === "문서").length > 1 || many) { + const name = p.이름 ?? ""; + got.items.forEach((it, k) => { + const title = k || !name ? it.제목 : it.제목 ? `${name} · ${it.제목}` : name; + items.push({ ...it, 제목: title, 가나다: true }); + }); + cur = items[items.length - 1]; + inDoc = true; + } else got.items.forEach((it) => cur.줄.push(...it.줄)); + } else if (p.갈래 === "글") cur.줄.push({ 꼴: "글", 글: p.글 }); + else if (p.갈래 === "율") rates.push({ ...p.줄 }); + else { + if (inDoc) items.push((cur = { 제목: "", 가나다: true, 줄: [] })); + inDoc = false; + lone ??= freshPlace(doc); + if (!places.includes(lone)) places.push(lone); + if (p.갈래 === "손") pushMoney({ ...p.줄, 번지: lone }); + else if ("고른" in p) { + const kind = p.갈래 as MoneyKind; + addPicked(doc, cur, kind, p.고른); + const line = doc.금액![doc.금액!.length - 1]; + line.수량 = p.수량; + line.번지 = lone; + } + } + } + if (rates.length) { + cur = items[items.length - 1]; + for (const r of rates) pushMoney({ ...r, 단가: rescope(r.단가, places) as string }); + } +} diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index ed6d86bbc..91b8c7d28 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -162,6 +162,34 @@ function repickOne({ 문서, 줄, 기호, 갈래, 고른 }) { return doc; } +/* 804-4 복사해 펼침 — common_util_line_stack_copy.ts · 받음 = 서버 조각(차례로 넣음) */ +const cp = util("common_util_line_stack_copy"); +function takeIn({ 문서, 받음 }) { + const doc = JSON.parse(JSON.stringify(문서)); + const items = ls.stackOf(doc); + const was = items.length; + for (const t of 받음) cp.addTaken(doc, items, t); + const laid = ls.docOf(doc, items); + const again = ls.stackOf(laid); + return { + 문서: laid, + 항목: again.slice(was).map((it) => ({ + 제목: it.제목, + 가나다: !!it.가나다, + 줄: it.줄.map((l) => + l.꼴 === "금액" + ? `금액:${laid.금액[l.i].이름}` + : l.꼴 === "글" + ? `글:${l.글}` + : `공식:${l.기호.length}`, + ), + })), + 옵션: (laid.변수 || []).filter((v) => ls.isOption(v)).map(idOf), + 못: ls.notesOf(laid), + 왕복: JSON.stringify(ls.docOf(laid, again)) === JSON.stringify(laid), + }; +} + const input = JSON.parse(fs.readFileSync(0, "utf8")); const job = { 왕복: roundTrip, @@ -170,5 +198,6 @@ const job = { 표고름: tablePick, 넣기: putPicked, 바꿈: repickOne, + 복사: takeIn, }[input.할일]; process.stdout.write(JSON.stringify(input.문서들.map(job))); diff --git a/resources/tester/test_804_4_line_stack_take.py b/resources/tester/test_804_4_line_stack_take.py new file mode 100644 index 000000000..af2dc1d92 --- /dev/null +++ b/resources/tester/test_804_4_line_stack_take.py @@ -0,0 +1,202 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""PLAN 804-4 — 줄 쌓기 다른 단가산출 · 일위대가 복사해 펼침(요구 680-1 · 681) — 임시 storage. + +① M01 줄 쌓기 = M01 전체만(찾기 창 탭 · 복사 길 없음) · M01 문서 길은 M02 키를 모름 +② M01 단가산출 복사 = 전체 항목 · 줄 · 옵션 굳힘(옵션 0) · 금액 = 원본 · 원본 연결 없음 +③ M02 SK 복사 = 그 SK 수식 문서 금액 · 항목 그대로 +④ 일위대가(SC) 복사 = 항목 여러 개 · 금액 = 일위대가 계 · 고정(노임 · 손 단가) · 율 · 안 SK(× 수량 ÷ 배) +⑤ 앞 줄 있는 문서에 넣음 = 앞 금액 + 원본(율 밑 = 그 조각만 · 끝수 1 원) +⑥ 권한 — 위 층만(남의 개인 층 403) · 저장 = 서버 정본 셈 같음 · M01 노임 바뀜 → [단가 갱신] 뒤 따라감 +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Doc_Take as tk +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_795_1_sk_copy import _fake, _live, _sk +from resources.tester.test_795_2_sc_copy import _sc +from resources.tester.test_804_2_line_stack import _ts +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +FORM = "GC000211" # 합판거푸집(옵션 많음) +DIG = "GF000099" # 측구터파기(굴착기 시간당 경비 부름) +STONE = "GF000219" # 원/㎡ +LB = "LB000002" # 보통인부 +ME = {"층": "personal", "주인": "7/42"} + + +def _norm(v): + return json.loads(json.dumps(dc.ml._norm(v), ensure_ascii=False, default=str)) + + +def _take(folder, src): + with store.at(folder): + return _norm(tk.take(src)) + + +def _into(doc, *taken): + return _ts("복사", [{"문서": doc, "받음": list(taken)}])[0] + + +def _money(doc): + d = json.loads( + json.dumps(dc.ml._norm(doc), default=str), parse_float=Decimal, parse_int=Decimal + ) + return dc.mc.calc(d, dc.ml.Links(_live(), dc.folder())) + + +def _rid(key, at=ME): + return refs.ref_id({**at, "키": key}) + + +def _src(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_M01_은_M01전체만(): + pick = _src("A00_Common/line_stack_pick.ts") + assert 'kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)' in pick + assert 'const copy = kind === "단가산출" && m02 ? m02 : null;' in pick + lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") + assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab + tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") + assert "scope: opt.scope, // 804-4" in tabs + screen = _src("A00_Common/line_stack_screen.ts") + assert "cp && opt.scope && key" in screen # 복사 길 = M02 저장한 SK 만 + # M01 문서 길 = M02 키 모름(부르면 「없는 단가산출」) + doc = dc.md.blank("X", "x") + doc["변수"] = [ + {"기호": "B", "아래": "", "위": "", "연결": {"갈래": "단가산출", "키": _rid("SK-000001")}} + ] + doc["금액"] = [{"이름": "남", "비목": "묶음", "수량": "1", "단가": "B"}] + got = dc.mc.calc(doc, dc.ml.Links(_live(), dc.folder())) + assert not got["셈됨"] and "없는 단가산출" in json.dumps(got["표지"], ensure_ascii=False) + + +def test_M01단가산출_복사_전체_옵션굳힘(world): + me = world["personal"] + t = _take(me, FORM) + assert t["출처"] == f"M01 {FORM}" and [p["갈래"] for p in t["조각"]] == ["문서"] + g = _into(dc.md.blank("SK-000009", "빈"), t) + assert g["옵션"] == [] and g["왕복"] and "번지" not in g["못"] + assert g["항목"][0]["줄"][0] == f"글:M01 {FORM} 「{t['이름']}」에서 복사" + lines = [x for it in g["항목"] for x in it["줄"]] + assert len(lines) > 5 and any(x.startswith("공식:") for x in lines) # 공식 묶음까지 전체 + assert _money(g["문서"])["계"] == _money(dc.folder()[FORM])["계"] + # 원본 연결 없음 — 복사본에 원본 키 단가산출 연결 없음 · 기호 모두 이 문서 + keys = {(v.get("연결") or {}).get("키") for v in g["문서"]["변수"]} + assert FORM not in keys + + +def test_SK_복사_그_SK_금액(world): + me = world["personal"] + key = _sk(me, 바탕={"로직": FORM}, 입력값={"유형": "복잡", "사용횟수": "3회"}) + with store.at(me): + want = _norm(path.read(key))["셈"]["계"] + t = _take(me, _rid(key)) + assert t["출처"] == f"개인 {key}" and t["조각"][0]["갈래"] == "문서" + g = _into(dc.md.blank("SK-000009", "빈"), t) + assert g["옵션"] == [] and _money(g["문서"])["계"] == want + # 옵션 「복잡」 그대로 굳힘 + assert any(x.get("식") == "'복잡'" for x in g["문서"]["식"]) + + +def _combo(world): + me = world["personal"] + sk = _sk(me, 바탕={"로직": STONE}) + fixed = {"이름": "보통인부", "단위": "인", "단가": {"노무비": 0}, "이음": {"노임": LB}} + hand = {"이름": "sub3임시 잡자재", "단위": "식", "단가": {"재료비": 1234}, "이음": {}} + rate = {"이름": "공구손료", "단위": "%", "단가": {}, "비율": {"기준": "노무비", "비목": "경비"}} + lines = [ + {"로직": DIG, "수량": {"손": 2}}, + {"로직": "", "고정": fixed, "수량": {"손": 0.3}}, + {"로직": "", "고정": hand, "수량": {"손": 2}}, + {"참조": {**ME, "키": sk}, "수량": {"손": 3}}, + {"로직": "", "고정": rate, "수량": {"손": 3}}, + ] + return me, sk, _sc(me, lines) + + +def test_일위대가_복사_여러항목_금액같음(world): + me, sk, sc = _combo(world) + t = _take(me, _rid(sc)) + kinds = [p["갈래"] for p in t["조각"]] + assert kinds == ["문서", "인력", "손", "문서", "율"], kinds + assert t["조각"][0]["곱"] == "2" and t["조각"][3]["곱"] == "3" + assert t["조각"][4]["줄"]["단가"] == "노무비 × 3 %" + g = _into(dc.md.blank("SK-000009", "빈"), t) + assert g["옵션"] == [] and g["왕복"] and "번지" not in g["못"] + titles = [it["제목"] for it in g["항목"]] + assert titles[0] == t["이름"] and len(g["항목"]) >= 4 # 머리 + 문서 둘 + 줄 조각 항목 + got = float(_money(g["문서"])["계"]) + # 일위대가 계 — 끝수 차례만 다름(원본 = 문서 계를 버린 뒤 × 수량 · 복사 = 줄마다 × 수량 뒤 버림) + assert got == pytest.approx(float(t["계"]), abs=10) + # 율 밑 = 그 일위대가 번지 합(앞 줄 섞임 없음) + rate = g["문서"]["금액"][-1] + assert rate["이름"] == "공구손료" and "번지.복사" in rate["단가"] + + +def test_앞줄있는문서_넣음_율밑안섞임(world): + me = world["personal"] + gc = dc.folder()[FORM] + one = _take(me, DIG) + g = _into(gc, one) + diff = float(_money(g["문서"])["계"]) - float(_money(gc)["계"]) + assert diff == pytest.approx(float(_money(dc.folder()[DIG])["계"]), abs=1) + # 일위대가(율 줄 있음) 둘을 잇달아 = 각자 계 합 + _, _, sc = _combo(world) + t = _take(me, _rid(sc)) + two = _into(dc.md.blank("SK-000009", "빈"), t, t) + one_sc = float(_money(_into(dc.md.blank("SK-000009", "빈"), t)["문서"])["계"]) + assert float(_money(two["문서"])["계"]) == pytest.approx(2 * one_sc, abs=2) + + +def test_권한_저장_단가갱신(world, monkeypatch): + me, sk, sc = _combo(world) + with pytest.raises(store.StoreError) as e: # 회사 층에서 개인 층 = 위 층 아님 + _take(world["company"], _rid(sc)) + assert e.value.status == 403 + with pytest.raises(store.StoreError): + _take(me, "참조|personal|7/43|SC-000001") # 남의 개인 층 + # 저장 = 서버 정본 셈 · 다시 읽어 같음 + target = _sk(me) # 빈 SK(바탕 · 줄 없음) — 줄 쌓기로 처음부터 복사해 채움 + with store.at(me): + read = _norm(path.read(target)) + g = _into(read["문서"], _take(me, _rid(sc))) + with store.at(me): + saved = _norm(path.save(target, {"문서": g["문서"], "판": read["판"]})) + again = _norm(path.read(target)) + assert saved["셈"]["계"] == again["셈"]["계"] == _money(g["문서"])["계"] + # 페이지 [저장](옛 칸 · 이름) 뒤에도 고친 문서 그대로(빈 SK 씨로 덮지 않음) + with store.at(me): + file = store.read("sangeun", target) + store.write("sangeun", target, file["판"], {**file["문서"], "이름": "sub3임시 이름바꿈"}) + again = _norm(path.read(target)) + assert again["셈"]["계"] == saved["셈"]["계"] and again["사본"]["따로"] + # M01 노임 바뀜 → [단가 갱신] 미리 셈 = 그 자원 단가 따라감(구조 · 품 그대로) — 값 줄 있는 SK(바탕 = 보통인부 든 로직) + other = _sk(me, 바탕={"로직": STONE}) + with store.at(me): + read = _norm(path.read(other)) + g = _into(read["문서"], _take(me, _rid(sc))) + with store.at(me): + again = _norm(path.save(other, {"문서": g["문서"], "판": read["판"]})) + row = dict(_live().get(LB)) + row["값"] = row["값"] + 1000 + _fake(monkeypatch, [row]) + with store.at(me): + got = _norm(path.preview(other, {"할일": "단가갱신"})) + assert any(x["키"] == LB for x in got["바뀐자원"]) + assert ( + got["후"]["계"] > again["셈"]["계"] + 1000 * 0.3 + ) # 보통인부 줄들(복사한 0.3 인 포함) 따라감 + assert got["문서"]["금액"] == again["문서"]["금액"]