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

This commit is contained in:
2026-11-08 21:10:24 +09:00
10 changed files with 643 additions and 27 deletions
+289
View File
@@ -0,0 +1,289 @@
"""일위대가 단가산출 줄 = SK 참조 한 행(PLAN 811-1 · 요구 707-1 · 681 · 698).
일위대가(SC `precombo`) · 구조물 조합 일위대가(`labor`)의 단가산출 줄 = `{참조: {층, 주인, SK}, 별칭, 메모, 수량}` 한 행 ·
저장 때 펼치지 않음(`M02_Library_Flat.flat_doc` SC = `sk=False`) · 셈 = 그 SK 1 단위 × 수량(`M02_Labor_Expand`) ·
줄 누름 = 그 SK 「수식」 모달(화면 `Labor_SkEdit`).
M01 로직 줄(옛 저장본 · 740-1 로 펼쳐 담은 줄) → 그 층 SK 문서(복사해 내 것 · 681):
몸 = `M02_Labor_Patch.to_sangeun`(바탕 로직 · 입력값 · 고름 · 자재표시 · 켜쌓기율 · 패치 덮개 · 뺀줄 · 더한줄) ·
이름 = 별칭 › 로직 이름 · 단위 = 바탕 기준단위(앞 수 뗌 · 원/10㎡ → ㎡ = 일위대가 로직 줄 ÷ 배와 같은 값) ·
자재표시 빈 재료 = 일위대가 기본(별도계상)을 적어 둠(SK 빈 칸 = 직접계산이라 금액이 바뀜) ·
사본 · 수식 = 그 일위대가 것을 실어 보냄(같은 값 묶음 · M01 이 바뀌었어도 금액 그대로)
옮기기(`migrate` · 한 번) = 층(마스터 · 회사 · 개인) SC · 구조물 조합 일위대가의 로직 줄 → SK + 참조 한 행 ·
같은 몸 · 같은 사본 = 한 층 안 SK 하나 공유 · 면적산출 칸 잇기(수량 빈 줄) = 옛 알약 키 → 참조 알약 키 ·
전후 금액(원 단위 · 줄마다) 같을 때만 씀 · 다르면 공유 없이 한 번 더 · 그래도 다르면 안 옮기고 목록 ·
프로젝트(B09 사본)는 손대지 않음 · 보기 = 임시 storage 사본에서 실제로 옮겨 봄(공유 storage 안 씀).
`python -m M02_MasterTemplete.M02_Labor_SkRow [--apply] [--backup <폴더>] [--xlsx <파일>]`
"""
from __future__ import annotations
import json
import shutil
from contextlib import contextmanager
from decimal import Decimal
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
KINDS = ("precombo", "labor")
CARRY = ("사본", "수식") # 일위대가 값 묶음 → 새 SK(같은 금액)
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
return m01.loaded()[1]
def logic_doc(one: dict, doc: dict, whole: Any) -> dict[str, Any]:
"""로직 줄 `one`(일위대가 `doc` 안) → SK 문서 몸(키 없음 · 저장은 부르는 쪽)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Patch as patch
from M02_MasterTemplete import M02_Sangeun_Calc as calc
code = str(one["로직"])
row = whole.logic(code)
shown = dict(one.get("자재표시") or {})
for n, item in enumerate(row.get("호표") or []):
if item.get("종류") == "재료":
mine = shown.get(str(n))
shown[str(n)] = {**labor.marks(mine), "별도계상": labor.mark_of(mine, "별도계상")}
unit = calc.base_unit(row)
name = str(one.get("별칭") or row.get("이름") or code)
sk = patch.to_sangeun({**one, "자재표시": shown}, name, unit[1] if unit else "식", whole)
return {**sk, **{k: doc[k] for k in CARRY if doc.get(k)}}
def from_logic(code: Any, new: bool = False, use: str | None = None) -> dict[str, Any]:
"""[+ 줄] M01 기초 고름 — 지금 층에 SK 사본(같은 내용 = 409 물음 · 733-2) → `{이름: SK 키, 참조}`."""
from M02_MasterTemplete import M02_Library_Ask as ask
whole = _whole()
try:
whole.logic(str(code))
except Exception as e: # noqa: BLE001 — 없는 로직
raise store.StoreError(400, f"없는 기초 「{code}」") from e
sk = logic_doc({"로직": str(code), "입력값": {}}, {}, whole)
key = ask.ask("sangeun", sk, new, use) or refs.create("sangeun", sk)["이름"]
at = refs.here()
return {"이름": key, "참조": {"층": at[0], "주인": at[1], "키": key}}
def ref_line(one: dict, ref: refs.Ref) -> dict[str, Any]:
"""옛 로직 줄 → 참조 한 행(별칭 · 메모 · 수량 그대로)."""
return {"참조": ref, **{k: one.get(k) for k in ("별칭", "메모", "수량")}}
def _finger(sk: dict) -> str:
"""한 층 안 공유 열쇠 — 몸 + 사본 판(값 묶음이 같아야 같은 금액)."""
copy = sk.get("사본") or {}
body = {k: v for k, v in sk.items() if k not in CARRY}
tag = (copy.get("판"), copy.get("단가판")) if isinstance(copy, dict) else None
return json.dumps([body, tag], ensure_ascii=False, sort_keys=True, default=str)
def convert(
doc: dict, made: dict[str, str] | None, whole: Any
) -> tuple[dict, dict[str, str], list]:
"""지금 층(`store.at` 안) 문서의 로직 줄 → SK(만들거나 `made` 에서 공유) + 참조 한 행 ·
답 = (새 문서, {옛 알약 키: 새 알약 키}(수량 빈 줄), [만든 SK 키])."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict)]
pills = combo_store.combo_pills({"담은로직": lines})["물량"]
by_logic = {code: p["키"] for p in pills for code in p.get("로직") or []}
at, out, moved, new = refs.here(), [], {}, []
for one in doc.get("담은로직") or []:
if not (isinstance(one, dict) and one.get("로직")):
out.append(one)
continue
sk = logic_doc(one, doc, whole)
key = (made or {}).get(_finger(sk))
if key is None:
key = refs.create("sangeun", sk)["이름"]
new.append(key)
if made is not None:
made[_finger(sk)] = key
ref = {"층": at[0], "주인": at[1], "키": key}
out.append(ref_line(one, ref))
if one.get("수량") is None and by_logic.get(one["로직"]):
moved.setdefault(by_logic[one["로직"]], []).append(refs.ref_id(ref))
return {**doc, "담은로직": out}, moved, new
def relink(area: dict, moved: dict[str, list[str]]) -> dict:
"""면적산출 칸 잇기 — 옛 로직 알약 키 칸을 새 참조 알약 키에도(같은 칸 · 옛 잇기는 뗌)."""
sheets = []
for sheet in area.get("시트") or []:
links = []
for link in sheet.get("연결") or []:
keys = moved.get(str(link.get("키"))) if isinstance(link, dict) else None
links += [{**link, "키": k} for k in keys] if keys else [link]
sheets.append({**sheet, "연결": links} if "연결" in sheet else sheet)
return {**area, "시트": sheets}
# ── 금액(전후 대조) ────────────────────────────────────────────────────
def _won(v: Any) -> Any:
return None if v is None else int(Decimal(str(v)))
def _unit(line: dict) -> Any:
"""줄 1 단위 값(기준 ÷ 배 · 0.01 원)."""
got = (line.get("기준") or {}).get("계")
times = Decimal(str(line.get("배") or 1))
return None if got is None else str((Decimal(str(got)) / times).quantize(Decimal("0.01")))
def money(kind: str, key: str, doc: dict) -> dict[str, Any]:
"""지금 층 문서 금액 — 조합 = 줄마다 반영 · 합(면적산출 칸 값) · SC = 1 단위(안 줄 저장 수량 ·
빈 수량 = 1)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete.M02_Labor_Expand import Expand, inner_pills
if kind == "labor": # 기준합 = 줄마다 단위가 달라(로직 줄 결과단위 · 참조 줄 1 단위) 안 견줌
got = labor.calc(doc)
rows = [[_won((x.get("반영") or {}).get("계")), _unit(x)] for x in got.get("줄") or []]
return {"줄": rows, "합": _won((got.get("반영합") or {}).get("계"))}
at = refs.here()
ref = {"층": at[0], "주인": at[1], "키": key}
given = {p["키"]: 1 for p in inner_pills(ref)}
out = {}
for name, use in (("저장", {}), ("하나", given)):
base = Expand(_whole(), refs.read_ref, use)(ref).get("기준") or {}
out[name] = _won(base.get("계"))
return out
def _path(kind: str, key: str) -> Path:
return store.root() / kind / f"{key}.json"
def _write(path: Path, doc: dict) -> None:
path.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
def _one(kind: str, path: Path, made: dict[str, str] | None, whole: Any) -> dict[str, Any]:
"""문서 하나 옮김(지금 층에 씀) — 전후 금액 · 다르면 되돌림(만든 SK 지움 · 파일 · 면적 원래대로)."""
raw = path.read_bytes()
doc = json.loads(raw)
key = path.stem
before = money(kind, key, doc)
area_path = _path("area", str(doc.get("열") or key)) if kind == "labor" else None
area_raw = area_path.read_bytes() if area_path and area_path.is_file() else None
got, moved, new = convert(doc, made, whole)
path.write_text(json.dumps(got, ensure_ascii=False, indent=2), encoding="utf-8")
if area_raw is not None and moved:
_write(area_path, relink(json.loads(area_raw), moved))
after = money(kind, key, got)
same = before == after
if not same: # 되돌림 — 이번에 만든 SK 지움(공유 기억도)
path.write_bytes(raw)
if area_raw is not None:
area_path.write_bytes(area_raw)
for sk in new:
_path("sangeun", sk).unlink(missing_ok=True)
if made is not None:
for f in [f for f, k in made.items() if k in new]:
made.pop(f)
sks = {r["키"] for r in refs.refs_in(got) if str(r.get("키"))[:2] == "SK"} if same else set()
return {
"줄": sum(1 for x in doc.get("담은로직") or [] if isinstance(x, dict) and x.get("로직")),
"SK": sorted(sks),
"새SK": new if same else [],
"알약": sum(len(v) for v in moved.values()) if area_path else 0,
"전": before,
"후": after,
"같음": same,
}
def run_here(whole: Any = None) -> list[dict[str, Any]]:
"""지금 설정 storage 의 층 문서 전부 옮김(쓰기) — 줄마다 결과."""
whole, out = whole or _whole(), []
shared: dict[tuple, dict[str, str]] = {}
for kind in KINDS:
for where, path in layers.layer_files(kind):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
if not any(isinstance(x, dict) and x.get("로직") for x in doc.get("담은로직") or []):
continue
folder = path.parent.parent
made = shared.setdefault(refs.where(folder), {})
with store.at(folder):
row = _one(kind, path, made, whole)
if not row["같음"]: # 공유 없이 한 번 더
row = {**_one(kind, path, None, whole), "공유없음": True}
out.append({"어디": where, "종류": kind, "키": path.stem, **row})
return out
@contextmanager
def _sandbox(root: Path):
"""층 폴더(마스터 · 회사 · 개인 templates)만 임시 storage 로 복사 → 설정을 그리로."""
from config import config_system
old = (config_system.STORAGE_BASE_DIR, layers.SYSTEM_ROOT, store.FOLDER)
src = layers.storage_root()
shutil.copytree(layers.system_dir(), root / "system")
for tpl in [*src.glob("*/templates"), *src.glob("*/*/templates")]:
shutil.copytree(tpl, root / "storage" / tpl.relative_to(src))
config_system.STORAGE_BASE_DIR = str(root / "storage")
layers.SYSTEM_ROOT = store.FOLDER = root / "system"
try:
yield
finally:
config_system.STORAGE_BASE_DIR, layers.SYSTEM_ROOT, store.FOLDER = old
def migrate(apply: bool = False, backup: Path | None = None, work: Path | None = None) -> list:
"""보기(`apply` False) = 임시 storage 에서 옮겨 봄 · 씀 = `backup` 에 층 폴더 사본을 떠 둔 뒤 실제 층."""
if not apply:
import tempfile
with tempfile.TemporaryDirectory(dir=work) as tmp, _sandbox(Path(tmp)):
return run_here()
if backup is None:
raise SystemExit("--apply 는 --backup <폴더> 와 함께")
src = layers.storage_root()
shutil.copytree(layers.system_dir(), backup / "system")
for tpl in [*src.glob("*/templates"), *src.glob("*/*/templates")]:
shutil.copytree(tpl, backup / "storage" / tpl.relative_to(src))
return run_here()
def to_xlsx(rows: list[dict], path: Path) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "옮기기"
ws.append(["어디", "종류", "키", "로직 줄", "참조 SK", "새 SK", "알약 잇기", "전", "후", "같음", "공유없음"]) # fmt: skip
for r in rows:
ws.append(
[r["어디"], r["종류"], r["키"], r["줄"], " ".join(r["SK"]), len(r["새SK"]), r["알약"]]
+ [json.dumps(r[k], ensure_ascii=False) for k in ("전", "후")]
+ ["같음" if r["같음"] else "다름", "예" if r.get("공유없음") else ""]
)
path.parent.mkdir(parents=True, exist_ok=True)
wb.save(path)
if __name__ == "__main__": # 브레인 — 보기 먼저 · `--apply --backup <폴더>` 는 승인 뒤
import sys
argv = sys.argv[1:]
opt = {a: argv[i + 1] for i, a in enumerate(argv[:-1]) if a in ("--backup", "--xlsx")}
got = migrate("--apply" in argv, Path(opt["--backup"]) if "--backup" in opt else None)
for one in got:
print(json.dumps(one, ensure_ascii=False, default=str))
lines, same = sum(r["줄"] for r in got), sum(r["줄"] for r in got if r["같음"])
sks = len({(r["어디"], k) for r in got for k in r["새SK"]})
print(f"문서 {len(got)} · 로직 줄 {lines} · 옮김 {same} · 새 SK {sks} · 다름 {sum(not r['같음'] for r in got)}") # fmt: skip
if "--xlsx" in opt:
to_xlsx(got, Path(opt["--xlsx"]))
+10 -5
View File
@@ -8,7 +8,8 @@
수량 = 넣은 수 ÷ 배(728 환산) · 바탕 없는 SK = 그 더한줄 × 넣은 수
SK 에 SC SC 줄마다 더한줄(로직 줄 = 로직 한 줄 + 패치 몸 · 고정 · 자재 = 재료) ·
수량 = 넣은 수 × 줄 수량(저장 `수량.손` › 실무수량 › 1 · 742-1) ÷ 배(물량 알약 배)
SC 에 SK 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 · 같은 자리(안 줄 알약 키 그대로) ·
SC 에 SK 811-1 — 층 문서(저장 · 읽기 · 복사)는 참조 한 행 그대로(펼치지 않음 · 셈 = SK × 수량) ·
SK 에 SC 를 넣을 때 그 SC 안 SK 줄만 아래처럼 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 ·
바탕 없는 SK · 단위 환산 SK = 못 넣음(값 단위가 달라짐)
SC 에 SC 사본 블록 — 안쪽 SC 몸 통째(M01 키 줄 + 덮개 · 실무수량)를 그 줄 참조 꼴
`{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본
@@ -390,14 +391,18 @@ def _twice(doc: dict) -> str | None:
return None
def flat_sc(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict:
def flat_sc(
doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True, sk: bool = True
) -> dict:
"""SC 문서 → M01 키만 — SK 참조 = 로직 줄 + 패치(같은 자리) · SC 참조 = 사본 블록(같은 자리) ·
`strict` False = 못 펼친 줄 그대로(겹침 · 바탕 없음 · 환산 · 없는 참조)."""
`strict` False = 못 펼친 줄 그대로(겹침 · 바탕 없음 · 환산 · 없는 참조) ·
`sk` False = SK 참조 줄은 참조 한 행 그대로(811-1 · 층 문서 저장 · 읽기 · 복사)."""
lines = []
for at, one in enumerate(doc.get("담은로직") or []):
ref = one.get("참조") if isinstance(one, dict) else None
kind = refs.kind_of(ref.get("키")) if isinstance(ref, dict) else None
if kind not in ("sangeun", "precombo") or refs.is_copy(ref):
keep = kind == "sangeun" and not sk
if kind not in ("sangeun", "precombo") or refs.is_copy(ref) or keep:
lines.append(one)
continue
try:
@@ -432,7 +437,7 @@ def flat_doc(
whole, trail = _whole(), (refs.ref_id(me),) if isinstance(me, dict) else ()
if kind == "sangeun":
return flat_sk(doc, read, whole, trail, strict)
return flat_sc(doc, read, whole, trail, strict)
return flat_sc(doc, read, whole, trail, strict, sk=False) # 811-1 SK 줄 = 참조 한 행
def flat_lines(kind: str, ref: Any, qty: Any = 1, doc: Any = None) -> dict[str, Any]:
@@ -29,7 +29,10 @@ import {
buildLaborDiagram,
diagramTitle,
renderComboDiagram,
type DiagramLogic,
type DiagramOut,
} from "./M02_MasterTemplete_Labor_Diagram";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
@@ -155,6 +158,26 @@ export function createCombo(opt: ComboOptions): Combo {
};
}
/** 참조 줄 카드(811-1 · 단가산출 = SK 참조 한 행) — 이름 · 값 = 셈 답 · 넣을 값 없음 · 누름 = 그 줄 모달 */
function refCards(doc: LaborDoc, calc: LaborCalc | null): DiagramLogic[] {
return doc.담은로직.flatMap((r) => {
if (!r.참조) return [];
const line = calc?.줄.find((l) => lineId(l) === lineId(r));
const money = line?.반영 ?? line?.기준;
const out: DiagramOut = line?.까닭 ? { 멈춤: line.까닭 } : money ? { ...money } : null;
return [
{
key: r.참조.키, // 같은 참조 두 번 금지 = 줄 하나
name: r.별칭 || line?.이름 || r.참조.키,
unit: line?.분모 ?? "",
inputs: [],
out,
...(money ? { basis: line?.반영 ? "반영" : "기준" } : {}),
},
];
});
}
/** 다이어그램 상자(열 상세) — `draw` 를 표가 다시 그릴 때마다 부름 */
export function diagramBox(): {
root: HTMLElement;
@@ -179,18 +202,22 @@ export function diagramBox(): {
again = () => {
if (!root.open) return;
const data = buildLaborDiagram({
rows: doc.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함
rows: doc.담은로직.filter((r) => !r.참조),
logics: [...logics.values()],
calc,
mats: doc.자재.length,
});
const at = data.logics.findIndex((l) => l.material);
data.logics.splice(at < 0 ? data.logics.length : at, 0, ...refCards(doc, calc));
renderComboDiagram(host, data, {
focusInput: (key) => {
const i = doc.담은로직.findIndex((r) => r.로직 === key);
if (i >= 0) focus(i);
},
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard)
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) · 참조 카드(811-1) = 그 줄 모달
openLogic: (key) => {
const ref = doc.담은로직.findIndex((r) => r.참조?.키 === key);
if (ref >= 0) return focus(ref);
setPendingLogicKey(key);
navigateTo(ROUTES.M01_MASTER_DATA);
},
@@ -0,0 +1,48 @@
/* =============================================================================
* M02_MasterTemplete_Labor_SkPick.ts
* 일위대가 [+ 줄] 단가산출 = 그 층 SK 참조 한 행(PLAN 811-1 · 요구 707-1 · 681) — 일위대가 표(`Labor_Table`)가 부름.
* · M01 기초 고름 = 그 층에 SK 사본을 만듦(`POST …/sangeun/from-logic` · 같은 내용 SK 있으면 물음 = 그것을 씀 · 그래도 새로)
* · M02 단가산출 고름 = 같은 층 · 같은 주인 = 그대로 참조 · 다른 층(위 층 = 읽기 전용) = 이 층에 사본(`newLibrary` 본뜬) 뒤 참조
* · 답 = 넣을 참조 {층, 주인, 키} · 취소 = null(일위대가 문서는 화면 [저장] 때 바뀜)
* ========================================================================== */
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { call, newLibrary, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { withSameAsk } from "./M02_MasterTemplete_Library_SameAsk";
export interface SkPickScope {
layer: Layer;
owner: string | null;
}
const q = (s: SkPickScope): string =>
s.owner && s.layer !== "system" ? `?owner=${encodeURIComponent(s.owner)}` : "";
/** M01 기초 → 이 층 SK 사본 참조 */
export const skFromLogic = async (s: SkPickScope, code: string): Promise<LaborRef | null> =>
(
await withSameAsk((extra) =>
call<{ 이름: string; 참조: LaborRef }>(`/layers/${s.layer}/sangeun/from-logic${q(s)}`, {
method: "POST",
body: JSON.stringify({ 로직: code, ...extra }),
}),
)
)?.참조 ?? null;
/** 이 층 주인 — 주인 null = 로그인 사용자 자기 층(회사 「C」 · 개인 「C/U」 · 서버 참조 꼴) */
async function ownerOf(s: SkPickScope): Promise<string | null> {
if (s.layer === "system" || s.owner) return s.layer === "system" ? null : s.owner;
const me = await fetchSessionUser();
if (!me) throw new Error("로그인이 끊김");
return s.layer === "company" ? String(me.company_id) : `${me.company_id}/${me.id}`;
}
/** 고른 SK — 이 층 것이면 그대로 · 다른 층이면 이 층 사본 */
export async function ownSk(s: SkPickScope, ref: LaborRef): Promise<LaborRef> {
const owner = await ownerOf(s);
if (ref.층 === s.layer && (ref.주인 ?? null) === owner) return ref;
const 본뜬 = { ...ref, 층: ref.층 as Layer };
const got = await newLibrary(s.layer, "sangeun", { 본뜬 }, s.owner);
return { 층: s.layer, 주인: owner, 키: got.이름 };
}
@@ -14,6 +14,8 @@
* 구조물 조합 일위대가(카드 · 열 상세 · PLAN 806-1)도 이 한 부품 — `mode` 없음 = 조합 갈래: 정보 칸(이름 · 구분)
* 없음 · 수량 칸 = 수량산출 모달(`Labor_Combo` — 면적산출 잇기) · 규격 열 줄(`Labor_Spec`) · 다이어그램(열 상세) ·
* 새 줄 수량 = 면적을 따름(빈 값). 단가산출(SK) 참조 줄 누름 = 그 단가산출 문서 모달(`Labor_SkEdit` — 수식 탭 · 저장 = 그 SK).
* 811-1(요구 707-1) — 단가산출 줄 = SK 참조 한 행(저장 때 안 펼침) · [로직 추가] M01 기초 = 이 층 SK 사본 · 다른 층 SK =
* 이 층 사본 뒤 참조(`Labor_SkPick`) · 프로젝트 · 셈 주입 = 옛 로직 줄.
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
@@ -41,6 +43,7 @@ import { tipBorn } from "./M02_MasterTemplete_Labor_Born";
import { nationalRows } from "./M02_MasterTemplete_Labor_National";
import { createCombo, diagramBox } from "./M02_MasterTemplete_Labor_Combo";
import { isSkRef, openSkEdit } from "./M02_MasterTemplete_Labor_SkEdit";
import { ownSk, skFromLogic, type SkPickScope } from "./M02_MasterTemplete_Labor_SkPick";
import { specBar, specCells } from "./M02_MasterTemplete_Labor_Spec";
import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
@@ -403,6 +406,16 @@ export function mountLaborTable(
changed();
}
function addRef(ref: LaborRef, name: string, 별칭: string | null): void {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: fresh() });
changed();
}
const failed = (e: unknown): void =>
showToast(e instanceof Error ? e.message : tc("Load_Failed"), "error");
/** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */
const library = {
layer: opt.layer,
@@ -412,13 +425,10 @@ export function mountLaborTable(
: opt.layer === getActiveLayer()
? getActiveOwner()
: null,
pick: (ref: LaborRef, name: string, 별칭: string | null = null) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: fresh() });
changed();
},
pick: (ref: LaborRef, name: string, 별칭: string | null = null) =>
own && isSkRef(ref) // 811-1 — 다른 층 SK = 이 층 사본 뒤 참조
? void ownSk(own, ref).then((r) => alive && addRef(r, name, 별칭), failed)
: addRef(ref, name, 별칭),
fixed: (fx: LaborFixed, name: string) => {
const id = lineId({ 고정: fx });
if (cur.담은로직.some((r) => lineId(r) === id))
@@ -448,11 +458,20 @@ export function mountLaborTable(
changed();
},
};
/** SK 사본 길(811-1) — M02 층 일위대가 · 조합만(프로젝트 · 셈 주입 = 옛 로직 줄) */
const own: SkPickScope | null =
opt.layer !== "project" && !opt.calc ? { layer: opt.layer, owner: library.owner } : null;
const addLogic = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker((x) => {
if (own)
// 811-1 — M01 기초 = 이 층 SK 사본 참조 한 행(펼치지 않음)
return void skFromLogic(own, x.키).then(
(r) => r && alive && addRef(r, x.이름 || x.키, null),
failed,
);
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: fresh() });
+21
View File
@@ -1,5 +1,6 @@
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` ·
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) ·
일위대가 [+ 줄] M01 기초 = 그 층 SK 사본(811-1 · `M02_Labor_SkRow`) `…/sangeun/from-logic`(층 쓰기) ·
식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) ·
가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` ·
`/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) ·
@@ -78,6 +79,26 @@ def sangeun_from_line(
return _call(folder, calc.from_line, body.줄, body.이름, body.단위, body.새로, body.씀)
class FromLogicBody(BaseModel):
로직: str
새로: bool = False
씀: str | None = None
@router.post("/layers/{layer}/sangeun/from-logic")
def sangeun_from_logic(
layer: Layer,
body: FromLogicBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""일위대가 [+ 줄] M01 기초(811-1) — 그 층 SK 사본 · 답 = 새 키 + 참조(일위대가 문서는 화면 [저장] 때)."""
from M02_MasterTemplete import M02_Labor_SkRow as skrow
folder = _root(session, layer, owner, write=True)
return _call(folder, skrow.from_logic, body.로직, body.새로, body.씀)
@router.post("/layers/{layer}/sangeun/calc")
def calc_sangeun(
layer: Layer,
+4 -2
View File
@@ -40,7 +40,9 @@ def test_화면_서고_상세는_일위대가표만():
_src("M02_MasterTemplete_Labor_Detail.ts"),
)
assert "return mountLaborTable(host, doc, opt);" in detail # 806-1 — 조합도 같은 부품
assert "calc = settle(calc, cur, combo?.given() ?? {});" in table # 짝 셈 바로(742-1 수량 = 문서)
assert (
"calc = settle(calc, cur, combo?.given() ?? {});" in table
) # 짝 셈 바로(742-1 수량 = 문서)
assert 'const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };' in table
for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'):
assert label in table # 우클릭 메뉴
@@ -55,5 +57,5 @@ def test_화면_줄_모달은_SK_상세_줄_모드_적용은_덮개와_옵션():
detail = _src("M02_MasterTemplete_Sangeun_Detail.ts")
assert "mountSangeunDetail(box, doc," in sk and "getValues: () => ({ ...values })" in detail
assert "if (got.patch) row.패치 = got.patch;" in modal and "setValues(got.values);" in modal
assert 'createCheckbox(t("AlsoSk"), { checked: false })' in modal # 기본 꺼짐
assert "AlsoSk" not in modal # 808-1 [단가산출로도 저장] 걷음 · 811-1 M01 기초 = SK 사본 참조
assert "openRowSangeun" not in modal # 717-3 두 보기 갈음
+10 -11
View File
@@ -138,7 +138,7 @@ def test_SC_에_SK_패치_겹침_E1_원본_고쳐도_그대로(world):
before = _sc_value(me, old)
host = _make(me, "precombo", 이름="밖", 담은로직=lines)
doc = _read(me, "precombo", host)
assert doc["담은로직"][0]["로직"] == PCT and doc["담은로직"][0]["본뜬"]["키"] == sk
assert doc["담은로직"][0]["참조"] == {**MINE, "키": sk} # 811-1 SK 줄 = 참조 한 행(안 펼침)
assert doc["담은로직"][0]["패치"]["덮개"] == {"labor:LB000036#0": {"qty": 0.5}}
assert doc["담은로직"][1]["참조"]["층"] == "사본" # 하위 호표(SC 안 SC) = 사본 블록
assert doc["담은로직"][1]["본뜬"]["키"] == inner
@@ -148,8 +148,8 @@ def test_SC_에_SK_패치_겹침_E1_원본_고쳐도_그대로(world):
store.write(
"sangeun", sk, now["판"], {**now["문서"], "덮개": {"labor:LB000002#0": {"qty": 2}}}
)
assert _sc_value(me, old) > before * 3 # 옛 참조 = 따라감(129,073 → 481,406 꼴)
assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) # 펼친 것 = 그대로
assert _sc_value(me, old) > before * 3 # 참조 = 따라감(129,073 → 481,406 꼴)
assert _sc_value(me, host) == pytest.approx(_sc_value(me, old), rel=1e-9) # 811-1 = SK 따라감
def test_SC_에_SC_사본_블록_실무수량_겹침_원본_지워도(world):
@@ -322,18 +322,19 @@ def test_옮기기_함수_전후_같음_사본_블록(world):
sk: _sk_total(me, _read(me, "sangeun", sk))
}
look = {r["키"]: r for r in flat.migrate(False)}
assert all(look[k]["같음"] and look[k]["펼침"] and not look[k]["씀"] for k in (sk, sc, dup))
assert all(look[k]["같음"] and not look[k]["씀"] for k in (sk, sc, dup))
assert look[sc]["남은참조"] == [y] # 811-1 SC 안 SK 줄 = 참조 한 행 그대로
rows = {r["키"]: r for r in flat.migrate(True)}
assert all(rows[k]["씀"] for k in (sk, sc, dup))
assert not refs.refs_in(json.loads((me / "sangeun" / f"{sk}.json").read_bytes()))
for key in (sc, dup): # 하위 호표 = 사본 블록만 남음(원본 연결 없음)
for key, keep in ((sc, [("personal", y)]), (dup, [])): # 하위 호표 = 사본 블록
left = refs.refs_in(json.loads((me / "precombo" / f"{key}.json").read_bytes()))
assert [(r["층"], r["키"]) for r in left] == [("사본", inner)]
assert [(r["층"], r["키"]) for r in left] == [*keep, ("사본", inner)]
assert _sk_total(me, _read(me, "sangeun", sk)) == pytest.approx(before[sk], rel=1e-9)
assert _sc_value(me, sc) == pytest.approx(before[sc], rel=1e-9)
assert _sc_value(me, dup) == pytest.approx(before[dup], rel=1e-9)
again = flat.migrate(True) # 두 번째 = 바꿀 것 없음
assert all(r["펼침"] and not r["씀"] for r in again)
assert all(not r["씀"] for r in again)
def test_733_지문_옛_참조_펼친_것_같음(world):
@@ -341,7 +342,7 @@ def test_733_지문_옛_참조_펼친_것_같음(world):
y = _y(me)
old = _raw(me, "precombo", 이름="옛", 담은로직=[{"참조": {**MINE, "키": y}}, {"로직": GF}])
new = _make(me, "precombo", 이름="새", 담은로직=[{"참조": {**MINE, "키": y}}, {"로직": GF}])
assert not refs.refs_in(_read(me, "precombo", new))
assert refs.refs_in(_read(me, "precombo", new)) == [{**MINE, "키": y}] # 811-1 참조 한 행
fingers = {k: same.finger("precombo", _read(me, "precombo", k)) for k in (old, new)}
assert fingers[old] == fingers[new]
@@ -387,7 +388,5 @@ def test_747_SK_재료_직접계산_일위대가에_넣어도_같은_금액(worl
before = _sc_value(me, old)
host = _make(me, "precombo", 이름="새", 담은로직=lines)
line = _read(me, "precombo", host)["담은로직"][0]
assert line["자재표시"] and all(
v["별도계상"] == "직접계산" for v in line["자재표시"].values()
)
assert line["참조"]["키"] == sk # 811-1 참조 한 행 = SK 재료 직접계산 그대로
assert _sc_value(me, host) == pytest.approx(before, rel=1e-9)
+206
View File
@@ -0,0 +1,206 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""811-1 일위대가 단가산출 줄 = SK 참조 한 행(요구 707-1 · 681 · 698) — 임시 storage · M01 읽기만.
- 저장 · 읽기 = 안 펼침(SK 참조 그대로) · 셈 = SK 1 단위 × 수량 · 모달 저장 = 그 SK 문서 → 일위대가 금액 따라감
- [+ 줄] M01 기초 = 그 층 SK 사본(`from-logic` · 같은 내용 = 409 물음 · 씀 · 새로) · 일반 사용자 회사 층 403
- 다른 층 SK = 이 층 사본 뒤 참조(화면 `Labor_SkPick` · 서버 서고 복사 길)
- 옮기기 = 로직 줄 → SK + 참조 한 행 · 면적 칸 잇기 옮김 · 전후 금액 같음 · 보기 = 임시 사본 · 실제 저장본 전수
"""
import json
import re
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Labor_SkRow as skrow
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Router as sk_router
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Labor_Expand import Expand
from resources.tester.test_740_1_library_flat import USER, _make, _read, _whole
from resources.tester.test_m02_labor_ref import MINE, STONE, TEN, world # noqa: F401
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
FORM = "GC000211" # 합판거푸집 — 재료 줄 있음(일위대가 기본 별도계상)
def _src(name: str) -> str:
return (M02 / name).read_text("utf-8")
def _value(folder, key, given=None):
with store.at(folder):
got = Expand(_whole(), refs.read_ref, given or {})({**MINE, "키": key})
return got["기준"]
def test_참조_한_행_저장_읽기_안_펼침_셈은_SK_곱_수량(world):
me = world["personal"]
sk = _make(me, "sangeun", 이름="sub1임시 SK", 바탕={"로직": TEN})
sc = _make(me, "precombo", 이름="sub1임시 SC", 담은로직=[{"참조": {**MINE, "키": sk}, "수량": {"손": 3}}]) # fmt: skip
doc = _read(me, "precombo", sc)
assert doc["담은로직"][0]["참조"] == {**MINE, "키": sk} and "로직" not in doc["담은로직"][0]
assert flat.flat_doc("precombo", doc)["담은로직"][0]["참조"]["키"] == sk # 읽기 길도 그대로
one, got = _value(me, sk), _value(me, sc)
want = labor.mcut.sums([labor.mcut.row({k: v * 3 for k, v in one.items()})])
assert got["계"] == want["계"] and got["계"] > 0
def test_모달_저장_SK_문서_뒤_일위대가_따라감(world):
me = world["personal"]
sk = _make(me, "sangeun", 이름="sub1임시 SK", 바탕={"로직": TEN})
sc = _make(me, "precombo", 이름="sub1임시 SC", 담은로직=[{"참조": {**MINE, "키": sk}, "수량": {"손": 1}}]) # fmt: skip
before = _value(me, sc)["계"]
with store.at(me): # 모달 [저장] = 그 SK 문서 쓰기(`saveTemplate` sangeun)
now = store.read("sangeun", sk)
cid = sd.line_ids(_whole().logic(TEN))[0]
doc = {**now["문서"], "덮개": {cid: {"배율": 2}}}
store.write("sangeun", sk, now["판"], calc.clean_doc(doc, refs.here()))
assert _value(me, sc)["계"] != before # 일위대가 금액이 SK 를 따라감
assert _read(me, "precombo", sc)["담은로직"][0]["참조"]["키"] == sk # 일위대가 문서는 그대로
def test_from_logic_층_SK_사본_같은것_물음_권한(world):
app = FastAPI()
app.include_router(sk_router.router)
app.dependency_overrides[verify_session] = lambda: USER
client = TestClient(app)
url = "/api/m02/layers/{}/sangeun/from-logic"
res = client.post(url.format("personal"), json={"로직": TEN})
assert res.status_code == 200, res.text
key, ref = res.json()["이름"], res.json()["참조"]
assert ref == {**MINE, "키": key}
doc = _read(world["personal"], "sangeun", key)
assert doc["바탕"] == {"로직": TEN} and doc["단위"] == "㎡" # 원/10㎡ → ㎡(줄 ÷ 배와 같은 값)
again = client.post(url.format("personal"), json={"로직": TEN})
assert again.status_code == 409 # 같은 내용 = 물음(733-2)
use = client.post(url.format("personal"), json={"로직": TEN, "씀": key})
assert use.json()["이름"] == key
new = client.post(url.format("personal"), json={"로직": TEN, "새로": True})
assert new.json()["이름"] != key
assert client.post(url.format("personal"), json={"로직": "GF999999"}).status_code == 400
assert client.post(url.format("company"), json={"로직": TEN}).status_code == 403
def test_M01_기초_SK_값_로직_줄과_같음_재료_별도계상(world):
"""새 SK 1 단위 = 옛 로직 줄 ÷ 배 · 재료 빈 칸 = 일위대가 기본(별도계상)을 적어 둠."""
me = world["personal"]
for code in (TEN, STONE, FORM):
with store.at(me):
got = skrow.from_logic(code, new=True)
line = labor.clean({"담은로직": [{"로직": code, "수량": {"손": 2}}]})
ref = labor.clean({"담은로직": [{"참조": got["참조"], "수량": {"손": 2}}]})
a, b = labor.calc(line, {}), labor.calc(ref, {})
assert a["반영합"] == b["반영합"], code
doc = _read(me, "sangeun", got["이름"])
assert doc["자재표시"] and all(v["별도계상"] == "별도계상" for v in doc["자재표시"].values())
def _labor_world(me):
"""개인 층 조합 일위대가(수량 빈 줄 = 면적 칸) + 면적 문서 + SC(로직 줄 · 손 수량)."""
lines = [{"로직": TEN, "수량": None}, {"로직": STONE, "수량": {"손": 2}}]
pill = combo_store.combo_pills({"담은로직": lines})["물량"][0]["키"]
with store.at(me):
doc = labor.clean({"담은로직": lines})
(me / "labor").mkdir(parents=True, exist_ok=True)
(me / "area").mkdir(parents=True, exist_ok=True)
(me / "labor" / "c811.json").write_text(json.dumps({**doc, "열": "c811", "단위": "㎡"}, ensure_ascii=False), "utf-8") # fmt: skip
area = {"시트": [{"id": "s1", "칸": {"A1": {"값": "4"}}, "연결": [{"키": pill, "칸": "A1"}]}]}
(me / "area" / "c811.json").write_text(json.dumps(area, ensure_ascii=False), "utf-8")
sc = _make(me, "precombo", 이름="sub1임시 SC", 담은로직=[{"로직": TEN, "수량": {"손": 3}}])
return pill, sc
def test_옮기기_로직_줄_SK_참조_면적_잇기_전후_같음(world):
me = world["personal"]
pill, sc = _labor_world(me)
with store.at(me):
before = labor.calc(json.loads((me / "labor" / "c811.json").read_bytes()))["반영합"]
sc_before = _value(me, sc)
rows = {r["키"]: r for r in skrow.run_here()}
assert rows["c811"]["같음"] and rows[sc]["같음"] and rows["c811"]["알약"] == 1
doc = json.loads((me / "labor" / "c811.json").read_bytes())
assert all("참조" in x and "로직" not in x for x in doc["담은로직"])
assert [x["수량"] for x in doc["담은로직"]] == [None, {"손": 2}] # 수량 · 별칭 그대로
area = json.loads((me / "area" / "c811.json").read_bytes())
keys = [x["키"] for x in area["시트"][0]["연결"]]
assert keys == [refs.ref_id(doc["담은로직"][0]["참조"])] and pill not in keys
with store.at(me):
assert labor.calc(doc)["반영합"] == before # 원 단위 같음(면적 칸 4)
assert _value(me, sc) == sc_before
assert refs.refs_in(_read(me, "precombo", sc))[0]["키"].startswith("SK-")
made = {k for r in rows.values() for k in r["새SK"]}
assert len(made) == 3 # SC TEN · 조합 TEN · STONE — 사본(값 묶음)이 달라 공유 안 함
assert skrow.run_here() == [] # 두 번째 = 옮길 로직 줄 없음
def test_옮기기_다르면_되돌림(world, monkeypatch):
me = world["personal"]
_labor_world(me)
raw = (me / "labor" / "c811.json").read_bytes()
real = skrow.money
monkeypatch.setattr(
skrow, "money", lambda k, key, doc: {"다름": id(doc)} if k == "labor" else real(k, key, doc)
)
rows = {r["키"]: r for r in skrow.run_here()}
assert not rows["c811"]["같음"] and rows["c811"]["공유없음"]
assert (me / "labor" / "c811.json").read_bytes() == raw # 원래대로
left = {p.stem for p in (me / "sangeun").glob("SK-*.json")}
sc_made = set(rows[next(k for k in rows if k != "c811")]["새SK"])
assert left == sc_made # 되돌린 문서가 만든 SK 는 지움
def test_보기_임시_사본_공유_storage_안_씀(world, tmp_path):
me = world["personal"]
_labor_world(me)
raw = (me / "labor" / "c811.json").read_bytes()
rows = skrow.migrate(False, work=tmp_path)
assert rows and all(r["같음"] for r in rows)
assert (me / "labor" / "c811.json").read_bytes() == raw # 보기 = 진짜 층 안 고침
assert not (me / "sangeun").exists() or not list((me / "sangeun").glob("SK-*.json"))
skrow.to_xlsx(rows, tmp_path / "목록.xlsx")
assert (tmp_path / "목록.xlsx").stat().st_size > 0
@pytest.mark.skipif(
not any(layers.storage_root().glob("*/*/templates/labor/*.json")), reason="저장본 없음"
)
def test_실제_저장본_전수_보기_같음(tmp_path):
"""공유 storage 층 문서 전부 — 임시 사본에서 옮겨 봄(쓰지 않음) · 줄마다 금액 같음."""
rows = skrow.migrate(False, work=tmp_path)
assert rows and all(r["같음"] for r in rows), [r["키"] for r in rows if not r["같음"]]
def test_화면_줄_넣기_SK_사본_참조_부품():
table = _src("M02_MasterTemplete_Labor_Table.ts")
pick = _src("M02_MasterTemplete_Labor_SkPick.ts")
assert 'opt.layer !== "project" && !opt.calc ? { layer: opt.layer' in table # 프로젝트 = 옛 길
assert "return void skFromLogic(own, x.키).then(" in table # M01 기초 = SK 사본
assert "own && isSkRef(ref)" in table and "void ownSk(own, ref)" in table # 다른 층 = 사본
assert "if (isSkRef(row.참조) && !opt.calc)" in table # 줄 누름 = SK 수식 모달
assert "/sangeun/from-logic" in pick and "{ 본뜬 }" in pick and "withSameAsk(" in pick
assert (
"fetchSessionUser()" in pick
) # 주인 null = 자기 층 → 서버 참조 꼴(같은 층 SK 를 사본으로 안 만듦)
combo = _src("M02_MasterTemplete_Labor_Combo.ts")
assert "...refCards(doc, calc)" in combo and "key: r.참조.키," in combo # 다이어그램 참조 카드
for text in (table, pick):
assert len(text.splitlines()) <= 700
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text)
assert len((M02 / "M02_Labor_SkRow.py").read_text("utf-8").splitlines()) <= 700
def test_값_한_벌_소수():
assert skrow._unit({"기준": {"계": Decimal("1289832")}, "배": 100}) == "12898.32"