Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-08 08:04:56 +09:00
12 changed files with 771 additions and 17 deletions
+8 -4
View File
@@ -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_틀") },
+31 -9
View File
@@ -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<Record<MoneyKind, FindKind>> = {
단가산출: "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.원문번호
+19
View File
@@ -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<string, FormulaDoc>;
@@ -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<void> => {
try {
const got = await call<Taken>(`/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(),
}),
]
: []),
+3
View File
@@ -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"],
+17
View File
@@ -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=<M01 키 | 참조|층|주인|키> → {이름, 출처, 조각, 계?}
⚠ 등록은 `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))
+4 -3
View File
@@ -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 {}
+259
View File
@@ -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}
@@ -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";
+1 -1
View File
@@ -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("금액 줄 기호");
+197
View File
@@ -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, unknown>; 곱?: string; 이름?: string }
| { 갈래: "인력" | "재료" | "기계"; 고른: Picked; 수량: string }
| { 갈래: "율" | "손"; 줄: MoneyLine & { 규격?: string } }
| { 갈래: "글"; 글: string };
export interface Taken {
이름: string;
출처: string;
조각: TakePiece[];
계?: number | null;
}
const COSTS = ["노무비", "재료비", "경비"];
const clone = <T>(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<Node>((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<string, unknown>): 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<string, unknown> = {},
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 });
}
}
@@ -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)));
@@ -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["문서"]["금액"]