feat(804-4): 줄 쌓기 다른 단가산출 · 일위대가 복사해 펼침 — M01 은 M01 전체만
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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_틀") },
|
||||
|
||||
@@ -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.원문번호
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("금액 줄 기호");
|
||||
|
||||
@@ -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["문서"]["금액"]
|
||||
Reference in New Issue
Block a user