merge(M02): origin/dev 받음 — 일위대가 조합 길 충돌 풂(39장)
- Labor_Api.ts · Router_Combos.py = 나무 길(sub5) · 미리 보기 길(sub2) 둘 다 지운 꼴 · 시스템 관리자 문 _door(sub4) 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
@@ -125,6 +125,28 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def from_combo(key: str) -> dict[str, Any]:
|
||||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||||
없는 조합은 404."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
|
||||
row = combos.find(key)
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 일위대가 조합 「{key}」")
|
||||
layer, owner = combos.where(key) or (None, None)
|
||||
return {
|
||||
"단위": row.get("단위"),
|
||||
"본뜬": {"층": layer, "주인": owner, "키": key},
|
||||
"담은로직": [
|
||||
{"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None}
|
||||
for one in row.get("담은로직") or []
|
||||
if isinstance(one, dict)
|
||||
],
|
||||
"자재": [],
|
||||
}
|
||||
|
||||
|
||||
# ── 알약 · 면적 값 ────────────────────────────────────────────────────
|
||||
def material_key(code: str) -> str:
|
||||
return f"{MATERIAL_HEAD}{code}"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""옛 일위대가 조합 → 구조물 열마다 일위대가 문서 — 한 번 돌리는 도구(PLAN 39-1b · 설계_정함 1장).
|
||||
|
||||
층마다 구조물 열마다 `labor/<열id>.json`:
|
||||
- 열 `일위대가` 키가 있으면 그 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} ·
|
||||
수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사
|
||||
- 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리)
|
||||
- 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로
|
||||
옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9).
|
||||
|
||||
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층
|
||||
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층
|
||||
[--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
|
||||
|
||||
|
||||
def _filled(doc: dict) -> bool:
|
||||
return bool(doc.get("담은로직") or doc.get("자재"))
|
||||
|
||||
|
||||
def migrate(folder: Path, dry: bool = False) -> dict[str, list[str]]:
|
||||
"""`folder` 층 하나 — `{만듦, 옮김, 그대로, 못함}`(열 id · 못함은 까닭 붙임)."""
|
||||
out: dict[str, list[str]] = {"만듦": [], "옮김": [], "그대로": [], "못함": []}
|
||||
with store.at(folder):
|
||||
for col in store._structure_columns():
|
||||
cid, key = col["id"], str(col.get("일위대가") or "")
|
||||
path = store._path("labor", cid)
|
||||
have = json.loads(path.read_bytes()) if path.is_file() else None
|
||||
done = have is not None and (have.get("본뜬") or {}).get("키") == key
|
||||
if have is not None and (_filled(have) or not key or done):
|
||||
out["그대로"].append(cid)
|
||||
continue
|
||||
if not key:
|
||||
out["만듦"].append(cid)
|
||||
if not dry:
|
||||
store._ensure("labor", cid)
|
||||
continue
|
||||
try:
|
||||
body = labor.from_combo(key)
|
||||
except store.StoreError as e:
|
||||
out["못함"].append(f"{cid} · {e.detail}")
|
||||
continue
|
||||
out["옮김"].append(f"{cid} ← {key}")
|
||||
if dry:
|
||||
continue
|
||||
store._ensure("labor", cid) # 번호(SU · 같은 뒷자리)
|
||||
got = store.read("labor", cid)
|
||||
store.write("labor", cid, got["판"], {**got["문서"], **body})
|
||||
return out
|
||||
|
||||
|
||||
def layer_folders(storage: bool) -> list[Path]:
|
||||
"""시스템 층 하나 · 또는 storage 회사 · 개인 층(구조물집계표가 있는 것 · 프로젝트 빼고)."""
|
||||
if not storage:
|
||||
return [layers.system_dir()]
|
||||
root = layers.storage_root()
|
||||
table = Path("table") / f"{store.STRUCTURE_TABLE}.json"
|
||||
found = [
|
||||
*root.glob(f"*/{layers.TEMPLATES_DIRNAME}"),
|
||||
*root.glob(f"*/*/{layers.TEMPLATES_DIRNAME}"),
|
||||
]
|
||||
return sorted(p for p in found if (p / table).is_file())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--storage", action="store_true", help="회사 · 개인 층(storage)")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
report: dict[str, Any] = {}
|
||||
for folder in layer_folders(args.storage):
|
||||
report[str(folder)] = migrate(folder, args.dry_run)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -184,12 +184,6 @@ def pills(key: str) -> dict[str, Any]:
|
||||
return combo_store.combo_pills(_find(_load()[0], key))
|
||||
|
||||
|
||||
def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]:
|
||||
"""저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키."""
|
||||
row = _find(_load()[0], combo) if isinstance(combo, str) else combo
|
||||
return _m01(combo_store.combo_preview, row, inputs)
|
||||
|
||||
|
||||
# ── 쓰기(회사 · 개인) ──────────────────────────────────────────────────
|
||||
def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]:
|
||||
"""서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든)."""
|
||||
|
||||
@@ -21,3 +21,105 @@
|
||||
.m02-labor__list tbody tr:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */
|
||||
.m02-labrow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-labrow__row.is-open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-labrow__row.is-open:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-labrow__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.m02-labrow__key,
|
||||
.m02-labrow__note,
|
||||
.m02-labrow__muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-labrow__empty {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow__money,
|
||||
.m02-labrow__num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-labrow__money {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m02-labrow__base {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow__applied {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m02-labrow__sum td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labrow__move {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 입력 모달 */
|
||||
.m02-labin {
|
||||
width: min(40rem, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.m02-labin__qty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labin__qty-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-labin__muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labin__lost {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labin__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
getActiveLayer,
|
||||
getActiveOwner,
|
||||
type ItemLogic,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
|
||||
export interface ComboLogic {
|
||||
@@ -56,28 +61,6 @@ export interface ComboOne {
|
||||
combo: Combo;
|
||||
줄: ComboLine[];
|
||||
}
|
||||
export interface PreviewLine {
|
||||
차례: number;
|
||||
로직: string;
|
||||
이름: string;
|
||||
ok: boolean;
|
||||
까닭?: string;
|
||||
갈래?: string;
|
||||
결과?: number;
|
||||
노무비?: number;
|
||||
재료비?: number;
|
||||
경비?: number;
|
||||
계?: number;
|
||||
}
|
||||
export interface PreviewAnswer {
|
||||
줄: PreviewLine[];
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
계: number;
|
||||
멈춤: string[];
|
||||
}
|
||||
|
||||
/** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */
|
||||
export interface LaborScope {
|
||||
layer: Exclude<Layer, "project">;
|
||||
@@ -188,13 +171,6 @@ export const deleteCombo = (
|
||||
scope: LaborScope = SYSTEM_SCOPE,
|
||||
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
|
||||
|
||||
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
|
||||
export const previewCombo = (
|
||||
combo: Combo | string,
|
||||
inputs: Record<string, Record<string, unknown>>,
|
||||
scope: LaborScope = SYSTEM_SCOPE,
|
||||
): Promise<PreviewAnswer> => request(at(scope, "/preview"), "POST", { combo, inputs });
|
||||
|
||||
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
|
||||
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
|
||||
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
|
||||
@@ -209,3 +185,92 @@ export const fetchLibAuto = (key: string): Promise<Record<string, unknown>> =>
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic-lib/auto?key=${enc(key)}`)
|
||||
.then((d) => d.값 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
|
||||
|
||||
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
|
||||
export type LaborQty = { 손: number } | null;
|
||||
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
|
||||
export interface LaborRow {
|
||||
로직: string;
|
||||
메모: string | null;
|
||||
입력값: Record<string, string | number>;
|
||||
수량: LaborQty;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
이름: string | null;
|
||||
규격: string | null;
|
||||
단위: string | null;
|
||||
수량: LaborQty;
|
||||
}
|
||||
export interface LaborDoc {
|
||||
종류: "일위대가";
|
||||
판?: number;
|
||||
열: string;
|
||||
일위대가번?: string;
|
||||
단위: string | null;
|
||||
본뜬?: { 층: string; 주인: string | null; 키: string } | null;
|
||||
담은로직: LaborRow[];
|
||||
자재: LaborMat[];
|
||||
계산값?: unknown;
|
||||
}
|
||||
export interface LaborCost {
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
계: number;
|
||||
}
|
||||
/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */
|
||||
export interface LaborCalcLine {
|
||||
로직: string;
|
||||
분모: string | null;
|
||||
배: number | null;
|
||||
수량: number | null;
|
||||
수량출처: string | null;
|
||||
기준: LaborCost | null;
|
||||
반영: LaborCost | null;
|
||||
결과?: number | null;
|
||||
까닭: string | null;
|
||||
}
|
||||
export interface LaborCalcMat {
|
||||
코드: string;
|
||||
기준단가: number | null;
|
||||
수량: number | null;
|
||||
수량출처: string | null;
|
||||
반영: number | null;
|
||||
까닭: string | null;
|
||||
}
|
||||
export interface LaborCalc {
|
||||
줄: LaborCalcLine[];
|
||||
자재: LaborCalcMat[];
|
||||
기준합: Partial<LaborCost>;
|
||||
반영합: Partial<LaborCost>;
|
||||
}
|
||||
|
||||
/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */
|
||||
const laborAt = (layer: Layer, column: string, tail: string): string => {
|
||||
const owner = layer === getActiveLayer() ? getActiveOwner() : null;
|
||||
const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : "";
|
||||
return `/layers/${layer}/labor/${enc(column)}${tail}${q}`;
|
||||
};
|
||||
|
||||
/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */
|
||||
export const calcLabor = (
|
||||
layer: Layer,
|
||||
column: string,
|
||||
doc: LaborDoc | null,
|
||||
수량?: Record<string, number>,
|
||||
): Promise<LaborCalc> =>
|
||||
request(laborAt(layer, column, "/calc"), "POST", {
|
||||
...(doc ? { doc } : {}),
|
||||
...(수량 ? { 수량 } : {}),
|
||||
});
|
||||
|
||||
/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */
|
||||
export const fetchLogicInputs = (
|
||||
keys: string[],
|
||||
): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> =>
|
||||
keys.length
|
||||
? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`)
|
||||
: Promise.resolve({ 로직: [], 빠진로직: [] });
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Detail.ts
|
||||
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
|
||||
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
|
||||
* · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장]
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
calcLabor,
|
||||
fetchLogicInputs,
|
||||
type LaborCalc,
|
||||
type LaborDoc,
|
||||
type LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import {
|
||||
buildLaborDiagram,
|
||||
diagramTitle,
|
||||
renderComboDiagram,
|
||||
} from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
|
||||
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
const d = (doc && typeof doc === "object" ? doc : {}) as Partial<LaborDoc>;
|
||||
return {
|
||||
...d,
|
||||
종류: "일위대가",
|
||||
열: d.열 ?? column,
|
||||
단위: d.단위 ?? null,
|
||||
담은로직: (d.담은로직 ?? []).map((r) => ({
|
||||
로직: r.로직,
|
||||
메모: r.메모 ?? null,
|
||||
입력값: r.입력값 ?? {},
|
||||
수량: r.수량 ?? null,
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
이름: m.이름 ?? null,
|
||||
규격: m.규격 ?? null,
|
||||
단위: m.단위 ?? null,
|
||||
수량: m.수량 ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export interface LaborDetailOptions {
|
||||
layer: Layer;
|
||||
column: string;
|
||||
readOnly: boolean;
|
||||
/** 저장 안 한 고침이 생기거나 사라짐 */
|
||||
onChanged: (dirty: boolean) => void;
|
||||
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */
|
||||
goArea: (() => void) | null;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
getDoc: () => LaborDoc;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function mountLaborDetail(
|
||||
host: HTMLElement,
|
||||
doc: unknown,
|
||||
opt: LaborDetailOptions,
|
||||
): LaborDetailHandle {
|
||||
const cur = normalizeLabor(doc, opt.column);
|
||||
const saved = JSON.stringify(cur);
|
||||
const edit = !opt.readOnly;
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
let alive = true;
|
||||
|
||||
const logicHost = el("div");
|
||||
const matHost = el("div");
|
||||
const dgHost = el("div");
|
||||
const dgBox = el("details", {
|
||||
className: "m01lab__box m01-logic__section m02-labor__dg",
|
||||
attrs: { open: "" },
|
||||
children: [el("summary", { text: diagramTitle() }), dgHost],
|
||||
});
|
||||
|
||||
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
|
||||
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
||||
|
||||
const drawDg = (): void => {
|
||||
if (!dgBox.open) return;
|
||||
const data = buildLaborDiagram({
|
||||
rows: cur.담은로직,
|
||||
logics: [...logics.values()],
|
||||
calc,
|
||||
mats: cur.자재.length,
|
||||
});
|
||||
renderComboDiagram(dgHost, data, {
|
||||
focusInput: (key) => {
|
||||
const i = cur.담은로직.findIndex((r) => r.로직 === key);
|
||||
if (i >= 0) openRow(i);
|
||||
},
|
||||
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard)
|
||||
openLogic: (key) => {
|
||||
setPendingLogicKey(key);
|
||||
navigateTo(ROUTES.M01_MASTER_DATA);
|
||||
},
|
||||
});
|
||||
};
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
|
||||
const draw = (): void => {
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail },
|
||||
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
|
||||
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
|
||||
),
|
||||
);
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
{ mats: cur.자재, calc, fail },
|
||||
edit ? { open: openMat, remove: removeMat } : { open: openMat },
|
||||
),
|
||||
);
|
||||
drawDg();
|
||||
};
|
||||
|
||||
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
|
||||
const recalc = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
const mine = ++seq;
|
||||
calc = null;
|
||||
fail = null;
|
||||
draw();
|
||||
try {
|
||||
const got = await calcLabor(opt.layer, opt.column, cur);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = got;
|
||||
} catch (error) {
|
||||
if (!alive || mine !== seq) return;
|
||||
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
|
||||
}
|
||||
draw();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const changed = (): void => {
|
||||
opt.onChanged(JSON.stringify(cur) !== saved);
|
||||
recalc();
|
||||
};
|
||||
|
||||
/** 아직 안 받은 로직 입력 목록만 받음 */
|
||||
const loadLogics = async (): Promise<void> => {
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
if (!want.length) return;
|
||||
try {
|
||||
const got = await fetchLogicInputs(want);
|
||||
for (const l of got.로직) logics.set(l.로직, l);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
function move(i: number, d: number): void {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= cur.담은로직.length) return;
|
||||
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
||||
changed();
|
||||
}
|
||||
function removeRow(i: number): void {
|
||||
cur.담은로직.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
function removeMat(i: number): void {
|
||||
cur.자재.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
|
||||
function openRow(i: number): void {
|
||||
const row: LaborRow = cur.담은로직[i];
|
||||
const logic = logics.get(row.로직) ?? null;
|
||||
const line = lineOf(row.로직);
|
||||
openLaborInput({
|
||||
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
seat: {
|
||||
qty: row.수량,
|
||||
linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null,
|
||||
unit: line?.분모 ?? "",
|
||||
},
|
||||
readOnly: !edit,
|
||||
goArea: opt.goArea,
|
||||
done: (next) => {
|
||||
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
row.메모 = next.메모;
|
||||
row.수량 = next.수량;
|
||||
changed();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openMat(i: number): void {
|
||||
const mat = cur.자재[i];
|
||||
const line = matOf(mat.코드);
|
||||
openQtyModal(
|
||||
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
|
||||
{
|
||||
qty: mat.수량,
|
||||
linked:
|
||||
line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null
|
||||
? `${line.수량출처} = ${line.수량}`
|
||||
: null,
|
||||
unit: mat.단위 ?? "",
|
||||
},
|
||||
!edit,
|
||||
opt.goArea,
|
||||
(qty) => {
|
||||
mat.수량 = qty;
|
||||
changed();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const addLogic = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
|
||||
void loadLogics().then(changed);
|
||||
}),
|
||||
});
|
||||
const addMat = createButton({
|
||||
label: tc("Labor_AddMat"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
pickMaterial((m) => {
|
||||
if (cur.자재.some((x) => x.코드 === m.코드))
|
||||
return showToast(
|
||||
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
||||
"error",
|
||||
);
|
||||
cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null });
|
||||
changed();
|
||||
}),
|
||||
});
|
||||
|
||||
const unit = createInputField({ type: "text", label: tc("Labor_Unit") });
|
||||
unit.input.value = cur.단위 ?? "";
|
||||
unit.input.readOnly = !edit;
|
||||
unit.input.maxLength = 20;
|
||||
unit.input.setAttribute("data-labor-field", "단위");
|
||||
unit.input.addEventListener("input", () => {
|
||||
cur.단위 = unit.input.value.trim() || null;
|
||||
opt.onChanged(JSON.stringify(cur) !== saved);
|
||||
});
|
||||
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
|
||||
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
|
||||
origin.input.readOnly = true;
|
||||
|
||||
const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [el("h3", { text: title }), ...(edit && button ? [button] : [])],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-logic__editor m01-combo m02-labor__edit",
|
||||
attrs: { "data-labor": opt.column },
|
||||
children: [
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
|
||||
}),
|
||||
section(tc("Logics_Title"), addLogic, logicHost),
|
||||
section(tc("Labor_Mats"), addMat, matHost),
|
||||
dgBox,
|
||||
],
|
||||
}),
|
||||
);
|
||||
draw();
|
||||
void loadLogics().then(() => {
|
||||
if (alive) recalc();
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => cur,
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
host.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -309,3 +309,13 @@
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */
|
||||
.m02-labordg__pill.is-auto .m02-labordg__pill-value {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-labordg__card.is-material {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Diagram.ts
|
||||
* 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값)
|
||||
* 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
|
||||
* 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT
|
||||
* 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
|
||||
* 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) ·
|
||||
* 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -29,7 +30,9 @@ const TEXT = {
|
||||
"누르면 이 로직을 단가산출 로직 화면에서 엶",
|
||||
"Open this logic in the unit-price logic screen",
|
||||
],
|
||||
Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"],
|
||||
Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"],
|
||||
Mats: ["자재목록", "Materials"],
|
||||
Auto: ["자동값", "auto"],
|
||||
Same: ["같은 이름의 넣을 값", "Same input name"],
|
||||
Won: ["원", "KRW"],
|
||||
Title: ["다이어그램", "Diagram"],
|
||||
@@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number];
|
||||
export interface DiagramInput {
|
||||
name: string;
|
||||
unit: string;
|
||||
/** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */
|
||||
/** 넣은 값 — 빈 글 = 값 없음 */
|
||||
value: string;
|
||||
/** 넣은 값이 없어 자동값을 보임 */
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
/** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */
|
||||
@@ -64,6 +69,10 @@ export interface DiagramLogic {
|
||||
unit: string;
|
||||
inputs: DiagramInput[];
|
||||
out: DiagramOut;
|
||||
/** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */
|
||||
basis?: string;
|
||||
/** 자재목록 카드 — 누름 없음 */
|
||||
material?: boolean;
|
||||
}
|
||||
|
||||
export interface DiagramData {
|
||||
@@ -73,53 +82,86 @@ export interface DiagramData {
|
||||
export interface DiagramHandlers {
|
||||
/** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */
|
||||
openLogic(key: string): void;
|
||||
/** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */
|
||||
/** 넣을 값 알약 누름 — 그 로직 입력 모달 */
|
||||
focusInput(key: string, name: string): void;
|
||||
}
|
||||
|
||||
/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */
|
||||
/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */
|
||||
|
||||
interface PreviewLineLike {
|
||||
ok: boolean;
|
||||
까닭?: string;
|
||||
갈래?: string;
|
||||
결과?: number;
|
||||
노무비?: number;
|
||||
재료비?: number;
|
||||
경비?: number;
|
||||
계?: number;
|
||||
}
|
||||
type Money = { 노무비: number; 재료비: number; 경비: number; 계: number };
|
||||
|
||||
export interface DiagramSource {
|
||||
export interface LaborDiagramSource {
|
||||
/** 담은 차례대로 */
|
||||
rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[];
|
||||
/** 로직 키 → 입력 이름 → 넣은 값 */
|
||||
values: Record<string, Record<string, string>>;
|
||||
/** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */
|
||||
lines: PreviewLineLike[] | null;
|
||||
rows: { 로직: string; 입력값: Record<string, string | number> }[];
|
||||
/** 로직 입력 목록(`/logic-lib/inputs`) */
|
||||
logics: {
|
||||
로직: string;
|
||||
이름: string;
|
||||
결과단위?: string;
|
||||
입력: { 이름: string; 단위?: string; 자동값?: unknown }[];
|
||||
}[];
|
||||
/** calc 답 · 아직이면 null */
|
||||
calc: {
|
||||
줄: {
|
||||
로직: string;
|
||||
기준: Money | null;
|
||||
반영: Money | null;
|
||||
결과?: number | null;
|
||||
까닭: string | null;
|
||||
}[];
|
||||
자재: { 기준단가: number | null; 반영: number | null }[];
|
||||
} | null;
|
||||
/** 담은 자재 수 — 0 이면 자재 카드 없음 */
|
||||
mats: number;
|
||||
}
|
||||
|
||||
const outOf = (l: PreviewLineLike | undefined): DiagramOut => {
|
||||
if (!l) return null;
|
||||
if (!l.ok) return { 멈춤: l.까닭 ?? "" };
|
||||
if (l.갈래 === "결과") return { 값: l.결과 ?? 0 };
|
||||
return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 };
|
||||
};
|
||||
const autoOf = (v: unknown): string =>
|
||||
typeof v === "string" || typeof v === "number" ? String(v) : "";
|
||||
|
||||
export function buildDiagramData(src: DiagramSource): DiagramData {
|
||||
return {
|
||||
logics: src.rows.map((r, i) => ({
|
||||
key: r.key,
|
||||
name: r.name,
|
||||
unit: r.unit,
|
||||
inputs: r.inputs.map((x) => ({
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
value: src.values[r.key]?.[x.name] ?? "",
|
||||
})),
|
||||
out: outOf(src.lines?.[i]),
|
||||
})),
|
||||
};
|
||||
export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
|
||||
const logics: DiagramLogic[] = src.rows.map((r) => {
|
||||
const def = src.logics.find((l) => l.로직 === r.로직);
|
||||
const line = src.calc?.줄.find((l) => l.로직 === r.로직);
|
||||
let out: DiagramOut = null;
|
||||
let basis: string | undefined;
|
||||
if (line?.까닭) out = { 멈춤: line.까닭 };
|
||||
else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 };
|
||||
else if (line && (line.반영 || line.기준)) {
|
||||
out = { ...((line.반영 ?? line.기준) as Money) };
|
||||
basis = line.반영 ? "반영" : "기준";
|
||||
}
|
||||
return {
|
||||
key: r.로직,
|
||||
name: def?.이름 ?? r.로직,
|
||||
unit: def?.결과단위 ?? "",
|
||||
inputs: (def?.입력 ?? []).map((x) => {
|
||||
const mine = r.입력값[x.이름];
|
||||
const has = mine !== undefined && mine !== "";
|
||||
return {
|
||||
name: x.이름,
|
||||
unit: x.단위 ?? "",
|
||||
value: has ? String(mine) : autoOf(x.자동값),
|
||||
...(has || !autoOf(x.자동값) ? {} : { auto: true }),
|
||||
};
|
||||
}),
|
||||
out,
|
||||
...(basis ? { basis } : {}),
|
||||
};
|
||||
});
|
||||
if (src.mats > 0) {
|
||||
const mats = src.calc?.자재 ?? null;
|
||||
const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0;
|
||||
logics.push({
|
||||
key: "",
|
||||
name: `${td("Mats")} ${src.mats}`,
|
||||
unit: "",
|
||||
inputs: [],
|
||||
out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null,
|
||||
...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}),
|
||||
material: true,
|
||||
});
|
||||
}
|
||||
return { logics };
|
||||
}
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
@@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o;
|
||||
const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0);
|
||||
|
||||
function summary(l: DiagramLogic): string {
|
||||
if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
|
||||
if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
|
||||
if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim();
|
||||
if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`;
|
||||
return td("Wait");
|
||||
@@ -360,8 +402,12 @@ export function renderComboDiagram(
|
||||
? l.inputs.map((x) => {
|
||||
const empty = x.value.trim() === "";
|
||||
const pill = el("button", {
|
||||
className: `m02-labordg__pill${empty ? " is-empty" : ""}`,
|
||||
attrs: { type: "button", "data-name": x.name, title: td("Goto") },
|
||||
className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-name": x.name,
|
||||
title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"),
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m02-labordg__pill-name", text: x.name }),
|
||||
el("span", {
|
||||
@@ -376,8 +422,12 @@ export function renderComboDiagram(
|
||||
: [],
|
||||
});
|
||||
const card = el("button", {
|
||||
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`,
|
||||
attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") },
|
||||
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
style: `grid-column:3;grid-row:${row}`,
|
||||
...(l.material ? {} : { title: td("Open") }),
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m02-labordg__card-key", text: l.key }),
|
||||
el("strong", { className: "m02-labordg__card-name", text: l.name }),
|
||||
@@ -385,7 +435,7 @@ export function renderComboDiagram(
|
||||
el("span", { className: "m02-labordg__card-out", text: summary(l) }),
|
||||
],
|
||||
});
|
||||
card.addEventListener("click", () => on.openLogic(l.key));
|
||||
if (!l.material) card.addEventListener("click", () => on.openLogic(l.key));
|
||||
const mine = bins.map((b, c) => {
|
||||
const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b);
|
||||
sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Edit.ts
|
||||
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
|
||||
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b).
|
||||
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 ·
|
||||
* 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감.
|
||||
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -12,10 +13,7 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import {
|
||||
deleteCombo,
|
||||
editCombo,
|
||||
@@ -26,9 +24,7 @@ import {
|
||||
type ComboSummary,
|
||||
type LaborScope,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
@@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
});
|
||||
return f.root;
|
||||
};
|
||||
const preview = buildPreview(() => combo);
|
||||
// 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면
|
||||
const dgHost = el("div");
|
||||
const dgBox = el("details", {
|
||||
className: "m01lab__box m01-logic__section m02-labor__dg",
|
||||
attrs: { open: "" },
|
||||
children: [el("summary", { text: diagramTitle() }), dgHost],
|
||||
});
|
||||
const drawDg = (): void => {
|
||||
if (dgBox.open)
|
||||
renderComboDiagram(dgHost, preview.data(), {
|
||||
focusInput: preview.focusInput,
|
||||
openLogic: async (logic) => {
|
||||
if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return;
|
||||
setPendingLogicKey(logic);
|
||||
navigateTo(ROUTES.M01_MASTER_DATA);
|
||||
},
|
||||
});
|
||||
};
|
||||
let dgTimer = 0;
|
||||
preview.onUpdate(() => {
|
||||
window.clearTimeout(dgTimer);
|
||||
dgTimer = window.setTimeout(drawDg, 120);
|
||||
});
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
const table = el("div", { className: "m01-master__grid-wrap" });
|
||||
const drawTable = (): void => {
|
||||
const move = (i: number, d: number): void => {
|
||||
@@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
}),
|
||||
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
|
||||
);
|
||||
void preview.redraw();
|
||||
};
|
||||
|
||||
const add = createButton({
|
||||
@@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
table,
|
||||
],
|
||||
}),
|
||||
dgBox,
|
||||
preview.root,
|
||||
);
|
||||
drawTable();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Input.ts
|
||||
* 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량
|
||||
* (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`).
|
||||
* [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborQty } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { createValueControl } from "./M02_Labor_Values";
|
||||
import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model";
|
||||
import "./M02_Labor_Values.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
export interface QtySeat {
|
||||
/** 지금 수량 */
|
||||
qty: LaborQty;
|
||||
/** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */
|
||||
linked: string | null;
|
||||
/** 손 입력 단위 표기(분모 · 자재 단위) */
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface QtyPart {
|
||||
root: HTMLElement;
|
||||
/** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */
|
||||
read: () => LaborQty | undefined;
|
||||
}
|
||||
|
||||
/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */
|
||||
function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart {
|
||||
const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`;
|
||||
const radio = (value: "cell" | "hand"): HTMLInputElement => {
|
||||
const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement;
|
||||
input.disabled = readOnly;
|
||||
return input;
|
||||
};
|
||||
const cell = radio("cell");
|
||||
const hand = radio("hand");
|
||||
const number = el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: { type: "number", step: "any", "data-labor-qty": "hand" },
|
||||
}) as HTMLInputElement;
|
||||
number.readOnly = readOnly;
|
||||
if (seat.qty) number.value = String(seat.qty.손);
|
||||
(seat.qty ? hand : cell).checked = true;
|
||||
number.disabled = !hand.checked;
|
||||
for (const r of [cell, hand])
|
||||
r.addEventListener("change", () => {
|
||||
number.disabled = !hand.checked;
|
||||
if (hand.checked) number.focus();
|
||||
});
|
||||
const go = goArea
|
||||
? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea })
|
||||
: null;
|
||||
const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m02-labin__qty-line",
|
||||
children: [input, el("span", { text: label }), ...rest],
|
||||
});
|
||||
const root = el("section", {
|
||||
className: "m02-labin__qty",
|
||||
children: [
|
||||
el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }),
|
||||
line(
|
||||
cell,
|
||||
tc("In_QtyCell"),
|
||||
el("small", {
|
||||
className: "m02-labin__muted",
|
||||
text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"),
|
||||
}),
|
||||
...(go ? [go] : []),
|
||||
),
|
||||
line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })),
|
||||
],
|
||||
});
|
||||
return {
|
||||
root,
|
||||
read: () => {
|
||||
if (!hand.checked) return null;
|
||||
const v = Number(number.value);
|
||||
return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m02-labin__foot",
|
||||
children: readOnly
|
||||
? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })]
|
||||
: [
|
||||
createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }),
|
||||
createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export interface LaborInputResult {
|
||||
입력값: LaborValues;
|
||||
메모: string | null;
|
||||
수량: LaborQty;
|
||||
}
|
||||
|
||||
export interface LaborInputOptions {
|
||||
/** 「GF000219 메쌓기」 */
|
||||
title: string;
|
||||
/** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */
|
||||
logic: ItemLogic | null;
|
||||
values: LaborValues;
|
||||
memo: string | null;
|
||||
seat: QtySeat;
|
||||
readOnly: boolean;
|
||||
/** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */
|
||||
goArea: (() => void) | null;
|
||||
done: (next: LaborInputResult) => void;
|
||||
}
|
||||
|
||||
/** 담은 로직 줄 입력 모달 */
|
||||
export function openLaborInput(opt: LaborInputOptions): void {
|
||||
const inputs: ItemInput[] = opt.logic?.입력 ?? [];
|
||||
const { kept, lost } = keepKnown(inputs, opt.values);
|
||||
const draft: LaborValues = { ...kept };
|
||||
const bad = new Set<string>();
|
||||
openModal({
|
||||
title: fill(tc("In_Title"), opt.title),
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labin",
|
||||
mount: (body, close) => {
|
||||
const rows = inputs.map((input) => {
|
||||
const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => {
|
||||
if (isBad) bad.add(input.이름);
|
||||
else bad.delete(input.이름);
|
||||
if (value === null) delete draft[input.이름];
|
||||
else draft[input.이름] = value;
|
||||
});
|
||||
field.control.disabled = opt.readOnly;
|
||||
field.control.setAttribute("data-labor-input", input.이름);
|
||||
const hint = valueHint(input);
|
||||
return el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputval__name",
|
||||
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
|
||||
attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") },
|
||||
}),
|
||||
el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const memo = el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: { type: "text", maxlength: "200", "data-labor-memo": "" },
|
||||
}) as HTMLInputElement;
|
||||
memo.value = opt.memo ?? "";
|
||||
memo.readOnly = opt.readOnly;
|
||||
const qty = qtyPart(
|
||||
opt.seat,
|
||||
opt.readOnly,
|
||||
opt.goArea &&
|
||||
(() => {
|
||||
close();
|
||||
opt.goArea?.();
|
||||
}),
|
||||
);
|
||||
const ok = (): void => {
|
||||
if (bad.size) return showToast(tc("In_Bad"), "error");
|
||||
const 수량 = qty.read();
|
||||
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
|
||||
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 });
|
||||
close();
|
||||
};
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "m02-inputval",
|
||||
children: [
|
||||
...(lost.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m02-labin__lost",
|
||||
text: fill(tc("Labor_Lost"), lost.join(", ")),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-labin__muted", text: tc("In_None") })]),
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", { className: "m02-inputval__name", text: tc("In_Memo") }),
|
||||
memo,
|
||||
],
|
||||
}),
|
||||
qty.root,
|
||||
],
|
||||
}),
|
||||
footer(opt.readOnly, ok, close),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 자재 줄 수량 모달 */
|
||||
export function openQtyModal(
|
||||
title: string,
|
||||
seat: QtySeat,
|
||||
readOnly: boolean,
|
||||
goArea: (() => void) | null,
|
||||
done: (qty: LaborQty) => void,
|
||||
): void {
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labin",
|
||||
mount: (body, close) => {
|
||||
const qty = qtyPart(
|
||||
seat,
|
||||
readOnly,
|
||||
goArea &&
|
||||
(() => {
|
||||
close();
|
||||
goArea();
|
||||
}),
|
||||
);
|
||||
const ok = (): void => {
|
||||
const 수량 = qty.read();
|
||||
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
|
||||
done(수량);
|
||||
close();
|
||||
};
|
||||
body.append(qty.root, footer(readOnly, ok, close));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Preview.ts
|
||||
* 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음
|
||||
* 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
fetchLibAuto as fetchAuto,
|
||||
fetchLibLogic as fetchLogic,
|
||||
previewCombo,
|
||||
type Combo,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
};
|
||||
|
||||
const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v);
|
||||
/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */
|
||||
const fmt = formatMoney;
|
||||
|
||||
/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */
|
||||
const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? "");
|
||||
|
||||
/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */
|
||||
function inputCell(
|
||||
input: LogicInput,
|
||||
key: string,
|
||||
values: Record<string, Record<string, string>>,
|
||||
onEdit: () => void,
|
||||
onInput: () => void,
|
||||
): HTMLElement {
|
||||
const mine = (values[key] ??= {});
|
||||
const value = mine[input.이름] ?? initial(input);
|
||||
mine[input.이름] = value;
|
||||
const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) }));
|
||||
const field = choices
|
||||
? createSelectField({ options: choices, value, compact: true, label: input.이름 })
|
||||
: createInputField({ type: "text", label: input.이름, compact: true });
|
||||
const control = "select" in field ? field.select : field.input;
|
||||
control.value = value;
|
||||
control.setAttribute("data-input", `${key}|${input.이름}`);
|
||||
const edit = (): void => {
|
||||
mine[input.이름] = control.value;
|
||||
onInput();
|
||||
};
|
||||
control.addEventListener("input", edit);
|
||||
control.addEventListener("change", () => {
|
||||
edit();
|
||||
onEdit(); // 바꾸면 다시 미리 보기 계산
|
||||
});
|
||||
return field.root;
|
||||
}
|
||||
|
||||
export interface PreviewHandle {
|
||||
root: HTMLElement;
|
||||
/** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */
|
||||
redraw: () => Promise<void>;
|
||||
/** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */
|
||||
data: () => DiagramData;
|
||||
/** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */
|
||||
onUpdate: (fn: () => void) => () => void;
|
||||
/** 그 입력 칸으로 가서 깜빡임 */
|
||||
focusInput: (key: string, name: string) => void;
|
||||
}
|
||||
|
||||
export function buildPreview(combo: () => Combo): PreviewHandle {
|
||||
const values: Record<string, Record<string, string>> = {};
|
||||
/** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */
|
||||
const inputs = new Map<string, LogicInput[]>();
|
||||
/** 로직마다 자동값은 한 번만 받음 */
|
||||
const asked = new Set<string>();
|
||||
/** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */
|
||||
const meta = new Map<string, { name: string; unit: string }>();
|
||||
let lines: Parameters<typeof buildDiagramData>[0]["lines"] = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => listeners.forEach((f) => f());
|
||||
const form = el("div", { className: "m01-logic__stack" });
|
||||
const out = el("div", {
|
||||
className: "m01-master__grid-wrap",
|
||||
attrs: { "data-combo": "preview" },
|
||||
});
|
||||
const rowOf = (cells: string[], note = "", bold = false): HTMLElement =>
|
||||
el("tr", {
|
||||
className: bold ? "m01lab__subtotal" : "",
|
||||
children: cells.map((t, i) => {
|
||||
const text = i === 0 && note ? `${t} — ${note}` : t;
|
||||
return el("td", { text, attrs: { title: text } });
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
|
||||
lines = null;
|
||||
notify();
|
||||
const send: Record<string, Record<string, unknown>> = {};
|
||||
for (const [key, defs] of inputs) {
|
||||
send[key] = {};
|
||||
for (const i of defs) {
|
||||
const raw = values[key]?.[i.이름] ?? initial(i);
|
||||
if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음)
|
||||
send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw);
|
||||
}
|
||||
}
|
||||
let got;
|
||||
try {
|
||||
got = await previewCombo(combo(), send);
|
||||
lines = got.줄;
|
||||
notify();
|
||||
} catch (error) {
|
||||
out.replaceChildren(
|
||||
el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const trs = got.줄.map((l) =>
|
||||
rowOf(
|
||||
[
|
||||
`${l.로직} ${l.이름}`,
|
||||
fmt(l.노무비 ?? 0),
|
||||
fmt(l.재료비 ?? 0),
|
||||
fmt(l.경비 ?? 0),
|
||||
fmt(l.계 ?? 0),
|
||||
],
|
||||
l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`,
|
||||
),
|
||||
);
|
||||
trs.push(
|
||||
rowOf(
|
||||
[tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)],
|
||||
"",
|
||||
true,
|
||||
),
|
||||
);
|
||||
const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")];
|
||||
out.replaceChildren(
|
||||
el("table", {
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: trs }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const button = el("button", {
|
||||
className: "ui-btn ui-btn--filled",
|
||||
text: tc("Prev_Run"),
|
||||
attrs: { type: "button", "data-combo": "run" },
|
||||
});
|
||||
button.addEventListener("click", () => void run());
|
||||
|
||||
const redraw = async (): Promise<void> => {
|
||||
const blocks: HTMLElement[] = [];
|
||||
inputs.clear();
|
||||
for (const row of combo().담은로직) {
|
||||
try {
|
||||
const [one, auto] = await Promise.all([
|
||||
fetchLogic(row.로직),
|
||||
asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직),
|
||||
]);
|
||||
asked.add(row.로직);
|
||||
const mine = (values[row.로직] ??= {});
|
||||
for (const i of one.logic.입력) {
|
||||
const got = (auto as Record<string, unknown>)[i.이름];
|
||||
if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue;
|
||||
mine[i.이름] = String(got);
|
||||
}
|
||||
inputs.set(row.로직, one.logic.입력);
|
||||
meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" });
|
||||
blocks.push(
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
el("strong", {
|
||||
className: "m01lab__cell--wide",
|
||||
text: `${row.로직} ${one.logic.이름}`,
|
||||
}),
|
||||
...one.logic.입력.map((i) =>
|
||||
inputCell(i, row.로직, values, () => void run(), notify),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */
|
||||
}
|
||||
}
|
||||
form.replaceChildren(...blocks);
|
||||
notify();
|
||||
if (blocks.length)
|
||||
await run(); // 열자마자 한 번 미리 보기 계산
|
||||
else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") }));
|
||||
};
|
||||
|
||||
const root = el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [el("h3", { text: tc("Prev_Title") })],
|
||||
}),
|
||||
el("h4", { text: tc("Prev_Inputs") }),
|
||||
form,
|
||||
button,
|
||||
out,
|
||||
],
|
||||
});
|
||||
const data = (): DiagramData =>
|
||||
buildDiagramData({
|
||||
rows: combo().담은로직.map((r) => ({
|
||||
key: r.로직,
|
||||
name: meta.get(r.로직)?.name ?? r.로직,
|
||||
unit: meta.get(r.로직)?.unit ?? "",
|
||||
inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })),
|
||||
})),
|
||||
values,
|
||||
lines,
|
||||
});
|
||||
const focusInput = (key: string, name: string): void => {
|
||||
const box = root.querySelector<HTMLElement>(`[data-input="${CSS.escape(`${key}|${name}`)}"]`);
|
||||
if (!box) return;
|
||||
box.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
box.focus({ preventScroll: true });
|
||||
box.classList.add("m02-labor__flash");
|
||||
window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600);
|
||||
};
|
||||
const onUpdate = (fn: () => void): (() => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
};
|
||||
return { root, redraw, data, onUpdate, focusInput };
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Rows.ts
|
||||
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
|
||||
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
|
||||
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type {
|
||||
LaborCalc,
|
||||
LaborCalcLine,
|
||||
LaborCalcMat,
|
||||
LaborCost,
|
||||
LaborMat,
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
import { valueLine } from "./M02_Labor_Values_Model";
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
계: "Prev_Sum",
|
||||
};
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */
|
||||
export function qtyText(
|
||||
line: { 수량: number | null; 수량출처: string | null } | undefined,
|
||||
unit: string,
|
||||
): string {
|
||||
if (!line || line.수량 === null || line.수량 === undefined) return "";
|
||||
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
||||
if (!line.수량출처 || line.수량출처 === "손")
|
||||
return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
|
||||
return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`;
|
||||
}
|
||||
|
||||
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */
|
||||
function moneyCell(
|
||||
base: number | null | undefined,
|
||||
applied: number | null | undefined,
|
||||
wait: boolean,
|
||||
): HTMLElement {
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
return el("td", {
|
||||
className: "m02-labrow__money",
|
||||
children: [
|
||||
el("small", {
|
||||
className: "m02-labrow__base",
|
||||
text: show(base),
|
||||
attrs: { title: tc("Labor_Base") },
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-labrow__applied",
|
||||
text: show(applied),
|
||||
attrs: { title: tc("Labor_Applied") },
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labrow",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
...(foot.length ? [el("tfoot", { children: foot })] : []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */
|
||||
export interface RowHandlers {
|
||||
open: (i: number) => void;
|
||||
move?: (i: number, d: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
}
|
||||
|
||||
export interface LogicTableData {
|
||||
rows: LaborRow[];
|
||||
logics: Map<string, ItemLogic>;
|
||||
/** calc 답 — null = 계산 중 · 못 셈은 `fail` */
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
}
|
||||
|
||||
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
|
||||
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const { move, remove } = on ?? {};
|
||||
const tools = !!(move && remove);
|
||||
const lineOf = (key: string): LaborCalcLine | undefined =>
|
||||
data.calc?.줄.find((l) => l.로직 === key);
|
||||
const trs = data.rows.map((row, i) => {
|
||||
const logic = data.logics.get(row.로직);
|
||||
const line = lineOf(row.로직);
|
||||
const unit = logic?.결과단위 ?? "";
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const qty = qtyText(line, line?.분모 ?? "");
|
||||
const note = [values.text, qty].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const small = (label: string, run: () => void): HTMLElement =>
|
||||
createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
|
||||
run();
|
||||
},
|
||||
});
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
children: [
|
||||
el("td", { text: String(i + 1) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: row.로직 }),
|
||||
el("strong", { text: logic?.이름 ?? row.로직 }),
|
||||
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
|
||||
...(values.empty
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
|
||||
...(why
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: `${tc("Labor_Calc_Fail")}: ${why}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: unit }),
|
||||
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)),
|
||||
...(move && remove
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
small(tc("Remove"), () => remove(i)),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
return tr;
|
||||
});
|
||||
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
|
||||
part?.[c] ?? null;
|
||||
const foot = data.rows.length
|
||||
? [
|
||||
el("tr", {
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { text: "" }),
|
||||
el("td", { text: tc("Prev_Sum") }),
|
||||
el("td", { text: "" }),
|
||||
...COSTS.map((c) =>
|
||||
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait),
|
||||
),
|
||||
...(tools ? [el("td", { text: "" })] : []),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const heads = [
|
||||
tc("Col_Order"),
|
||||
tc("Col_Name"),
|
||||
tc("Col_Unit"),
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
...(tools ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.rows.length
|
||||
? table(heads, trs, foot)
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
}
|
||||
|
||||
export interface MatTableData {
|
||||
mats: LaborMat[];
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
}
|
||||
|
||||
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
|
||||
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const remove = on?.remove;
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
const trs = data.mats.map((mat, i) => {
|
||||
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량);
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", { text: mat.코드 }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: mat.규격 ?? "" }),
|
||||
el("td", { text: mat.단위 ?? "" }),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: qty,
|
||||
attrs: { title: line?.수량출처 ?? "" },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }),
|
||||
el("td", { className: "m02-labrow__num", text: show(line?.반영) }),
|
||||
...(remove
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
remove(i);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
return tr;
|
||||
});
|
||||
const heads = [
|
||||
tc("Labor_Code"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_Spec"),
|
||||
tc("Col_Unit"),
|
||||
tc("Labor_Qty"),
|
||||
tc("Labor_BasePrice"),
|
||||
tc("Labor_AppliedSum"),
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.mats.length
|
||||
? table(heads, trs, [])
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
@@ -65,16 +65,48 @@ const TEXT = {
|
||||
Pick_None: ["찾은 로직 없음", "No logics found"],
|
||||
Pick_Close: ["닫기", "Close"],
|
||||
Pick_Blocked: ["막힘", "Blocked"],
|
||||
Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"],
|
||||
Prev_Inputs: ["넣을 값", "Inputs"],
|
||||
Prev_Run: ["미리 보기 계산", "Run preview"],
|
||||
Prev_Labor: ["노무비", "Labor"],
|
||||
Prev_Material: ["재료비", "Material"],
|
||||
Prev_Expense: ["경비", "Expense"],
|
||||
Prev_Sum: ["합계", "Total"],
|
||||
Prev_Row: ["로직", "Logic"],
|
||||
Prev_Fail: ["못 셈", "Failed"],
|
||||
Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"],
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나
|
||||
Labor_Title: ["일위대가", "Unit price"],
|
||||
Labor_Unit: ["단위", "Unit"],
|
||||
Labor_Origin: ["본뜬 곳", "Copied from"],
|
||||
Labor_Mats: ["자재목록", "Materials"],
|
||||
Labor_NoMat: ["담은 자재 없음", "No materials yet"],
|
||||
Labor_AddMat: ["자재 추가", "Add material"],
|
||||
Labor_Qty: ["수량", "Quantity"],
|
||||
Labor_Base: ["기준", "Base"],
|
||||
Labor_Applied: ["반영", "Applied"],
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
Labor_AppliedSum: ["반영 금액", "Applied amount"],
|
||||
Labor_Calc_Wait: ["계산 중…", "Calculating…"],
|
||||
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
|
||||
Labor_Empty: ["빈 값 {n}", "{n} empty"],
|
||||
Labor_Lost: [
|
||||
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
|
||||
"Inputs no longer in the logic (dropped on save) — {value}",
|
||||
],
|
||||
Labor_Code: ["코드", "Code"],
|
||||
Labor_Spec: ["규격", "Spec"],
|
||||
Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"],
|
||||
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Qty: ["수량", "Quantity"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
|
||||
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
|
||||
In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"],
|
||||
In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"],
|
||||
In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"],
|
||||
In_Ok: ["확인", "OK"],
|
||||
In_Cancel: ["취소", "Cancel"],
|
||||
In_Close: ["닫기", "Close"],
|
||||
In_Loading: ["입력 목록 받는 중…", "Loading inputs…"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######)
|
||||
personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######)
|
||||
저장소 = `M02_MasterTemplete_Combo_Store`.
|
||||
담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
|
||||
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
|
||||
"""
|
||||
@@ -41,13 +41,6 @@ class ComboBody(BaseModel):
|
||||
판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409
|
||||
|
||||
|
||||
class PreviewBody(BaseModel):
|
||||
"""저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다."""
|
||||
|
||||
combo: dict[str, Any] | str
|
||||
inputs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@router.get("/combos/all")
|
||||
def get_all(session: Session = Depends(verify_session)) -> list[dict]:
|
||||
"""고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
|
||||
@@ -146,17 +139,6 @@ def delete_practice(판: str = "", session: Session = Depends(verify_session)) -
|
||||
return _call(_root(session, "system", None, write=True), combos.drop_practice, 판)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/combos/preview")
|
||||
def post_preview(
|
||||
layer: Layer,
|
||||
body: PreviewBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한."""
|
||||
return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs)
|
||||
|
||||
|
||||
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
|
||||
def _read(fn, *args):
|
||||
try:
|
||||
|
||||
@@ -252,3 +252,9 @@
|
||||
.m02-struct__sheet.is-pills-off .ss-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [알약] 켬 — 눌린 단추를 강조색으로(밝음 · 어두움 같은 토큰) */
|
||||
.m02-struct__card-tools .ui-btn[aria-pressed="true"] {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"종류": "일위대가",
|
||||
"판": 1,
|
||||
"열": "item1",
|
||||
"단위": "개소",
|
||||
"본뜬": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "UA000008"
|
||||
},
|
||||
"담은로직": [
|
||||
{
|
||||
"로직": "GC000198",
|
||||
"메모": "레미콘타설(장비) 무근,소형",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000214",
|
||||
"메모": "유로폼 설치 및 해체 보통",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000206",
|
||||
"메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000207",
|
||||
"메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000218",
|
||||
"메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000225",
|
||||
"메모": "깬잡석찰붙임 L3=30cm내외",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000226",
|
||||
"메모": "큰돌메쌓기 뒷길이 0.6~0.8m",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000214",
|
||||
"메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000222",
|
||||
"메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000677",
|
||||
"메모": "모르터바르기 1:3",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000221",
|
||||
"메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000088",
|
||||
"메모": "구조물터파기(토사) 굴착기0.6m3",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000113",
|
||||
"메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
"일위대가번": "SU-000001",
|
||||
"계산값": {
|
||||
"줄": [
|
||||
{
|
||||
"로직": "GC000198",
|
||||
"알약": "레디믹스트콘크리트 타설|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 58122.782608695656,
|
||||
"재료비": 0,
|
||||
"경비": 1162.455652173913,
|
||||
"계": 59285.23826086956
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000214",
|
||||
"알약": "유로폼 설치 및 해체|㎡",
|
||||
"분모": "㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 51524.36363636364,
|
||||
"재료비": 0,
|
||||
"경비": 1545.730909090909,
|
||||
"계": 53070.09454545454
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000206",
|
||||
"알약": "철근 현장가공|ton",
|
||||
"분모": "ton",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 217028.66666666666,
|
||||
"재료비": 0,
|
||||
"경비": 19532.58,
|
||||
"계": 236561.24666666667
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000207",
|
||||
"알약": "철근 현장조립|ton",
|
||||
"분모": "ton",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 574487.6470588235,
|
||||
"재료비": 11765,
|
||||
"경비": 11489.75294117647,
|
||||
"계": 597742.4
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000218",
|
||||
"알약": "기초다짐 및 뒷채움|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 12869.246375,
|
||||
"재료비": 1530.2328689,
|
||||
"경비": 1315.382,
|
||||
"계": 15714.8612439
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000225",
|
||||
"알약": "돌붙임 찰붙임|㎡",
|
||||
"분모": "㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 49447.04583333333,
|
||||
"재료비": 4806.2270256,
|
||||
"경비": 6425.98,
|
||||
"계": 60679.25285893333
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"알약": "기계비빔타설|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 144348.25222222222,
|
||||
"재료비": 1647.113,
|
||||
"경비": 378,
|
||||
"계": 146373.36522222223
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000226",
|
||||
"알약": "큰돌쌓기 메쌓기|㎡",
|
||||
"분모": "㎡",
|
||||
"배": 10,
|
||||
"기준": {
|
||||
"노무비": 819958.01,
|
||||
"재료비": 132344.5084416,
|
||||
"경비": 111948.7001,
|
||||
"계": 1064251.2185416
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000214",
|
||||
"알약": "야면석 채집|㎡ · ㎥",
|
||||
"분모": "㎡ · ㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 18927.48,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 18927.48
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000222",
|
||||
"알약": "돌쌓기 찰쌓기|㎡",
|
||||
"분모": "㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 51101.38458333333,
|
||||
"재료비": 6772.4108088,
|
||||
"경비": 9054.79,
|
||||
"계": 66928.58539213333
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000677",
|
||||
"알약": "모르타르 배합|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 113564.88,
|
||||
"재료비": 166136,
|
||||
"경비": 0,
|
||||
"계": 279700.88
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000221",
|
||||
"알약": "돌쌓기 찰쌓기|㎡",
|
||||
"분모": "㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 52917.12,
|
||||
"재료비": 1469.88,
|
||||
"경비": 0,
|
||||
"계": 54387
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GC000088",
|
||||
"알약": "터파기(기계) 보통토사|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 843.1458333333334,
|
||||
"재료비": 627.50883,
|
||||
"경비": 423.15714285714284,
|
||||
"계": 1893.811806190476
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000113",
|
||||
"알약": "되메우기|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 2660.6419100706057,
|
||||
"재료비": 416.1508405797101,
|
||||
"경비": 391.04953017996496,
|
||||
"계": 3467.8422808302807
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
"기준합": {
|
||||
"노무비": 2167800.6667278423,
|
||||
"재료비": 327515.0318154797,
|
||||
"경비": 163667.5782754784,
|
||||
"계": 2658983.2768188003
|
||||
},
|
||||
"반영합": {
|
||||
"노무비": 0,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"종류": "일위대가",
|
||||
"판": 1,
|
||||
"열": "item2",
|
||||
"단위": null,
|
||||
"본뜬": null,
|
||||
"담은로직": [],
|
||||
"자재": [],
|
||||
"계산값": {},
|
||||
"일위대가번": "SU-000002"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"종류": "일위대가",
|
||||
"판": 1,
|
||||
"열": "item3",
|
||||
"단위": "식",
|
||||
"본뜬": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "UA000001"
|
||||
},
|
||||
"담은로직": [
|
||||
{
|
||||
"로직": "GF000219",
|
||||
"메모": "메쌓기 — 돌쌓기 인력",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000119",
|
||||
"메모": "노체포설 — 굴착기 0.7㎥",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000137",
|
||||
"메모": "운반 — 중기운반 트레일러(20ton)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
"일위대가번": "SU-000003",
|
||||
"계산값": {
|
||||
"줄": [
|
||||
{
|
||||
"로직": "GF000219",
|
||||
"알약": "돌쌓기 메쌓기|㎡",
|
||||
"분모": "㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 79279.668,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 79279.668
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000119",
|
||||
"알약": "노체포설|㎥",
|
||||
"분모": "㎥",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 2433.321308321308,
|
||||
"재료비": 1077.3082366522367,
|
||||
"경비": 1012.3273551844981,
|
||||
"계": 4522.956900158043
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000137",
|
||||
"알약": "중기운반 트레일러|회",
|
||||
"분모": "회",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 38181.82530864197,
|
||||
"재료비": 32481.682622277778,
|
||||
"경비": 13280.479259259258,
|
||||
"계": 83943.98719017902
|
||||
},
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
"기준합": {
|
||||
"노무비": 119894.81461696328,
|
||||
"재료비": 33558.99085893002,
|
||||
"경비": 14292.806614443756,
|
||||
"계": 167746.61209033706
|
||||
},
|
||||
"반영합": {
|
||||
"노무비": 0,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ from fastapi.testclient import TestClient
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
|
||||
@@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None:
|
||||
|
||||
|
||||
# ── 미리 보기 ─────────────────────────────────────────────────────────
|
||||
# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름
|
||||
def _preview(body: dict) -> dict:
|
||||
got = combo_store.combo_preview(body["combo"], body["inputs"])
|
||||
return json.loads(json.dumps(got, default=str, ensure_ascii=False))
|
||||
|
||||
|
||||
def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None:
|
||||
key = _make(client)["key"]
|
||||
each = {k: _inputs(k) for k in SAMPLE}
|
||||
@@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
|
||||
for name in ("노무비", "재료비", "경비", "계")
|
||||
}
|
||||
|
||||
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each})
|
||||
got = _preview({"combo": key, "inputs": each})
|
||||
assert got["멈춤"] == [], got["멈춤"]
|
||||
assert [one["로직"] for one in got["줄"]] == list(SAMPLE)
|
||||
for name in ("노무비", "재료비", "경비", "계"):
|
||||
@@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
|
||||
def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None:
|
||||
before = _list(client)
|
||||
row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]}
|
||||
got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
|
||||
got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}})
|
||||
assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0
|
||||
assert _list(client) == before # 아무것도 안 생김
|
||||
|
||||
@@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하
|
||||
def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None:
|
||||
key = _make(client, keys=(STACK, LAYER))["key"]
|
||||
only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤
|
||||
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only})
|
||||
got = _preview({"combo": key, "inputs": only})
|
||||
stuck = [one for one in got["줄"] if not one["ok"]]
|
||||
assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"]
|
||||
assert len(got["멈춤"]) == 1
|
||||
@@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T
|
||||
def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None:
|
||||
before = _get(client, "/api/m01/logic", key=STACK)
|
||||
key = _make(client)["key"]
|
||||
_post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
|
||||
_preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
|
||||
after = _get(client, "/api/m01/logic", key=STACK)
|
||||
assert after["version"] == before["version"] and after["logic"] == before["logic"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력.
|
||||
"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 ·
|
||||
금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠.
|
||||
@@ -39,7 +39,7 @@ function load(f) {
|
||||
}
|
||||
const m = load(file);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j))));
|
||||
process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j))));
|
||||
"""
|
||||
|
||||
|
||||
@@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list:
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
ROWS = [
|
||||
LOGICS = [
|
||||
{
|
||||
"key": "GC000001",
|
||||
"name": "돌쌓기",
|
||||
"unit": "원/㎡",
|
||||
"inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}],
|
||||
"로직": "GC000001",
|
||||
"이름": "돌쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}],
|
||||
},
|
||||
{
|
||||
"key": "GF000002",
|
||||
"name": "작업량",
|
||||
"unit": "㎥/시간",
|
||||
"inputs": [{"name": "지역", "unit": ""}],
|
||||
},
|
||||
{"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []},
|
||||
{"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]},
|
||||
{"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []},
|
||||
]
|
||||
ROWS = [
|
||||
{"로직": "GC000001", "입력값": {"높이": 4}},
|
||||
{"로직": "GF000002", "입력값": {"지역": "부산"}},
|
||||
{"로직": "GC000003", "입력값": {}},
|
||||
]
|
||||
MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150}
|
||||
APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450}
|
||||
|
||||
|
||||
def test_돈_값_멈춤_세_갈래():
|
||||
lines = [
|
||||
{"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150},
|
||||
{"ok": True, "갈래": "결과", "결과": 8.946},
|
||||
{"ok": False, "까닭": "표에 맞는 줄이 없음"},
|
||||
]
|
||||
(got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}])
|
||||
def _line(key: str, **more) -> dict:
|
||||
return {"로직": key, "기준": None, "반영": None, "까닭": None, **more}
|
||||
|
||||
|
||||
def test_돈_값_멈춤_세_갈래_반영이_있으면_반영():
|
||||
calc = {
|
||||
"줄": [
|
||||
_line("GC000001", 기준=MONEY, 반영=APPLIED),
|
||||
_line("GF000002", 결과=8.946),
|
||||
_line("GC000003", 까닭="표에 맞는 줄이 없음"),
|
||||
],
|
||||
"자재": [],
|
||||
}
|
||||
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}])
|
||||
outs = [x["out"] for x in got["logics"]]
|
||||
assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150}
|
||||
assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영"
|
||||
assert outs[1] == {"값": 8.946}
|
||||
assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"}
|
||||
|
||||
|
||||
def test_넣은_값은_로직별_빈_값은_빈_글():
|
||||
(got,) = run(
|
||||
[
|
||||
{
|
||||
"rows": ROWS,
|
||||
"values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}},
|
||||
"lines": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
def test_수량_없으면_기준():
|
||||
calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []}
|
||||
(got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}])
|
||||
assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준"
|
||||
|
||||
|
||||
def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글():
|
||||
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}])
|
||||
first, second, third = got["logics"]
|
||||
assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")]
|
||||
assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")]
|
||||
assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0]
|
||||
assert first["inputs"][0]["unit"] == "m"
|
||||
assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")]
|
||||
assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")]
|
||||
assert third["inputs"] == []
|
||||
|
||||
|
||||
def test_아직_안_셌으면_출력_없음():
|
||||
(got,) = run([{"rows": ROWS, "values": {}, "lines": None}])
|
||||
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}])
|
||||
assert [x["out"] for x in got["logics"]] == [None, None, None]
|
||||
assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"]
|
||||
|
||||
|
||||
def test_자재목록은_카드_하나_재료비로():
|
||||
calc = {
|
||||
"줄": [_line("GC000001", 기준=MONEY)],
|
||||
"자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}],
|
||||
}
|
||||
(got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}])
|
||||
card = got["logics"][-1]
|
||||
assert card["material"] is True and card["inputs"] == []
|
||||
assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200}
|
||||
assert card["basis"] == "기준" # 반영 없는 자재가 있음
|
||||
(none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}])
|
||||
assert not any(x.get("material") for x in none["logics"])
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""옛 일위대가 조합 → 열마다 일위대가 문서 옮기기(`M02_Labor_Migrate` · PLAN 39-1b).
|
||||
|
||||
시스템 층 = 견본 표(임시 폴더) · 조합은 M01 마스터(읽기만) · 개인 층 = 임시 storage.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Labor_Migrate as migrate
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
SHARED = "UP42-000001"
|
||||
|
||||
|
||||
def _point(folder: Path, keys: dict[str, str]) -> None:
|
||||
path = folder / "table" / f"{store.STRUCTURE_TABLE}.json"
|
||||
doc = json.loads(path.read_bytes())
|
||||
for col in doc["열"]:
|
||||
if col["id"] in keys:
|
||||
col["일위대가"] = keys[col["id"]]
|
||||
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, monkeypatch):
|
||||
system = tmp_path / "system"
|
||||
system.mkdir()
|
||||
write_sample_table(system)
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
storage = tmp_path / "storage"
|
||||
storage.mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
_point(system, {"rv": "UA000008", "c1": "UA000008", "pv_b": "UA999999"})
|
||||
personal = layers.personal_dir(7, 42)
|
||||
items_store.ensure(personal)
|
||||
with store.at(personal):
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
doc = got["문서"]
|
||||
doc["열"] = [
|
||||
{"id": "a", "머리": ["돌", "가"], "꼴": "수", "일위대가": SHARED},
|
||||
{"id": "b", "머리": ["돌", "나"], "꼴": "수", "일위대가": SHARED},
|
||||
]
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
combo = {
|
||||
"그룹": "일위대가조합",
|
||||
"다음": 2,
|
||||
"줄": [
|
||||
{
|
||||
"키": SHARED,
|
||||
"이름": "개인 조합",
|
||||
"단위": "개소",
|
||||
"담은로직": [{"로직": "GF000219", "메모": "메쌓기"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
(personal / combos.FILE).write_text(json.dumps(combo, ensure_ascii=False), encoding="utf-8")
|
||||
return {"system": system, "personal": personal, "storage": storage}
|
||||
|
||||
|
||||
def _labor(folder: Path, cid: str) -> dict:
|
||||
return json.loads((folder / "labor" / f"{cid}.json").read_bytes())
|
||||
|
||||
|
||||
def test_시스템_층_열마다_복사_같은_조합도_따로_없는_조합은_못함(world):
|
||||
system = world["system"]
|
||||
got = migrate.migrate(system)
|
||||
assert got["옮김"] == ["rv ← UA000008", "c1 ← UA000008"]
|
||||
assert got["못함"] == ["pv_b · 없는 일위대가 조합 「UA999999」"]
|
||||
rv, c1 = _labor(system, "rv"), _labor(system, "c1")
|
||||
assert rv["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"}
|
||||
assert rv["담은로직"] == c1["담은로직"] and len(rv["담은로직"]) == 14
|
||||
assert all(one["입력값"] == {} and one["수량"] is None for one in rv["담은로직"])
|
||||
assert rv["일위대가번"] != c1["일위대가번"] # 열마다 자기 번호
|
||||
with store.at(system): # 나중에 만든 도면도 같은 뒷자리
|
||||
store.create_structure("rv")
|
||||
doc = json.loads((system / "structure" / "rv.json").read_bytes())
|
||||
assert doc["도번"][2:] == rv["일위대가번"][2:]
|
||||
assert rv["계산값"]["줄"] # 서버가 계산값까지
|
||||
assert _labor(system, "ms")["담은로직"] == [] # 키 없는 열 = 빈 일위대가
|
||||
|
||||
|
||||
def test_개인_층_여러_열이_쓰던_조합은_열마다_본뜬은_그_층(world):
|
||||
personal = world["personal"]
|
||||
assert migrate.layer_folders(True) == [personal]
|
||||
got = migrate.migrate(personal)
|
||||
assert got["옮김"] == [f"a ← {SHARED}", f"b ← {SHARED}"]
|
||||
a, b = _labor(personal, "a"), _labor(personal, "b")
|
||||
assert a["본뜬"] == b["본뜬"] == {"층": "personal", "주인": "7/42", "키": SHARED}
|
||||
assert (a["단위"], a["담은로직"][0]["메모"]) == ("개소", "메쌓기")
|
||||
assert a["일위대가번"] != b["일위대가번"]
|
||||
|
||||
|
||||
def test_다시_돌려도_같음_사람이_고친_문서는_그대로(world):
|
||||
system = world["system"]
|
||||
migrate.migrate(system)
|
||||
before = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")}
|
||||
assert migrate.migrate(system)["옮김"] == []
|
||||
after = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")}
|
||||
assert before == after
|
||||
with store.at(system): # 사람이 비운 문서도 다시 안 채움
|
||||
got = store.read("labor", "rv")
|
||||
store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": []})
|
||||
assert "rv" in migrate.migrate(system)["그대로"]
|
||||
assert _labor(system, "rv")["담은로직"] == []
|
||||
|
||||
|
||||
def test_미리_보기는_파일을_안_씀(world):
|
||||
system = world["system"]
|
||||
got = migrate.migrate(system, dry=True)
|
||||
assert "rv ← UA000008" in got["옮김"]
|
||||
assert not list((system / "labor").glob("*.json"))
|
||||
Reference in New Issue
Block a user