merge(M02): origin/dev(39 통합) 받음 — G1 · G2 지움과 부딪힌 곳 풂

- 남의 새 줄(labor · 일위대가번 SU · 실무문서 · 「전체 항목」 · 수평선 · 알약 켬끔 · 복사해 가져오기)은 살리고 material · 자재조합 · 재료번 · matset 줄만 뺌
- dev 쪽 새 시험(test_m02_item_labor · test_m02_card_sheet_edit)의 재료산출 · 자재조합 자리 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-04 19:22:22 +09:00
co-authored by Claude Opus 5.5
104 changed files with 7025 additions and 2214 deletions
+2
View File
@@ -62,6 +62,8 @@ export const STATE_REGISTRY = {
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
"m02-card-pills": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
+13
View File
@@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { ConditionOptions } from "./spreadsheet_condition";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
@@ -48,6 +49,7 @@ import type {
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
export type { LinkInfo } from "./spreadsheet_link_set";
export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
@@ -76,6 +78,9 @@ export interface SpreadsheetHandle {
recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */
getLinkedValues(): Record<string, CalcValue>;
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
* 주소가 틀리거나 읽기만이면 false */
setLink(key: string, cell: string | null, info?: LinkInfo): boolean;
destroy(): void;
}
@@ -288,6 +293,14 @@ export function createSpreadsheet(
}
return out;
},
setLink(key, cell, info) {
if (readOnly) return false;
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info);
if (command === false) return false;
if (command) dispatch(command);
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
return true;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
@@ -0,0 +1,55 @@
/* =============================================================================
* spreadsheet_link_set.ts (주인 D)
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 ·
* 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
* ========================================================================== */
import { parseA1, toA1 } from "./spreadsheet_address";
import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types";
/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */
export interface LinkInfo {
이름?: string;
단위?: string;
자재?: LinkMaterial;
}
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */
export function linkCommand(
book: Workbook,
sheetId: string,
key: string,
cell: string | null,
info: LinkInfo = {},
): Command | null | false {
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
if (cell !== null && !at) return false;
const a1 = at ? toA1(at.r, at.c) : null;
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
const home = book.시트.find((s) => s.id === sheetId);
if (a1 && !home) return false;
if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
if (one) {
const 이름 = info.이름 ?? old?.이름;
const 단위 = info.단위 ?? old?.단위;
const 자재 = info.자재 ?? old?.자재;
if (이름) one.이름 = 이름;
if (단위) one.단위 = 단위;
if (자재) one.자재 = 자재;
}
const commands: Command[] = [];
for (const s of book.시트) {
const was = s.연결 ?? [];
const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1));
const next = s === home && one ? [...rest, one] : rest;
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
const copy = structuredClone(s);
if (next.length) copy.연결 = next;
else delete copy.연결;
commands.push({ 종류: "시트통째", 시트: copy });
}
if (!commands.length) return null;
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
}
+31 -1
View File
@@ -217,7 +217,37 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l
out += cm.check_logics(files, whole, book, narrowed and narrowed[:2])
if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로)
out += cm.check_links(whole)
return out + cm.mcb.check_all(files, whole)
return out + cm.mcb.check_all(files, whole) + labor_errors(whole)
def _labor_files() -> list[tuple[str, Path]]:
"""모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일)."""
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
root = layers.storage_root()
found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))]
found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))]
personal = sorted(root.glob("*/*/templates/labor/*.json"))
return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal]
def labor_errors(whole) -> list[str]:
"""일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림.
전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌."""
out = []
for where, path in _labor_files():
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem
for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []:
key = str(one.get("로직") or "") if isinstance(one, dict) else ""
try:
whole.logic(key)
except cm.mf.FormulaError:
out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌")
return out
def warm(folder: Path) -> threading.Thread:
+4 -2
View File
@@ -150,7 +150,9 @@ def combo(key: str) -> dict:
}
_PER = re.compile(r"원\s*/\s*(\d*\.?\d*)\s*([^\s·(]+)")
_PER = re.compile(
r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)"
) # 「원/1,000본」 = 배 1000
_TAIL = re.compile(r"\s*\([^()]*\)\s*$")
@@ -185,7 +187,7 @@ def combo_pills(row: dict) -> dict:
(name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []}
)
pill["로직"].append(code)
times = per[0][0]
times = per[0][0].replace(",", "")
if len(per) == 1 and times and float(times) != 1:
pill["배"] = float(times) if "." in times else int(times)
return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []}
+9 -2
View File
@@ -53,6 +53,7 @@ KINDS = {
"입력연결": LINK_FILE,
"입력값": VALUES_FILE,
"고른자재": PICKS_FILE,
"일위대가": LABOR_FILE, # 읽기만 — 일위대가는 원본 층에서 고침(39-1c)
}
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출)
SKELETON = {"도면": "structure", "면적": "area"}
@@ -169,6 +170,8 @@ def _check_links(content: Any) -> None:
def check(kind: str, content: Any) -> None:
if kind == "일위대가":
raise ValueError("일위대가는 원본(마스터 · 회사 · 개인) 층에서 고침 — 프로젝트는 넣을 값만")
if kind == "입력값":
_check_values(content)
elif kind == "고른자재":
@@ -251,7 +254,10 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
before = {name: read_json(target / file) for name, file in KINDS.items()}
files = build_snapshot(manifest["원본"])
labor = files.get(LABOR_FILE) or {}
files[VALUES_FILE] = _kept_values(values, labor)
seeded = files[VALUES_FILE] # 새 원본의 첫 값 — 프로젝트에서 넣은 값이 같은 키면 이김
kept_values = _kept_values(values, labor)
for logic, inputs in kept_values.items():
seeded[logic] = {**seeded.get(logic, {}), **inputs}
files[PICKS_FILE] = _kept_picks(picks, labor)
for old in target.glob("*.json"): # 원본에서 빠진 파일(도면 없앰 등)은 지움
if old.name != MANIFEST_NAME and old.name not in files:
@@ -266,7 +272,7 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
source = version_of(source_files(files))
stamp = now_text()
# 원본을 다시 가져왔으니 시트 고침은 없음 — 살린 입력값 · 고른 자재는 `값있음` 으로 따로 알림
kept = bool(files[VALUES_FILE] or files[PICKS_FILE])
kept = bool(kept_values or files[PICKS_FILE])
fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp, "고침": False}
fresh["값있음"] = kept
fresh.pop("고친날", None)
@@ -341,4 +347,5 @@ def calc(project_root: str | Path, folder: str) -> dict[str, Any]:
read_json(target / PRICE_FILE) or {},
read_json(target / VALUES_FILE) or {},
read_json(target / PICKS_FILE) or {},
read_json(target / KINDS["면적"]),
)
+48 -6
View File
@@ -8,9 +8,12 @@
원문번호 틀의 `{입력}` 은 그 입력의 고르기 값으로 모두 끼워 모음(고르기가 없으면 그 테이블 전부 ·
자재품목만은 너무 커서 뺌). 줄은 마스터 파일 차례 그대로 담음 — 고르기의 「첫 줄」 이 같게.
일위대가 자재(MT · 39-1c)도 그 줄(+ 준용 · 연결)을 담음.
`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가
그대로 읽는 꼴.
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` 만 씀 — 마스터 현재값을 읽지 않음.
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터
현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`).
"""
from __future__ import annotations
@@ -200,12 +203,14 @@ class _Collector:
self.pick(row, item["요소"], item.get("단위"))
def collect(logic_keys: list[str]) -> dict[str, Any]:
"""담은 로직의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`."""
def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]:
"""담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`."""
files, whole = master()
collector = _Collector(whole)
for key in logic_keys:
collector.logic(key)
for code in materials or []:
collector.add(code)
out: dict[str, Any] = {}
for name, data in files.items():
slot = m01.items_key(data)
@@ -256,9 +261,17 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any])
def calc(
labor: dict[str, Any], prices: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]
labor: dict[str, Any],
prices: dict[str, Any],
values: dict[str, Any],
picks: dict[str, Any],
area: Any = None,
) -> dict[str, Any]:
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합."""
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합
(수량 1 기준).
일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 —
수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌)."""
snapshot = load_prices(prices)
gaps = missing_inputs(snapshot, labor, values)
blocked = {gap["로직"] for gap in gaps}
@@ -293,4 +306,33 @@ def calc(
"계": got["계"],
}
)
return to_json({"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck})
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
doc = labor.get("줄") or {}
pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []}
base = []
for line in lines:
pill = pill_of.get(line["로직"]) or {}
money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
base.append({**head, "분모": pill.get("단위"), "기준": money})
mats = []
for one in doc.get("자재") or []:
code = str(one.get("코드") or "")
try:
price, _ = mm.element(snapshot, code, {}, one.get("단위") or None)
except mf.FormulaError:
price = None
mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price})
settled = labor_doc.settle(
to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area)
)
for line, done in zip(lines, settled["줄"]):
line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")})
return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]}
+18 -3
View File
@@ -6,7 +6,8 @@
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
- 항목을 지정한 구조물마다 원본 층에서 내용을 복사(`M02_Item_Source.build_source`) + 프로젝트 값
`입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · `manifest.json`
`입력값.json`(첫 값 = 일위대가 문서 입력값 · 확인 거짓 · 자동값은 안 적음) · `고른자재.json` ·
`manifest.json`
- 이미 있는 복사본은 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌거나 지워져도 그대로
([재적용]은 사용자가)
- 지정을 다른 항목으로 바꾸면 새로 가져옴 · 구조물을 지우거나 지정을 풀면 복사본을 지움 —
@@ -30,6 +31,7 @@ from common_util.common_util_json import atomic_write_json
from common_util.common_util_summary_item import snapshot_folder_name
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
LINK_FILE,
PRICE_FILE,
build_source,
@@ -85,9 +87,22 @@ def origin_of(item: dict[str, Any]) -> dict[str, Any]:
return {**item, "경로": path}
def seed_values(labor: Any) -> dict[str, Any]:
"""일위대가 문서 입력값 → 프로젝트 입력값 첫 값 `{로직: {입력: {값, 확인: false}}}`(39-1c)."""
doc = labor.get("줄") if isinstance(labor, dict) else None
out: dict[str, Any] = {}
for line in (doc or {}).get("담은로직") or []:
given = line.get("입력값") if isinstance(line, dict) else None
if isinstance(given, dict) and given and line.get("로직"):
out[str(line["로직"])] = {k: {"값": v, "확인": False} for k, v in given.items()}
return out
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
"""원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}}
"""원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면
LookupError."""
files = build_source(item)
return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}}
def source_files(files: dict[str, Any]) -> dict[str, Any]:
+34 -14
View File
@@ -2,9 +2,10 @@
모으는 것 = `{파일 이름: 내용}`:
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
`일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) ·
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
`입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) ·
`단가.json` 계산에 닿는 마스터 줄 전부
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
원본 층 자리 = `M02_Template_Layers` 경로 · 일위대가 · 단가는 M01 시스템 것.
@@ -22,6 +23,7 @@ from M02_MasterTemplete import M02_Template_Layers as layers
TABLE_NAME = "구조물집계표"
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출)
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"))
LABOR_KIND = "labor" # 열 id → 일위대가 문서(39-1c) — 스냅샷은 `LABOR_FILE` 에 감싸서
LINK_FILE = "입력연결.json"
PRICE_FILE = "단가.json"
LABOR_FILE = "일위대가.json"
@@ -79,15 +81,27 @@ def version_of(content: Any) -> str:
def labor_rows(ua_key: str) -> dict[str, Any] | None:
"""조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만
`빠진로직`)."""
from M01_MasterData import M01_MasterData_Store as m01
"""옛 조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄 — labor 문서가 없는 열
(옮기기 전)."""
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
ua = combos.find(ua_key)
if ua is None:
return None
wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")]
return None if ua is None else logic_rows(ua)
def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]:
"""일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 —
복사본 계산이 마스터를 안 읽게)."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
return {**logic_rows(doc), "알약": labor_doc.pills(doc)}
def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
"""`{줄: row, 로직: 담은 로직 줄(M01 시스템), 빠진로직: 없는 로직 키}`."""
from M01_MasterData import M01_MasterData_Store as m01
wanted = [str(entry.get("로직")) for entry in row.get("담은로직") or [] if entry.get("로직")]
found: dict[str, dict[str, Any]] = {}
raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음
for key in wanted:
@@ -97,11 +111,11 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None:
continue
if file not in raw:
raw[file] = read_json(Path(m01.FOLDER) / file)
row = row_of(raw[file], key)
if row is not None:
found[key] = row
got = row_of(raw[file], key)
if got is not None:
found[key] = got
return {
"줄": ua,
"줄": row,
"로직": [found[key] for key in wanted if key in found],
"빠진로직": [key for key in wanted if key not in found],
}
@@ -137,10 +151,16 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
part = read_json(root / kind / f"{column_id}.json") if column_id else None
if part is not None:
files[name] = part
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
if isinstance(doc, dict):
labor = labor_doc_rows(doc)
else:
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
if labor is not None:
files[LABOR_FILE] = labor
files[LINK_FILE] = input_links(root, column_id)
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys)) if logic_keys else {}
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
wanted = logic_keys + [code for code in materials if code]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
return files
+309
View File
@@ -0,0 +1,309 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
"""
from __future__ import annotations
import json
from decimal import Decimal, InvalidOperation
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
mf = m01.cm.mf
mm = mf.mp.mm
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
def _bad(text: str) -> store.StoreError:
return store.StoreError(400, text)
def _number(raw: Any) -> Decimal | None:
"""칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌)."""
if raw is None or isinstance(raw, bool):
return None
try:
got = Decimal(str(raw).replace(",", "").strip())
except InvalidOperation:
return None
return got if got.is_finite() else None
def _specs(logic: dict) -> list[dict]:
"""로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」."""
if isinstance(logic.get("흐름"), dict):
return mf.inputs_of(logic)
return list(logic.get("입력") or [])
def _qty(value: Any, where: str) -> dict | None:
if value is None:
return None
hand = _number(value.get("손")) if isinstance(value, dict) else None
if hand is None:
raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}")
return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)}
# ── 정리 · 검사 ───────────────────────────────────────────────────────
def clean(doc: Any) -> dict[str, Any]:
"""서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 ·
모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것."""
if not isinstance(doc, dict):
raise _bad("일위대가 문서가 묶음 아님")
whole = m01.loaded()[1]
lines, seen = [], set()
for at, one in enumerate(doc.get("담은로직") or [], start=1):
where = f"담은 로직 {at}째"
if not isinstance(one, dict):
raise _bad(f"{where} · 묶음 아님")
code = str(one.get("로직") or "")
try:
logic = whole.logic(code)
except mf.FormulaError as e:
raise _bad(f"{where} · {e}") from e
if code in seen:
raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」")
seen.add(code)
names = {str(spec.get("이름")) for spec in _specs(logic)}
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo = one.get("메모")
lines.append(
{
"로직": code,
"메모": str(memo) if memo else None,
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
}
)
mats, codes = [], set()
for at, one in enumerate(doc.get("자재") or [], start=1):
where = f"자재 {at}째"
code = str(one.get("코드") or "") if isinstance(one, dict) else ""
try:
row = whole.get(code) if code[:2] == mm.MARKET else None
except mf.FormulaError:
row = None
if row is None:
raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」")
if code in codes:
raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」")
codes.add(code)
mats.append(
{
"코드": code,
**{k: row.get(k) or "" for k in _MAT_COLS[1:]},
"수량": _qty(one.get("수량"), where),
}
)
out = {
"종류": "일위대가",
"판": doc.get("판") or 1,
"열": doc.get("열"),
"단위": str(doc["단위"]) if doc.get("단위") else None,
"본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None,
"담은로직": lines,
"자재": mats,
}
if doc.get("일위대가번"):
out["일위대가번"] = doc["일위대가번"]
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}"
def pills(doc: dict) -> list[dict[str, Any]]:
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」."""
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
mats = [
{
"키": material_key(m["코드"]),
"이름": m.get("이름") or m["코드"],
"단위": m.get("단위") or "",
"자재": {k: m.get(k) or "" for k in _MAT_COLS},
}
for m in doc.get("자재") or []
]
return [*logic, *mats]
def area_values(area: Any) -> dict[str, Decimal]:
"""면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만."""
out: dict[str, Decimal] = {}
if not isinstance(area, dict):
return out
calc = area.get("계산값") or {}
for sheet in area.get("시트") or []:
cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {}
for link in sheet.get("연결") or []:
at = link.get("칸")
raw = (got.get(at) or {}).get("수")
if raw is None:
raw = (cells.get(at) or {}).get("값")
value = _number(raw)
if value is not None and link.get("키"):
out[str(link["키"])] = value
return out
def _area_of(column: Any) -> dict[str, Decimal]:
"""지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것."""
try:
path = store._path("area", str(column or ""))
except store.StoreError:
return {}
return area_values(json.loads(path.read_bytes())) if path.is_file() else {}
# ── 계산 ──────────────────────────────────────────────────────────────
def _money(value: Any) -> dict[str, Decimal] | None:
if not isinstance(value, dict):
return None
return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")}
def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None:
"""반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None."""
if qty is None:
return None
return {k: base[k] * qty / times for k in (*COSTS, "계")}
def _sum(rows: list[dict | None]) -> dict[str, Decimal]:
return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")}
def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]:
"""(수량, 출처) — 손 · 칸(알약 키) · 없음."""
if isinstance(hand, dict) and _number(hand.get("손")) is not None:
return _number(hand["손"]), "손"
if key and key in given:
return given[key], "칸"
return None, "없음"
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []}
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
lines, mats, bases, reflects = [], [], [], []
for line in result.get("줄") or []:
qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have)
base = _money(line.get("기준"))
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
bases.append(base)
reflects.append(done)
lines.append({**line, "수량": qty, "수량출처": source, "반영": done})
for one in result.get("자재") or []:
qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have)
price = _number(one.get("기준단가"))
cost = price * qty if price is not None and qty is not None else None
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
whole = m01.loaded()[1]
by_logic = {
code: pill
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
for code in pill["로직"]
}
lines = []
for one in doc.get("담은로직") or []:
code = one["로직"]
pill = by_logic.get(code) or {}
line = {
"로직": code,
"알약": pill.get("키"),
"분모": pill.get("단위"),
"배": pill.get("배") or 1,
}
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})})
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__})
continue
if "결과" in got: # 돈 아닌 로직 — 반영 없음
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
continue
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
mats = []
for one in doc.get("자재") or []:
try:
price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None)
except mf.FormulaError as e:
price, why = None, str(e)
else:
why = None if price is not None else "단가 없음"
mats.append(
{"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why}
)
have = _area_of(doc.get("열")) if given is None else given
return settle(_json({"줄": lines, "자재": mats}), doc, have)
def _json(value: Any) -> Any:
"""Decimal → JSON 수(정수면 int)."""
if isinstance(value, Decimal):
return int(value) if value == value.to_integral_value() else float(value)
if isinstance(value, list):
return [_json(v) for v in value]
if isinstance(value, dict):
return {k: _json(v) for k, v in value.items()}
return value
def server(doc: Any) -> dict[str, Any]:
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
got = clean(doc)
return {**got, "계산값": calc(got)}
+94
View File
@@ -0,0 +1,94 @@
"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김).
담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은
그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 =
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과
옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
def _num(value: Any) -> Any:
"""Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지."""
if isinstance(value, Decimal):
return int(value) if value == value.to_integral_value() else float(value)
if isinstance(value, list):
return [_num(v) for v in value]
if isinstance(value, dict):
return {k: _num(v) for k, v in value.items()}
return value
def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
"""([로직 줄 …], 빠진 로직 키) — 키 차례 그대로."""
whole = m01.loaded()[1]
found, missing = [], []
for code in codes:
try:
found.append(whole.logic(code))
except combo_store.cm.mf.FormulaError:
missing.append(code)
return found, missing
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
if not ua:
return None, [], []
row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
if row is None:
return None, [], []
found, missing = _logics(combo_store.mcb.logic_keys(row))
return row, found, missing
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"""로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
whole = m01.loaded()[1]
out = []
for logic in logics:
auto = m01.mau.values(whole, logic)
specs = []
for spec in logic.get("입력") or []:
one = {k: spec[k] for k in _SPEC if k in spec}
one.setdefault("설명", None)
one.setdefault("단위", None)
specs.append({**one, "자동값": auto.get(spec["이름"])})
out.append(
{
"로직": logic.get("키"),
"이름": logic.get("이름"),
"결과단위": logic.get("결과단위"),
"입력": specs,
}
)
return out
def logic_inputs(codes: list[str]) -> dict[str, Any]:
"""일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)"""
found, missing = _logics(list(dict.fromkeys(c for c in codes if c)))
return _num({"로직": _listed(found), "빠진로직": missing})
def inputs_of(ua: str | None) -> dict[str, Any]:
"""옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음."""
row, logics, missing = combo_logics(ua)
return _num(
{
"일위대가": ua or None,
"이름": row.get("이름") if row else None,
"로직": _listed(logics),
"빠진로직": missing,
}
)
+90
View File
@@ -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()
+64
View File
@@ -0,0 +1,64 @@
"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`.
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"])
class CalcBody(BaseModel):
"""`doc` = 저장 전 화면 문서(없으면 저장본) · `수량` = {알약 키: 값}(없으면 저장된 면적산출)."""
doc: dict[str, Any] | None = None
수량: dict[str, Any] | None = None
def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]:
got = sent if sent is not None else store.read("labor", column)["문서"]
return labor.clean({**got, "열": column})
def _calc(column: str, body: CalcBody) -> dict[str, Any]:
doc = _doc(column, body.doc)
return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)}
def _pills(column: str) -> dict[str, Any]:
return {"열": column, "알약": labor.pills(_doc(column, None))}
@router.post("/layers/{layer}/labor/{column}/calc")
def calc_labor(
layer: Layer,
column: str,
body: CalcBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""줄마다 기준 · 반영 · 합 + 알약(저장 안 함 · 화면은 최신 응답만)."""
return _call(_root(session, layer, owner), _calc, column, body)
@router.get("/layers/{layer}/labor/{column}/pills")
def labor_pills(
layer: Layer,
column: str,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본)."""
return _call(_root(session, layer, owner), _pills, column)
+99
View File
@@ -0,0 +1,99 @@
/* 넣을 값 칸(39-3 · 옛 선택 입력 CSS 에서 옮김) — 프로젝트 값 카드 · 일위대가 입력 모달이 같이 씀. 색은 테마 토큰. */
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */
.m02-inputval__wrap {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: var(--m02-struct-body-min, 16rem);
min-width: 0;
gap: var(--spacing-8);
}
.m02-inputval {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
overflow: auto;
min-height: 0;
min-width: 0;
}
.m02-inputval__places,
.m02-inputval__missing {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m02-inputval__logic {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-inputval__logic-name {
margin: 0;
color: var(--color-text);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.m02-inputval__row {
display: grid;
grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr);
gap: var(--spacing-8);
align-items: center;
min-width: 0;
}
.m02-inputval__name {
color: var(--color-text);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.m02-inputval__cell {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-8);
align-items: center;
min-width: 0;
}
.m02-inputval__control {
flex: 1 1 6rem;
min-width: 0;
max-width: 24rem;
}
/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */
.m02-inputval__value.is-auto,
.m02-inputval__control.is-auto,
.m02-inputval__control::placeholder {
color: var(--color-text-secondary);
font-style: italic;
opacity: 1;
}
.m02-inputval__state {
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
}
.m02-inputval__state.is-confirmed {
color: var(--color-text);
font-weight: 600;
}
.m02-inputval__error {
flex: 1 0 100%;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-inputval__control[aria-invalid="true"] {
border-color: var(--color-danger);
}
+316
View File
@@ -0,0 +1,316 @@
/* =============================================================================
* M02_Labor_Values.ts
* 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌:
* 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder).
* · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀)
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch";
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model";
import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model";
import "./M02_Labor_Values.css";
const fill = (text: string, values: Record<string, string | number>): string =>
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" };
const autoValue = (input: ItemInput): string | number | null => {
const auto = input.자동값;
return typeof auto === "string" || typeof auto === "number" ? auto : null;
};
export interface ValueControl {
control: HTMLInputElement | HTMLSelectElement;
/** 범위 밖 글(빈 글이면 비움) */
error: HTMLElement;
/** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */
wasBad: boolean;
}
/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */
export function createValueControl(
input: ItemInput,
value: string | number | null,
onChange: (value: string | number | null, bad: boolean) => void,
): ValueControl {
const auto = autoValue(input);
const range = rangeOf(input);
const choices = choicesOf(input);
const control = (
choices.length
? el("select", { className: "ui-select ui-select--sm m02-inputval__control" })
: el("input", {
className: "ui-input m02-inputval__control",
attrs: {
type: range ? "number" : "text",
...(range?.min !== undefined ? { min: String(range.min) } : {}),
...(range?.max !== undefined ? { max: String(range.max) } : {}),
...(range ? { step: "any" } : {}),
placeholder: auto === null ? "" : String(auto),
},
})
) as HTMLInputElement | HTMLSelectElement;
if (control instanceof HTMLSelectElement) {
control.append(
el("option", {
attrs: { value: "" },
text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`,
}),
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
);
}
const empty = value === null || value === "";
control.value = empty ? "" : String(value);
control.classList.toggle("is-auto", empty);
const error = el("span", { className: "m02-inputval__error" });
const out: ValueControl = { control, error, wasBad: false };
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => {
const raw = control.value;
const bad = isOutside(input, raw);
control.setAttribute("aria-invalid", bad ? "true" : "false");
error.textContent = bad
? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
: "";
control.classList.toggle("is-auto", raw === "");
onChange(storedOf(input, raw), bad);
});
return out;
}
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
export interface InputsValueData {
list: ItemInputs;
link: InputLinks | null;
values: ValueDoc;
}
export interface InputsValueOptions {
mode: "view" | "value";
/** mode "view" — [선택 입력 수정] */
onEdit?: () => void;
/** mode "value" — 값이 바뀔 때마다 */
onChange?: (values: ValueDoc) => void;
}
export interface InputsValueHandle {
root: HTMLElement;
/** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */
set: (data: InputsValueData | null, note?: string, retry?: () => void) => void;
/** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */
loading: () => () => void;
/** 지금 값(확인한 것 · 값이 든 것만) */
getValues: () => ValueDoc;
/** 범위 밖 값이 없나 */
isValid: () => boolean;
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
setEditLabel: (label: string) => void;
}
const VALUE_TEXT = {
title: "선택 입력",
missing: "빠진 로직 {value}",
edit: "선택 입력 수정",
none: "선택으로 이은 입력이 없음",
auto: "자동값",
unconfirmed: "확인 안 함",
confirmed: "✓ 확인함",
confirmBox: "확인",
summary: "선택 {value}",
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
noList: "입력 목록을 못 읽음 — {value}",
};
type ValueEntry = ValueDoc[string][string];
export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle {
let data: InputsValueData | null = null;
let values: ValueDoc = {};
let invalid = 0;
const summary = el("span", { className: "m02-struct__no" });
const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" });
edit.hidden = opts.mode !== "view";
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
const places = el("p", { className: "m02-inputval__places" });
const body = el("div", { className: "m02-struct__sheet m02-inputval" });
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
const shell = cardShell({
title: VALUE_TEXT.title,
sub: [summary],
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
stateHost: body,
});
shell.tools.append(edit);
const root = shell.root;
const groups = (): ReturnType<typeof pickedGroups> =>
data ? pickedGroups(data.list, data.link) : [];
const one = (logic: string, name: string): ValueEntry =>
values[logic]?.[name] ?? { 값: null, 확인: false };
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
const put = (logic: string, name: string, next: ValueEntry): void => {
(values[logic] ??= {})[name] = next;
};
const autoOf = autoValue;
const paintSummary = (): void => {
let total = 0;
let confirmed = 0;
for (const { logic, inputs } of groups())
for (const input of inputs) {
total += 1;
if (one(logic.로직, input.이름).확인) confirmed += 1;
}
summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : "";
};
/** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */
const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => {
const stored = one(logic.로직, input.이름);
const auto = autoOf(input);
const state = el("span", { className: "m02-inputval__state" });
const paintState = (): void => {
const now = one(logic.로직, input.이름);
state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed;
state.classList.toggle("is-confirmed", now.확인);
};
if (opts.mode === "view") {
const text = isEmpty(stored.값) ? auto : stored.값;
const shown = el("span", {
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
text: text === null ? "—" : String(text),
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
});
paintState();
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
}
const emit = (): void => {
paintState();
paintSummary();
opts.onChange?.(values);
};
const range = rangeOf(input);
const field = createValueControl(input, stored.값, (value, bad) => {
if (bad !== field.wasBad) invalid += bad ? 1 : -1;
field.wasBad = bad;
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad });
box.checked = value !== null && !bad;
emit();
});
const { control, error } = field;
const box = el("input", {
attrs: {
type: "checkbox",
title: VALUE_TEXT.confirmBox,
"aria-label": VALUE_TEXT.confirmBox,
},
}) as HTMLInputElement;
box.checked = stored.확인;
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
box.addEventListener("change", () => {
const now = one(logic.로직, input.이름);
if (box.checked && isEmpty(now.값) && auto !== null) {
control.value = String(auto);
control.classList.remove("is-auto");
put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true });
} else if (box.checked && isEmpty(now.값)) {
box.checked = false; // 받아들일 값이 없음
} else {
put(logic.로직, input.이름, { ...now, 확인: box.checked });
}
emit();
});
paintState();
return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] });
};
const paint = (note?: string, retry?: () => void): void => {
invalid = 0;
paintSummary();
if (!data) {
places.textContent = "";
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
}
const seats = [...seatsOf(data.link).values()];
const area = seats.filter((seat) => seat === "면적").length;
const material = seats.filter((seat) => seat === "재료").length;
places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : "";
const shown = groups();
const missing = data.list.빠진로직.length
? [
el("p", {
className: "m02-inputval__missing",
text: fill(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }),
}),
]
: [];
if (!shown.length) {
body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing);
return;
}
body.replaceChildren(
...shown.map(({ logic, inputs }) =>
el("section", {
className: "m02-inputval__logic",
attrs: { "data-logic": logic.로직 },
children: [
el("h4", {
className: "m02-inputval__logic-name",
text: logic.이름 || logic.로직,
attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` },
}),
...inputs.map((input) =>
el("div", {
className: "m02-inputval__row",
attrs: { "data-input": inputKey(logic.로직, input.이름) },
children: [
el("span", {
className: "m02-inputval__name",
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
attrs: input.설명 ? { title: input.설명 } : {},
}),
valueCell(logic, input),
],
}),
),
],
}),
),
...missing,
);
};
return {
root,
set: (next, note, retry) => {
data = next;
values = next ? structuredClone(next.values) : {};
paint(note, retry);
},
loading: () => shell.loading(),
getValues: () => {
// 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함)
const out: ValueDoc = {};
for (const [logic, byName] of Object.entries(values))
for (const [name, entry] of Object.entries(byName))
if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry;
return out;
},
isValid: () => invalid === 0,
setEditLabel: (label) => {
(edit.querySelector(".ui-btn__label") ?? edit).textContent = label;
},
};
}
@@ -0,0 +1,116 @@
/* =============================================================================
* M02_Labor_Values_Model.ts
* 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김.
* 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 ·
* 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림).
* ========================================================================== */
import type { ItemInput } from "./M02_MasterTemplete_Api_Fetch";
const text = (value: unknown): string =>
value === null || value === undefined
? ""
: typeof value === "object"
? JSON.stringify(value)
: String(value);
/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */
export function valueHint(input: ItemInput): string {
if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / ");
const range = input.범위;
if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`;
if (range && typeof range === "object") {
const r = range as Record<string, unknown>;
const lo = r.최소 ?? r.min;
const hi = r.최대 ?? r.max;
if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`;
}
return text(range);
}
/** 자동값 글 — 없으면 빈 글 */
export const autoText = (input: ItemInput): string => text(input.자동값);
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
export type ValueDoc = Record<
string,
Record<string, { 값: string | number | null; 확인: boolean }>
>;
const number = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */
export function rangeOf(input: ItemInput): { min?: number; max?: number } | null {
const raw = input.범위;
if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) };
if (raw && typeof raw === "object") {
const r = raw as Record<string, unknown>;
return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) };
}
return null;
}
/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */
export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] {
return (input.고르기 ?? []).map((one) => {
const object =
typeof one === "object" && one !== null ? (one as Record<string, unknown>) : null;
const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number;
return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw };
});
}
/** 범위 밖 글인가 — 빈 글 · 범위 없는 입력은 아님 */
export function isOutside(input: ItemInput, raw: string): boolean {
const range = rangeOf(input);
if (!range || raw.trim() === "") return false;
const value = Number(raw);
return (
!Number.isFinite(value) ||
(range.min !== undefined && value < range.min) ||
(range.max !== undefined && value > range.max)
);
}
/** 칸 글 → 저장 값 — 빈 글 = null(자동값) · 범위 = 수 · 고르기 = 원래 모양 · 그 밖 = 글 */
export function storedOf(input: ItemInput, raw: string): string | number | null {
if (raw === "") return null;
if (rangeOf(input)) return Number(raw);
return choicesOf(input).find((c) => c.값 === raw)?.raw ?? raw;
}
/* ── 일위대가 입력값(39-3) — 로직 한 줄의 `{입력 이름: 값}` ───────────────────────────── */
export type LaborValues = Record<string, string | number>;
/** 로직 입력 목록에 있는 이름 · 빈 값 아닌 것만 남김 + 사라진 이름(로직 입력이 바뀌어 버려질 값) */
export function keepKnown(
inputs: ItemInput[],
values: LaborValues | null | undefined,
): { kept: LaborValues; lost: string[] } {
const names = new Set(inputs.map((i) => i.이름));
const kept: LaborValues = {};
const lost: string[] = [];
for (const [name, value] of Object.entries(values ?? {})) {
if (!names.has(name)) lost.push(name);
else if (value !== "" && value !== null && value !== undefined) kept[name] = value;
}
return { kept, lost };
}
/** 줄 아래 작은 글 — 「지역 서울 · 규격 25mm」(넣은 값만 · 입력 차례) + 빈 값 수(자동값도 없는 입력) */
export function valueLine(
inputs: ItemInput[],
values: LaborValues | null | undefined,
): { text: string; empty: number } {
const parts: string[] = [];
let empty = 0;
for (const input of inputs) {
const value = values?.[input.이름];
if (value !== undefined && value !== "") {
parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`);
} else if (autoText(input) === "") empty += 1;
}
return { text: parts.join(" · "), empty };
}
+22 -17
View File
@@ -1,13 +1,12 @@
"""M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀
(PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`.
「회사 C 를 맡음」(`manages` · 39-6) = SYSTEM_ADMIN · 또는 C 의 회사 관리자(ADMIN · 마스터).
system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN
company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) ·
남의 회사 = SYSTEM_ADMIN 읽기만
personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 ·
다른 회사 = SYSTEM_ADMIN 읽기만
owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) ·
읽기로 남의 층에 뼈대를 안 만듦.
company {C} 읽기 = C 사람 · SYSTEM_ADMIN · 수정 = C 를 맡은 사람
personal {C}/{U} 본인 = 읽기 · 수정 · 남 = C 를 맡은 사람만 읽기 · 수정(일반 = 403)
owner 글 없음 = 자기 층 · 남의 층 읽기는 그 층 폴더가 있을 때만(없으면 404) ·
읽기로 남의 층에 뼈대를 안 만듦(쓰기는 부르는 쪽이 소속을 본 뒤 `folder(…, create=True)`).
"""
from __future__ import annotations
@@ -33,6 +32,14 @@ def is_company_admin(session: dict[str, Any]) -> bool:
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
def manages(session: dict[str, Any], company: Any) -> bool:
"""회사 `company` 를 맡음 — 그 회사 층 · 그 회사 모든 개인 층을 보고 고침."""
if is_system_admin(session):
return True
own = session.get("company_id")
return own is not None and str(own) == str(company) and is_company_admin(session)
@dataclass(frozen=True)
class Grant:
"""층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨)."""
@@ -63,15 +70,14 @@ def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Gran
raise HTTPException(status_code=409, detail=NO_COMPANY)
if not _SHAPE[layer].match(who):
raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」")
company = who.split("/")[0]
if who == own:
return Grant(layer, who, layer == "personal" or is_company_admin(session), True)
same_company = who.split("/")[0] == str(session.get("company_id"))
colleague = layer == "personal" and same_company and is_company_admin(session)
if not (is_system_admin(session) or colleague):
return Grant(layer, who, layer == "personal" or manages(session, company), True)
if not manages(session, company):
raise HTTPException(
status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음"
)
return Grant(layer, who, False, False)
return Grant(layer, who, True, False)
def require(
@@ -82,16 +88,15 @@ def require(
if write and not got.write:
if layer == "system":
detail = "마스터 템플릿은 시스템 관리자만 고침."
elif got.mine:
detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
else:
detail = "남의 층은 읽기만."
detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
raise HTTPException(status_code=403, detail=detail)
return got
def folder(got: Grant) -> Path:
"""회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦)."""
def folder(got: Grant, create: bool = False) -> Path:
"""회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함 · `create` = 쓰기(부르는 쪽이 소속을
본 뒤 뼈대를 만듦)."""
if got.owner is None:
raise HTTPException(status_code=409, detail=NO_COMPANY)
try:
@@ -102,6 +107,6 @@ def folder(got: Grant) -> Path:
path = layers.personal_dir(company, user)
except ValueError as error:
raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error
if not got.mine and not path.is_dir():
if not (got.mine or create) and not path.is_dir():
raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음")
return path
@@ -8,8 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend";
/** 층 이름 — 서버와 같은 글 */
export type Layer = "system" | "company" | "personal" | "project";
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) */
export type Kind = "table" | "drawing" | "structure" | "area";
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
* 「일위대가」 = labor(열마다 문서 · 39장) */
export type Kind = "table" | "drawing" | "structure" | "area" | "labor";
export interface TemplateInfo {
종류: Kind;
@@ -152,6 +153,8 @@ export const createStructure = (column: string): Promise<TemplateDoc> =>
export interface StructureNumbers {
도번: string;
면적번: string;
/** 일위대가 `SU-######`(39장) */
일위대가번: string;
}
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
@@ -168,8 +171,6 @@ export interface StructureCombo extends StructureNumbers {
분류3: string | null;
단위: string | null;
일위대가: string | null;
/** 자재 조합 키 `UM` */
자재조합: string | null;
바인딩: unknown;
펼침: unknown;
}
@@ -241,6 +242,20 @@ export const copyStructure = async (column: string, from: string): Promise<void>
await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 면적산출 알약 하나 — 로직 물량 「이름|단위」(`로직` · `배`) · 자재 「자재|MT키」(`자재`) */
export interface LaborPill {
키: string;
이름: string;
단위: string;
로직?: string[];
배?: number;
자재?: { 코드: string; 이름: string; 규격: string; 단위: string };
}
/** 그 열 일위대가의 면적산출 알약(저장본 · PLAN 39-1a pills 길) — 지금 층 */
export const fetchLaborPills = (column: string): Promise<{ 열: string; 알약: LaborPill[] }> =>
call(owned(activeLayer, `/layers/${activeLayer}/labor/${enc(column)}/pills`));
/** 다른 열(`from`) 면적산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 면적번 · 열은 그대로 */
export const copyArea = async (column: string, from: string): Promise<void> => {
await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
@@ -445,9 +460,11 @@ export const pasteTree = (
export interface LayerOwner {
owner: string;
name: string;
/** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */
비어있음?: boolean;
}
/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */
/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */
export const fetchOwners = (
layer: "company" | "personal",
company: string | null = null,
@@ -0,0 +1,107 @@
/* =============================================================================
* M02_MasterTemplete_AreaLink.ts
* 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶.
* 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기.
* 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라
* 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장].
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
const TEXT = {
title: "칸 지정 — {value}",
close: "닫기",
now: "지금 연결",
none: "연결 없음",
picked: "지금 고른 칸으로 ({value})",
address: "칸 주소",
hint: "예 B6",
link: "잇기",
unlink: "풀기",
bad: "칸 주소가 틀림 — 예 B6",
readOnly: "읽기만 — 고칠 수 없음",
};
const fill = (text: string, value: string): string => text.replace("{value}", value);
export interface AreaLinkOptions {
/** 줄 이름(모달 제목) */
label: string;
/** 알약 키 */
key: string;
/** 연결에 같이 적을 이름 · 단위 · 자재 */
info: LinkInfo;
sheet: SpreadsheetHandle;
/** 시트에서 지금 고른 칸(A1) */
picked: () => string;
readOnly: boolean;
}
/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */
function linkedText(opts: AreaLinkOptions): string | null {
const link = opts.sheet
.getDoc()
.시트.flatMap((s) => s.연결 ?? [])
.find((l) => l.키 === opts.key);
if (!link) return null;
const value = opts.sheet.getLinkedValues()[opts.key];
return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim();
}
export function openAreaLink(opts: AreaLinkOptions): void {
const { close } = openModal({
title: fill(TEXT.title, opts.label),
closeLabel: TEXT.close,
dialogClass: "m02-modal m02-area-link",
mount: (body) => {
const now = el("p", { className: "m02-area-link__now" });
const paint = (): void => {
const text = linkedText(opts);
now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`;
unlink.disabled = opts.readOnly || !text;
};
const done = (cell: string | null): boolean => {
if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false;
close();
return true;
};
const here = opts.picked();
const pick = createButton({
label: fill(TEXT.picked, here),
variant: "filled",
size: "sm",
onClick: () => void done(here),
});
const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint });
const link = createButton({
label: TEXT.link,
variant: "ghost",
size: "sm",
onClick: () => {
if (!done(address.input.value)) address.setError(TEXT.bad);
},
});
const unlink = createButton({
label: TEXT.unlink,
variant: "ghost",
size: "sm",
onClick: () => void done(null),
});
address.input.addEventListener("input", () => address.setError(undefined));
address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click());
pick.disabled = link.disabled = address.input.disabled = opts.readOnly;
paint();
body.append(
now,
...(opts.readOnly
? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })]
: []),
el("div", { className: "m02-area-link__row", children: [pick, unlink] }),
el("div", { className: "m02-area-link__row", children: [address.root, link] }),
);
if (!opts.readOnly) address.input.focus();
},
});
}
@@ -1,98 +0,0 @@
"""일위대가 조합 층 사이 복사(PLAN 30-3) — 읽을 수 있는 층의 조합 · 폴더를 쓸 수 있는 층에 붙여넣기.
정함(30장): 복사본 = 대상 층의 새 키(마스터 UA · 회사 UC · 개인 UP) · 본뜬키 = 원본 키 ·
같은 이름이면 「이름 (2)」 · 담은 로직 그대로 · 폴더 복사는 안의 조합 · 빈 폴더까지.
붙는 자리 = `경로`(대상 폴더 · 깊이 2 안) — 고른 조합은 그 폴더에 · 고른 폴더는 그 아래 한 단으로.
마스터에 붙이면 M01 저장 길(대장 UA) · 본뜬키는 UA 만(마스터 틀) — 다른 층 원본이면 비움.
권한은 라우터 몫(원본 층 읽기 · 대상 층 쓰기) · 대상 층 = `store.at(대상)`.
"""
from __future__ import annotations
from typing import Any
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Store as store
def _free_name(name: str, taken: set[str]) -> str:
out, n = name, 2
while out in taken:
out, n = f"{name} ({n})", n + 1
taken.add(out)
return out
def _paths(value: Any, what: str) -> list[list[str]]:
if not isinstance(value, list) or not all(isinstance(p, list) for p in value):
raise store.StoreError(422, f"「{what}」 이 폴더 경로 목록이 아님")
return [[str(x) for x in p] for p in value]
def picked(source: dict, keys: list[str], folders: list[list[str]], where: list[str]):
"""(붙일 조합 [(원본 줄, 새 경로)], 새 빈 폴더) — 깊이 2 넘으면 422 · 없는 키 · 폴더 404."""
have = combos.folders_of(source)
rows = {str(r.get("키")): r for r in source.get("줄") or []}
out: dict[str, tuple[dict, list[str]]] = {}
made: list[list[str]] = []
for folder in folders:
if folder not in have:
raise store.StoreError(404, f"없는 폴더 「{' › '.join(folder)}」")
cut = len(folder) - 1 # 고른 폴더부터 대상 아래로
made += [where + f[cut:] for f in have if f[: len(folder)] == folder]
for key, row in rows.items():
path = combos.path_of(row)
if path[: len(folder)] == folder:
out[key] = (row, where + path[cut:])
for key in keys:
if key not in rows:
raise store.StoreError(404, f"없는 조합 「{key}」")
out.setdefault(key, (rows[key], list(where)))
if any(len(p) > combos.DEPTH for p in [*made, *(p for _, p in out.values())]):
raise store.StoreError(422, f"폴더는 {combos.DEPTH} 단까지 — 더 얕은 폴더에 붙여 넣음")
return list(out.values()), made
def paste(source: dict, body: dict) -> dict[str, Any]:
"""`source` = 원본 층 표(부르는 쪽이 원본 층에서 읽음) · `body` = `{키?, 폴더?, 경로?, 판}` →
`{…대상 층 목록, 새키: [원본 → 새 키]}`."""
keys = [str(k) for k in body.get("키") or []]
folders = _paths(body.get("폴더") or [], "폴더")
where = [str(x) for x in body.get("경로") or []]
if not keys and not folders:
raise store.StoreError(422, "붙여 넣을 조합 · 폴더가 없음")
doc, have = combos._load()
if have != (body.get("판") or ""):
raise store.StoreError(409, {"stale": [combos.FILE], "판": have})
if where and where not in combos.folders_of(doc):
raise store.StoreError(404, f"없는 폴더 「{' › '.join(where)}」")
items, made = picked(source, keys, folders, where)
taken = {str(r.get("이름") or "").strip() for r in doc["줄"]}
master = combos.heads()[-1] == combos.MASTER
rows = []
for row, path in items:
one = {k: row.get(k) for k in combos.mcb.COLS if k != "키"}
one["이름"] = _free_name(str(row.get("이름") or "").strip(), taken)
one["본뜬키"] = str(row.get("키"))
if master and not one["본뜬키"].startswith(combos.MASTER):
one["본뜬키"] = None # 마스터 틀은 UA 계보만
combos._set_path(one, path)
rows.append(one)
folders_after = combos.folders_of({**doc, "폴더": [*combos.folders_of(doc), *made]})
if master:
new = [combos.create(one)["키"] for one in rows] # M01 저장 길(검사 · 대장) — 하나씩
_, version = combos._load()
combos._write_master_folders(folders_after, version)
else:
number = int(doc.get("다음") or 1)
fixed = []
for at, one in enumerate(rows):
fixed.append(combos._clean(one, f"{combos.heads()[-1]}{number + at:06d}"))
new_doc = {**doc, "다음": number + len(fixed), "폴더": folders_after}
new_doc["줄"] = [*doc["줄"], *fixed]
for one in fixed:
combos._check(new_doc, one)
combos._save(new_doc, have)
new = [one["키"] for one in fixed]
pairs = [{"원본": str(row.get("키")), "새키": key} for (row, _), key in zip(items, new)]
return {**combos.listing(), "새키": pairs}
@@ -7,13 +7,12 @@
개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######`
회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀).
지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫.
나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함).
목록 `폴더` = [구분] · [구분, 상세구분](읽기만 · 나무 고침 · 붙여넣기 길은 39-2 에서 지움).
"""
from __future__ import annotations
import json
import os
import re
from datetime import date
from pathlib import Path
@@ -127,18 +126,13 @@ def _find(doc: dict, key: str) -> dict[str, Any]:
return row
# ── 나무 폴더 ─────────────────────────────────────────────────────────
# ── 폴더(읽기만) ───────────────────────────────────────────────────────
def path_of(row: dict) -> list[str]:
"""조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리."""
a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분"))
return [a, b] if a and b else [a] if a else []
def _set_path(row: dict, path: list[str]) -> None:
row["구분"] = path[0] if path else None
row["상세구분"] = path[1] if len(path) > 1 else None
def folders_of(doc: dict) -> list[list[str]]:
"""폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지."""
out: list[list[str]] = []
@@ -190,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]:
"""서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든)."""
@@ -289,6 +277,21 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]:
return {"키": key, "판": _save(doc, version), "combo": fixed}
def drop_practice(version: str) -> dict[str, Any]:
"""실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 ·
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만."""
if heads()[-1] != MASTER:
raise store.StoreError(400, "실무문서는 마스터 층에만 있음")
doc, have = _load()
if have != (version or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
keys = [str(r.get("키")) for r in doc.get("줄") or []]
_refuse_used(keys)
if keys:
_m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have)
return {**listing(), "지운조합": keys}
def delete(key: str, version: str) -> dict[str, Any]:
"""지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음)."""
if heads()[-1] == MASTER:
@@ -297,128 +300,3 @@ def delete(key: str, version: str) -> dict[str, Any]:
_find(doc, key)
_refuse_used([key])
return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)}
# ── 나무 고침 ─────────────────────────────────────────────────────────
def _moved(path: list[str], old: list[str], new: list[str]) -> list[str]:
return new + path[len(old) :] if path[: len(old)] == old else path
def _folder(folders: list[list[str]], path: list[str] | None, what: str) -> list[str]:
path = [str(x) for x in path or []]
if not path or path not in folders:
raise store.StoreError(404, f"{what} 폴더 「{' › '.join(path)}」 없음")
return path
def _fresh(name: Any) -> str:
text = str(name or "").strip()
if not text or "›" in text or len(text) > mcb.MAX_이름:
raise store.StoreError(422, "폴더 이름 — 빈 이름 · 「›」 · 너무 긴 이름은 못 씀")
return text
def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
"""나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫."""
op, key = body.get("op"), body.get("키")
folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []]
gone: list[str] = []
target = [str(x) for x in body.get("새경로") or []]
if op in ("move", "add-folder") and target and target not in folders:
raise store.StoreError(404, f"없는 폴더 「{' › '.join(target)}」")
if op == "add-folder":
made = [*[str(x) for x in body.get("경로") or []], _fresh(body.get("이름"))]
if len(made) > DEPTH or (made[:-1] and made[:-1] not in folders):
raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 있는 폴더 아래에만")
if made in folders:
raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」")
folders.append(made)
elif op == "rename" and key:
_find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름"))
elif op == "move" and key:
_set_path(_find({"줄": rows}, str(key)), target)
elif op == "delete" and key:
_find({"줄": rows}, str(key))
gone = [str(key)]
elif op in ("rename", "move", "delete"):
old = _folder(folders, body.get("경로"), "고칠")
if op == "delete":
gone = [str(r["키"]) for r in rows if path_of(r)[: len(old)] == old]
folders = [f for f in folders if f[: len(old)] != old]
else:
new = [*old[:-1], _fresh(body.get("이름"))] if op == "rename" else [*target, old[-1]]
deepest = max(len(f) for f in folders if f[: len(old)] == old)
if new[: len(old)] == old or deepest - len(old) + len(new) > DEPTH:
raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 제 안으로 못 옮김")
if new in folders:
raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(new)}」")
folders = [_moved(f, old, new) for f in folders]
for r in rows:
if path_of(r)[: len(old)] == old:
_set_path(r, _moved(path_of(r), old, new))
else:
raise store.StoreError(
422, f"모르는 나무 고침 「{op}」 — add-folder · rename · move · delete"
)
rows = [r for r in rows if str(r.get("키")) not in gone]
return {**doc, "폴더": folders, "줄": rows}, gone
def edit_tree(body: dict) -> dict[str, Any]:
"""폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 ·
조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422."""
return edit_tree_many({"판": body.get("판"), "ops": [body]})
def edit_tree_many(body: dict) -> dict[str, Any]:
"""나무 고침 여럿을 한 번에(PLAN 30-3 · 여러 개 옮기기 · 지우기) — `{판, ops: [고침 …]}` ·
차례대로 적용 · 하나라도 막히면 아무것도 안 씀 · 저장 한 번."""
doc, have = _load()
if have != (body.get("판") or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
ops = body.get("ops")
if not isinstance(ops, list) or not ops:
raise store.StoreError(422, "나무 고침 목록(ops)이 비었음")
new, gone = doc, []
for op in ops:
new, got = _apply(new, op if isinstance(op, dict) else {})
gone += got
_refuse_used(gone)
renamed = [str(op["키"]) for op in ops if op.get("op") == "rename" and op.get("키")]
commit(doc, have, new, gone, renamed)
return {**listing(), "지운조합": gone}
def commit(doc: dict, have: str, new: dict, gone: list[str], checked: list[str] = ()) -> None:
"""고친 표 한 벌 저장 — 회사 · 개인 = 파일 한 번(`checked` 조합은 이름 검사) ·
마스터 = 줄은 M01 저장 길 · 머리 `폴더` 따로."""
if heads()[-1] != MASTER:
for key in checked:
if key in {str(r.get("키")) for r in new["줄"]}:
if errors := _name_errors(new, _find(new, key)):
raise store.StoreError(422, {"errors": errors})
_save(new, have)
return
was = {str(r["키"]): r for r in doc["줄"]}
changes = [{"op": "delete", "key": k} for k in gone] + [
{"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r
]
version = _m01(combo_store._write, changes, have)["version"] if changes else have
_write_master_folders(new["폴더"], version)
def _write_master_folders(folders: list[list[str]], version: str) -> None:
"""마스터 조합 파일 머리 `폴더` — 줄은 안 건드림(줄 고침은 M01 저장 길)."""
path = Path(m01.FOLDER) / FILE
with m01._LOCK:
doc, have = m01.read(FILE)
if have != version:
raise store.StoreError(409, {"stale": [FILE], "판": have})
if doc.get("폴더") == folders:
return
head = {k: v for k, v in doc.items() if k != "줄"}
text = m01.dump({**head, "폴더": folders, "줄": doc.get("줄") or []})
temporary = path.with_name(f".{FILE}.tmp")
temporary.write_text(text, encoding="utf-8", newline="\n")
os.replace(temporary, path)
m01.cache.stale([FILE])
@@ -103,101 +103,3 @@
.m02-inputs__seat:disabled {
opacity: 0.7;
}
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */
.m02-inputval__wrap {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: var(--m02-struct-body-min, 16rem);
min-width: 0;
gap: var(--spacing-8);
}
.m02-inputval {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
overflow: auto;
min-height: 0;
min-width: 0;
}
.m02-inputval__places,
.m02-inputval__missing {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m02-inputval__logic {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-inputval__logic-name {
margin: 0;
color: var(--color-text);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.m02-inputval__row {
display: grid;
grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr);
gap: var(--spacing-8);
align-items: center;
min-width: 0;
}
.m02-inputval__name {
color: var(--color-text);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.m02-inputval__cell {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-8);
align-items: center;
min-width: 0;
}
.m02-inputval__control {
flex: 1 1 6rem;
min-width: 0;
max-width: 24rem;
}
/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */
.m02-inputval__value.is-auto,
.m02-inputval__control.is-auto,
.m02-inputval__control::placeholder {
color: var(--color-text-secondary);
font-style: italic;
opacity: 1;
}
.m02-inputval__state {
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
}
.m02-inputval__state.is-confirmed {
color: var(--color-text);
font-weight: 600;
}
.m02-inputval__error {
flex: 1 0 100%;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-inputval__control[aria-invalid="true"] {
border-color: var(--color-danger);
}
@@ -14,30 +14,18 @@ M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) ·
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics
PLACES = ("면적", "재료", "선택", None)
PILL_HEAD = "입력"
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
def _num(value: Any) -> Any:
"""Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지."""
if isinstance(value, Decimal):
return int(value) if value == value.to_integral_value() else float(value)
if isinstance(value, list):
return [_num(v) for v in value]
if isinstance(value, dict):
return {k: _num(v) for k, v in value.items()}
return value
def pill_key(logic: str, name: str) -> str:
@@ -56,55 +44,10 @@ def _combo_row(ua: str) -> dict[str, Any] | None:
return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
if not ua:
return None, [], []
row = _combo_row(str(ua))
if row is None:
return None, [], []
whole = m01.loaded()[1]
found, missing = [], []
for one in combo_store.mcb.items(row):
code = str(one.get("로직") or "") if isinstance(one, dict) else ""
try:
found.append(whole.logic(code))
except combo_store.cm.mf.FormulaError:
missing.append(code)
return row, found, missing
def inputs(key: str) -> dict[str, Any]:
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
일위대가 없는 항목은 로직 빈 목록."""
ua = _item(key).get("일위대가")
row, logics, missing = combo_logics(ua)
whole = m01.loaded()[1]
out = []
for logic in logics:
auto = m01.mau.values(whole, logic)
specs = []
for spec in logic.get("입력") or []:
one = {k: spec[k] for k in _SPEC if k in spec}
one.setdefault("설명", None)
one.setdefault("단위", None)
specs.append({**one, "자동값": auto.get(spec["이름"])})
out.append(
{
"로직": logic.get("키"),
"이름": logic.get("이름"),
"결과단위": logic.get("결과단위"),
"입력": specs,
}
)
return _num(
{
"일위대가": ua or None,
"이름": row.get("이름") if row else None,
"로직": out,
"빠진로직": missing,
}
)
일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌)."""
return labor_inputs.inputs_of(_item(key).get("일위대가"))
# ── 항목 연결 파일 ─────────────────────────────────────
+8 -291
View File
@@ -10,7 +10,7 @@
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { el } from "@ui/ui_template_elements";
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
import {
fetchItemInputs,
@@ -18,28 +18,21 @@ import {
getActiveLayer,
readInputLinks,
saveInputLinks,
type InputLinks,
type InputSeat,
type ItemInput,
type ItemInputs,
type ItemLogic,
type ItemRow,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import {
autoText,
choicesOf,
countOf,
inputKey,
linesOf,
pickedGroups,
pillsOf,
rangeOf,
seatsOf,
SEAT_OPTIONS,
valueHint,
type SeatMap,
type ValueDoc,
} from "./M02_MasterTemplete_Inputs_Model";
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
import "./M02_MasterTemplete_Inputs.css";
@@ -288,286 +281,10 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro
}
}
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
export interface InputsValueData {
list: ItemInputs;
link: InputLinks | null;
values: ValueDoc;
}
export interface InputsValueOptions {
mode: "view" | "value";
/** mode "view" — [선택 입력 수정] */
onEdit?: () => void;
/** mode "value" — 값이 바뀔 때마다 */
onChange?: (values: ValueDoc) => void;
}
export interface InputsValueHandle {
root: HTMLElement;
/** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */
set: (data: InputsValueData | null, note?: string, retry?: () => void) => void;
/** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */
loading: () => () => void;
/** 지금 값(확인한 것 · 값이 든 것만) */
getValues: () => ValueDoc;
/** 범위 밖 값이 없나 */
isValid: () => boolean;
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
setEditLabel: (label: string) => void;
}
const VALUE_TEXT = {
edit: "선택 입력 수정",
none: "선택으로 이은 입력이 없음",
auto: "자동값",
unconfirmed: "확인 안 함",
confirmed: "✓ 확인함",
confirmBox: "확인",
outOfRange: "범위 {value} 밖",
summary: "선택 {value}",
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
noList: "입력 목록을 못 읽음 — {value}",
};
type ValueEntry = ValueDoc[string][string];
export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle {
let data: InputsValueData | null = null;
let values: ValueDoc = {};
let invalid = 0;
const summary = el("span", { className: "m02-struct__no" });
const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" });
edit.hidden = opts.mode !== "view";
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
const places = el("p", { className: "m02-inputval__places" });
const body = el("div", { className: "m02-struct__sheet m02-inputval" });
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
const shell = cardShell({
title: TEXT.title,
sub: [summary],
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
stateHost: body,
});
shell.tools.append(edit);
const root = shell.root;
const groups = (): ReturnType<typeof pickedGroups> =>
data ? pickedGroups(data.list, data.link) : [];
const one = (logic: string, name: string): ValueEntry =>
values[logic]?.[name] ?? { 값: null, 확인: false };
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
const put = (logic: string, name: string, next: ValueEntry): void => {
(values[logic] ??= {})[name] = next;
};
const autoOf = (input: ItemInput): string | number | null => {
const auto = input.자동값;
return typeof auto === "string" || typeof auto === "number" ? auto : null;
};
const paintSummary = (): void => {
let total = 0;
let confirmed = 0;
for (const { logic, inputs } of groups())
for (const input of inputs) {
total += 1;
if (one(logic.로직, input.이름).확인) confirmed += 1;
}
summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : "";
};
/** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */
const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => {
const stored = one(logic.로직, input.이름);
const auto = autoOf(input);
const state = el("span", { className: "m02-inputval__state" });
const paintState = (): void => {
const now = one(logic.로직, input.이름);
state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed;
state.classList.toggle("is-confirmed", now.확인);
};
if (opts.mode === "view") {
const text = isEmpty(stored.값) ? auto : stored.값;
const shown = el("span", {
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
text: text === null ? "—" : String(text),
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
});
paintState();
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
}
const emit = (): void => {
paintState();
paintSummary();
opts.onChange?.(values);
};
const range = rangeOf(input);
const choices = choicesOf(input);
const control = (
choices.length
? el("select", { className: "ui-select ui-select--sm m02-inputval__control" })
: el("input", {
className: "ui-input m02-inputval__control",
attrs: {
type: range ? "number" : "text",
...(range?.min !== undefined ? { min: String(range.min) } : {}),
...(range?.max !== undefined ? { max: String(range.max) } : {}),
...(range ? { step: "any" } : {}),
placeholder: auto === null ? "" : String(auto),
},
})
) as HTMLInputElement | HTMLSelectElement;
const error = el("span", { className: "m02-inputval__error" });
const box = el("input", {
attrs: {
type: "checkbox",
title: VALUE_TEXT.confirmBox,
"aria-label": VALUE_TEXT.confirmBox,
},
}) as HTMLInputElement;
if (control instanceof HTMLSelectElement) {
control.append(
el("option", {
attrs: { value: "" },
text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`,
}),
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
);
}
control.value = isEmpty(stored.값) ? "" : String(stored.값);
control.classList.toggle("is-auto", isEmpty(stored.값));
box.checked = stored.확인;
const outside = (raw: string): boolean => {
if (!range || raw.trim() === "") return false;
const value = Number(raw);
return (
!Number.isFinite(value) ||
(range.min !== undefined && value < range.min) ||
(range.max !== undefined && value > range.max)
);
};
let wasBad = false;
const changed = (): void => {
const raw = control.value;
const bad = outside(raw);
if (bad !== wasBad) invalid += bad ? 1 : -1;
wasBad = bad;
control.setAttribute("aria-invalid", bad ? "true" : "false");
error.textContent = bad
? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
: "";
control.classList.toggle("is-auto", raw === "");
const value: string | number | null =
raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw);
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad });
box.checked = raw !== "" && !bad;
emit();
};
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed);
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
box.addEventListener("change", () => {
const now = one(logic.로직, input.이름);
if (box.checked && isEmpty(now.값) && auto !== null) {
control.value = String(auto);
control.classList.remove("is-auto");
put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true });
} else if (box.checked && isEmpty(now.값)) {
box.checked = false; // 받아들일 값이 없음
} else {
put(logic.로직, input.이름, { ...now, 확인: box.checked });
}
emit();
});
paintState();
return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] });
};
const paint = (note?: string, retry?: () => void): void => {
invalid = 0;
paintSummary();
if (!data) {
places.textContent = "";
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
}
const seats = [...seatsOf(data.link).values()];
const area = seats.filter((seat) => seat === "면적").length;
const material = seats.filter((seat) => seat === "재료").length;
places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : "";
const shown = groups();
const missing = data.list.빠진로직.length
? [
el("p", {
className: "m02-inputval__missing",
text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }),
}),
]
: [];
if (!shown.length) {
body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing);
return;
}
body.replaceChildren(
...shown.map(({ logic, inputs }) =>
el("section", {
className: "m02-inputval__logic",
attrs: { "data-logic": logic.로직 },
children: [
el("h4", {
className: "m02-inputval__logic-name",
text: logic.이름 || logic.로직,
attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` },
}),
...inputs.map((input) =>
el("div", {
className: "m02-inputval__row",
attrs: { "data-input": inputKey(logic.로직, input.이름) },
children: [
el("span", {
className: "m02-inputval__name",
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
attrs: input.설명 ? { title: input.설명 } : {},
}),
valueCell(logic, input),
],
}),
),
],
}),
),
...missing,
);
};
return {
root,
set: (next, note, retry) => {
data = next;
values = next ? structuredClone(next.values) : {};
paint(note, retry);
},
loading: () => shell.loading(),
getValues: () => {
// 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함)
const out: ValueDoc = {};
for (const [logic, byName] of Object.entries(values))
for (const [name, entry] of Object.entries(byName))
if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry;
return out;
},
isValid: () => invalid === 0,
setEditLabel: (label) => {
(edit.querySelector(".ui-btn__label") ?? edit).textContent = label;
},
};
}
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */
export {
createInputsValueCard,
type InputsValueData,
type InputsValueHandle,
type InputsValueOptions,
} from "./M02_Labor_Values";
@@ -1,6 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Inputs_Model.ts
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글.
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
* 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄).
* 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` ·
* 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음.
* ========================================================================== */
@@ -78,62 +79,11 @@ export function pillsOf(
return out;
}
const text = (value: unknown): string =>
value === null || value === undefined
? ""
: typeof value === "object"
? JSON.stringify(value)
: String(value);
/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */
export function valueHint(input: ItemInput): string {
if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / ");
const range = input.범위;
if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`;
if (range && typeof range === "object") {
const r = range as Record<string, unknown>;
const lo = r.최소 ?? r.min;
const hi = r.최대 ?? r.max;
if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`;
}
return text(range);
}
/** 자동값 글 — 없으면 빈 글 */
export const autoText = (input: ItemInput): string => text(input.자동값);
/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */
export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model";
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
export type ValueDoc = Record<
string,
Record<string, { 값: string | number | null; 확인: boolean }>
>;
const number = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */
export function rangeOf(input: ItemInput): { min?: number; max?: number } | null {
const raw = input.범위;
if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) };
if (raw && typeof raw === "object") {
const r = raw as Record<string, unknown>;
return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) };
}
return null;
}
/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */
export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] {
return (input.고르기 ?? []).map((one) => {
const object =
typeof one === "object" && one !== null ? (one as Record<string, unknown>) : null;
const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number;
return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw };
});
}
/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */
export function pickedGroups(
list: ItemInputs,
@@ -13,6 +13,7 @@
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type {
LinkOptions,
SpreadsheetHandle,
@@ -60,6 +61,13 @@ const TEXT = {
readFailed: "{value} 파일을 못 읽음 — {why}",
discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?",
sheetHelp: "단축키 — 칸을 고르고 바로 침",
pills: "알약",
pillsTip: "아래 알약 줄 보임 · 숨김",
take: "복사해 가져오기",
takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로",
blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴",
makeNew: "새로 만들기",
makeCopy: "복사 생성",
};
/** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */
@@ -76,6 +84,11 @@ const SHEET_KEYS: [string, string][] = [
];
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */
const emptyBook = (book: Workbook): boolean =>
(book.시트 ?? []).every(
(s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식),
);
const fill = (text: string, value: string): string => text.replace("{value}", value);
/** 스프레드시트는 면적산출 카드를 열 때만 받음 */
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
@@ -97,6 +110,8 @@ export interface ItemCardsOptions {
onDirty?: (dirty: boolean) => void;
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
sheetEdit?: SheetEditOptions;
/** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */
onImport?: (kind: EditKind) => void;
}
/** 카드 스프레드시트를 다루는 손잡이 — 알약이 씀 */
@@ -149,6 +164,8 @@ interface Card {
saver: SaveControl;
help: HTMLElement;
edit: HTMLButtonElement;
/** 「복사해 가져오기」 — 찬 카드만 */
take: HTMLButtonElement;
no: HTMLElement;
name: HTMLElement;
}
@@ -160,6 +177,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
let docVersion: string | null = null;
let preview: DrawingCardHandle | null = null;
let seq = 0;
/** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */
const started = new Set<EditKind>();
const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? "";
const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());
@@ -187,8 +206,44 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
size: "sm",
});
edit.addEventListener("click", () => opts.onEdit(kind));
shell.tools.append(help, saver.button, edit);
return { kind, shell, saver, help, edit, no, name };
const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" });
take.title = fill(TEXT.takeTip, PART_WORD[kind]);
take.addEventListener("click", () => opts.onImport?.(kind));
shell.tools.append(help, saver.button, take, edit);
return { kind, shell, saver, help, edit, take, no, name };
};
/** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */
const blank = (c: Card, empty: boolean): boolean => {
const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind);
c.take.hidden = show || !opts.editable() || !opts.onImport;
return show;
};
/** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */
const startBox = (c: Card, paint: () => void): HTMLElement => {
const fresh = createButton({
label: TEXT.makeNew,
variant: "ghost",
size: "sm",
onClick: () => {
started.add(c.kind);
paint();
},
});
const copy = createButton({
label: TEXT.makeCopy,
variant: "ghost",
size: "sm",
onClick: () => opts.onImport?.(c.kind),
});
copy.hidden = !opts.onImport;
return el("div", {
className: "m02-struct__start",
children: [
el("p", { className: "m02-struct__empty", text: TEXT.blank }),
el("div", { className: "m02-struct__start-row", children: [fresh, copy] }),
],
});
};
const previewHost = el("div", { className: "m02-struct__preview" });
@@ -219,6 +274,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
return card;
};
const sheets = [sheetCard("area")];
// 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임)
const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" });
pills.title = TEXT.pillsTip;
const paintPills = (shown: boolean): void => {
pills.setAttribute("aria-pressed", String(shown));
sheets[0].host.classList.toggle("is-pills-off", !shown);
};
pills.addEventListener("click", () => {
const shown = pills.getAttribute("aria-pressed") !== "true";
writeStateRaw("m02-card-pills", String(shown));
paintPills(shown);
});
paintPills(readStateRaw("m02-card-pills") === "true");
sheets[0].shell.tools.prepend(pills);
const cards: Card[] = [info, ...sheets];
const defaults: Record<EditKind, string> = {
structure: TEXT.edit,
@@ -241,6 +310,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
c.saver.button.hidden = c.help.hidden = !can;
c.edit.hidden = !opts.editable();
}
pills.hidden = !sheetEdit;
};
const drop = (): void => {
@@ -307,6 +377,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
* 그 사이 열이 바뀌면 버림 */
const paintSheet = async (c: SheetCard, mine: number): Promise<void> => {
if (!c.doc) return;
if (blank(c, emptyBook(c.doc)))
return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq)));
c.host.replaceChildren();
try {
const { createSpreadsheet } = await loadSpreadsheet();
if (mine !== seq || !c.doc) return;
@@ -327,14 +400,21 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]);
void paintSheet(c, mine);
}
paintDrawing();
};
/** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */
const paintDrawing = (): void => {
if (!doc) return;
previewHost.replaceChildren();
if (blank(info, !doc.도면?.entities?.length))
return void previewHost.append(startBox(info, paintDrawing));
// 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`)
preview = mountDrawingCard(previewHost, {
doc,
version: docVersion,
source: opts.source(),
column: at,
column: column ?? "",
editable: opts.editable(),
saver: info.saver,
});
@@ -363,6 +443,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
return;
}
root.hidden = false;
if (col.id !== column) started.clear();
column = col.id;
label = col.label;
for (const c of cards) c.name.textContent = label;
@@ -0,0 +1,199 @@
/* =============================================================================
* M02_MasterTemplete_ItemList.ts
* 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나
* 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
* (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음).
* 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다.
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
* ========================================================================== */
import { createInputField, el } from "@ui/ui_template_elements";
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
import "../M01_MasterData/M01_MasterData_UI_Style.css";
import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch";
import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model";
/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */
export type ListKind = "structure" | "area" | "labor" | "item";
/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */
export interface ItemRow extends TreeItem {
도번?: string | null;
면적번?: string | null;
수정일?: Partial<Record<"structure" | "area" | "labor", string>>;
labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null;
}
export interface Col<R> {
head: string;
get: (row: R) => string;
}
const TEXT = {
search: "찾기",
none: "항목이 없음",
noMatch: "맞는 항목이 없음",
loading: "불러오는 중",
failed: "목록을 못 받음",
};
const SIZE = 30;
const DASH = "—";
const day = (iso?: string): string => (iso ? iso.slice(0, 10) : "");
const money = (value: number | null | undefined): string =>
typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH;
/** 가장 늦게 고친 날 — 항목 통째 */
const latest = (row: ItemRow): string =>
day(
Object.values(row.수정일 ?? {})
.sort()
.pop(),
);
const NAME: Col<ItemRow> = { head: "이름", get: (r) => leafName(r) };
const FOLDER: Col<ItemRow> = { head: "폴더", get: (r) => folderOf(r).join(" / ") };
const UNIT: Col<ItemRow> = { head: "단위", get: (r) => r.단위 ?? "" };
const changed = (part: "structure" | "area" | "labor"): Col<ItemRow> => ({
head: "고친 날",
get: (r) => day(r.수정일?.[part]),
});
/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */
export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
structure: [
{ head: "도번", get: (r) => r.도번 || DASH },
NAME,
FOLDER,
UNIT,
changed("structure"),
],
area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")],
labor: [
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
NAME,
FOLDER,
UNIT,
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
{ head: "합계", get: (r) => money(r.labor?.합계) },
changed("labor"),
],
item: [
NAME,
FOLDER,
UNIT,
{ head: "도번", get: (r) => r.도번 || DASH },
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
{ head: "면적번", get: (r) => r.면적번 || DASH },
{ head: "고친 날", get: latest },
],
};
/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */
export function pageOf<R>(
rows: R[],
cols: Col<R>[],
query: string,
page: number,
size = SIZE,
): { shown: R[]; page: number; total: number } {
const hits = rows.filter((r) =>
matchText(
cols.map((c) => c.get(r)),
query,
),
);
const last = Math.max(1, Math.ceil(hits.length / size));
const at = Math.min(Math.max(1, page), last);
return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length };
}
/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */
export const loadItems = async (layer: Layer, owner: string | null): Promise<ItemRow[]> =>
(await fetchTree(layer, owner)).items as ItemRow[];
/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */
export function buildAllItems(
kind: ListKind,
layer: Layer,
owner: string | null,
onOpen: (row: ItemRow) => void,
): HTMLElement {
const list = buildItemList();
loadItems(layer, owner).then(
(rows) => list.show(rows, ITEM_COLS[kind], onOpen),
(error: unknown) =>
list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`),
);
return list.root;
}
export interface ItemListHandle {
root: HTMLElement;
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
show: <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void) => void;
/** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */
message: (text?: string) => void;
}
export function buildItemList(): ItemListHandle {
let draw = (): void => {};
let page = 1;
const searchField = createInputField({ type: "search", placeholder: TEXT.search });
const search = searchField.input;
const grid = el("div", { className: "m01-master__grid-wrap" });
let pager = buildPager(0, SIZE, 1, () => {});
const note = (text: string): void => {
grid.replaceChildren(el("p", { className: "m01-master__empty", text }));
};
const show = <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void): void => {
page = 1;
draw = (): void => {
const got = pageOf(rows, cols, search.value, page);
page = got.page;
const next = buildPager(got.total, SIZE, page, (p) => {
page = p;
draw();
});
pager.replaceWith(next);
pager = next;
if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none);
const body = got.shown.map((row) => {
const tr = el("tr", {
className: "m01-master__table-row",
children: cols.map((c) => {
const text = c.get(row);
return el("td", { text, attrs: { title: text } });
}),
});
tr.addEventListener("click", () => onPick(row));
return tr;
});
grid.replaceChildren(
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })],
}),
el("tbody", { children: body }),
],
}),
);
};
draw();
};
search.addEventListener("input", () => {
page = 1;
draw();
});
const root = el("div", {
className: "m02-itemlist",
children: [buildHeadFilters([searchField.root, pager]), grid],
});
// 처음 = 빈 표 자리(줄이 오면 `show`)
note(TEXT.loading);
return { root, show, message: (text = TEXT.loading) => note(text) };
}
@@ -0,0 +1,35 @@
/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */
.m02-itempick {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m02-itempick .m01-master__grid-wrap {
max-height: min(60vh, calc(100vh - 300px));
}
.ui-modal.m02-itempick__dialog {
width: min(960px, 94vw);
}
/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */
.m02-itempick__two {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m02-itempick__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-itempick__buttons {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
@@ -0,0 +1,255 @@
/* =============================================================================
* M02_MasterTemplete_ItemPick.ts
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`).
* 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀.
* 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈
* (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로.
* 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만).
* ========================================================================== */
import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchSessionUser, type SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
buildItemList,
ITEM_COLS,
loadItems,
type Col,
type ItemRow,
type ListKind,
} from "./M02_MasterTemplete_ItemList";
import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api";
import {
entryFromHash,
ownOwner,
readableSources,
viewCompany,
type Source,
} from "./M02_MasterTemplete_Layer";
import { leafName } from "./M02_MasterTemplete_TreePanel_Model";
import "./M02_MasterTemplete_TreePanel.css";
import "./M02_MasterTemplete_ItemPick.css";
const TEXT = {
close: "닫기",
person: "사람",
noPeople: "다른 사람이 없음",
empty: "비어 있음",
failed: "목록을 못 받음 — {value}",
makeNew: "새로 만들기",
makeCopy: "복사 생성",
};
/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */
export interface From {
layer: Layer;
owner: string | null;
label: string;
practice: boolean;
}
export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary };
/** 고른 것 이름 — 확인 글에 씀 */
export const pickedName = (p: Picked): string =>
"row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`;
const PRACTICE_COLS: Col<ComboSummary>[] = [
{ head: "키", get: (c) => c.키 },
{ head: "이름", get: (c) => c.이름 },
{ head: "구분", get: (c) => c.구분 ?? "" },
{ head: "상세구분", get: (c) => c.상세구분 ?? "" },
{ head: "단위", get: (c) => c.단위 ?? "" },
{ head: "담은 로직 수", get: (c) => String(c.count) },
];
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */
const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음;
export interface PickerOptions {
user: SessionUser | null;
/** 볼 회사(`viewCompany`) */
company: string | null;
/** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */
kind: ListKind;
/** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */
skip?: (from: From, row: ItemRow) => boolean;
onPick: (picked: Picked) => void;
}
/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */
export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
const { tabs, first } = readableSources(opt.user, opt.company, {
practice: opt.kind === "labor",
});
const list = buildItemList();
const people = createSelectField({ options: [], compact: true, label: TEXT.person });
people.root.hidden = true;
let owners: { owner: string; name: string }[] | null = null;
let seq = 0;
const buttons = tabs.map((tab) => {
const b = el("button", {
className: "m02-layers__btn",
text: tab.label,
attrs: { type: "button", "data-source": tab.id },
});
b.addEventListener("click", () => void show(tab));
return b;
});
async function show(tab: Source): Promise<void> {
const mine = ++seq;
buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id));
people.root.hidden = tab.id !== "people";
list.message();
try {
if (tab.id === "practice") {
const combos = await fetchCombos();
if (mine !== seq) return;
const from = { layer: tab.layer, owner: null, label: tab.label, practice: true };
return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo }));
}
let owner = tab.owner;
let label = tab.label;
if (tab.id === "people") {
// 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람
const me = ownOwner("personal", opt.user);
owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me);
if (mine !== seq) return;
if (!owners.length) return list.message(TEXT.noPeople);
if (!people.select.value) {
people.setOptions(
owners.map((o) => ({
value: o.owner,
text: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name,
})),
(owners.find((o) => !empty(o)) ?? owners[0]).owner,
);
}
owner = people.select.value;
const who = owners.find((o) => o.owner === owner);
label = `${tab.label} · ${who?.name ?? owner}`;
if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음
}
const rows = await loadItems(tab.layer, owner);
if (mine !== seq) return;
const from = { layer: tab.layer, owner, label, practice: false };
list.show(
rows.filter((row) => !opt.skip?.(from, row)),
ITEM_COLS[opt.kind],
(row) => opt.onPick({ from, row }),
);
} catch (error) {
if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error)));
}
}
people.select.addEventListener("change", () => {
const tab = tabs.find((t) => t.id === "people");
if (tab) void show(tab);
});
const start = tabs.find((t) => t.id === first) ?? tabs[0];
if (start) void show(start);
return {
root: el("div", {
className: "m02-itempick",
children: [
el("div", { className: "m02-layers__row", children: [...buttons, people.root] }),
list.root,
],
}),
};
}
export interface TwoWayOptions {
/** 위 안내 한 줄 */
note: string;
/** [새로 만들기] — 지금 길(창 · 빈 문서) */
onNew: () => void;
/** [복사 생성] 뒤 고를 목록 */
picker: Omit<PickerOptions, "onPick">;
/** 줄 누름 → 확인 글(취소면 그대로 고름) */
ask: (picked: Picked) => string;
/** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */
onCopy: (picked: Picked) => Promise<void>;
}
/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */
export function buildTwoWay(opt: TwoWayOptions): HTMLElement {
const fresh = createButton({ label: TEXT.makeNew, variant: "filled" });
const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" });
const below = el("div", { className: "m02-itempick__below" });
let busy = false;
fresh.addEventListener("click", opt.onNew);
copy.addEventListener("click", () => {
if (below.firstChild) return;
copy.classList.add("is-active");
const picker = buildPicker({
...opt.picker,
onPick: (picked) =>
void (async () => {
if (busy || !(await showConfirmDialog(opt.ask(picked)))) return;
busy = true; // 연달아 눌러도 한 번만
try {
await opt.onCopy(picked);
} finally {
busy = false;
}
})(),
});
below.append(picker.root);
});
return el("div", {
className: "m02-itempick__two",
children: [
el("p", { className: "m02-itempick__note", text: opt.note }),
el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }),
below,
],
});
}
/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
export function pickFrom(
opt: Omit<PickerOptions, "onPick"> & { title: string; ask?: (picked: Picked) => string },
): Promise<Picked | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Picked | null): void => {
if (!done) resolve(value);
done = true;
};
openModal({
title: opt.title,
closeLabel: TEXT.close,
dialogClass: "m02-modal m02-itempick__dialog",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
const picker = buildPicker({
...opt,
onPick: (picked) =>
void (async () => {
if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return;
finish(picked);
close();
})(),
});
body.append(picker.root);
},
});
});
}
/** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */
export async function pickHere(
opt: Omit<PickerOptions, "onPick" | "user" | "company"> & {
title: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
const user = await fetchSessionUser().catch(() => null);
return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) });
}
+50 -6
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -75,10 +76,47 @@ def items(folder: Path) -> list[dict[str, Any]]:
"일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None,
**{k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번")},
}
out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names})
out.append(
{
"key": r.get("키"),
**{k: r.get(k) for k in ITEM_FIELDS},
"names": names,
**_list_extras(Path(folder), str(r.get("열"))),
}
)
return out
def _list_extras(folder: Path, col: str) -> dict[str, Any]:
"""목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의
번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음."""
stamps: dict[str, str] = {}
for kind in ("structure", "area", "labor"):
path = folder / kind / f"{col}.json"
if path.is_file():
stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat(
timespec="seconds"
)
labor = None
path = folder / "labor" / f"{col}.json"
if path.is_file():
try:
doc = json.loads(path.read_bytes())
except ValueError:
doc = {}
total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계")
try:
total = None if total is None or isinstance(total, bool) else float(total)
except (TypeError, ValueError):
total = None # 수 아닌 값(옛 꼴)은 「—」
labor = {
"번호": doc.get("일위대가번"),
"로직수": len(doc.get("담은로직") or []),
"합계": total,
}
return {"수정일": stamps, "labor": labor}
def _fresh_id(want: str, used: set[str]) -> str:
out, n = want, 2
while out in used:
@@ -93,8 +131,9 @@ def copy_item(
origin: dict[str, Any],
path: list[str] | None = None,
) -> dict[str, Any]:
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 ·
도면 · 면적산출 내용(알약 연결 포함) · 항목 연결 복사 ·
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 ·
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 내용(알약 연결 포함) ·
항목 연결 복사 ·
새 번호 · 새 SS 키 ·
열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 ·
붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`."""
@@ -115,7 +154,12 @@ def copy_item(
table = store.read("table", store.STRUCTURE_TABLE)
doc = table["문서"]
new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)})
made = {**json.loads(json.dumps(col)), "id": new_id, "본뜬": origin}
made = {
**json.loads(json.dumps(col)),
"id": new_id,
"일위대가": None, # 정본 = 일위대가 문서(아래 복사)
"본뜬": origin,
}
made.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김
if path:
made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움
@@ -126,10 +170,10 @@ def copy_item(
have = store.read(kind, new_id)["문서"]
body = {k: v for k, v in got.items() if k != "계산값"}
body = {**body, "열": new_id, number: have.get(number)}
if kind in store.SHEETS:
body = store._server_values(body)
body = store._server(kind, body)
with store._LOCK:
atomic_write_json(store._path(kind, new_id), body)
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
return {"key": ss["키"], "열": new_id, "이름": ss["이름"]}
@@ -21,3 +21,118 @@
.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);
}
/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */
.m02-labview {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-labview__title {
margin: 0;
font-size: var(--text-body-sm);
}
@@ -1,222 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Labor.ts
* 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추
* (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` ·
* 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`).
* 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김).
* ========================================================================== */
import { el, showConfirmDialog } from "@ui/ui_template_elements";
import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
editComboTree,
editComboTreeMany,
fetchComboList,
pasteCombos,
type ComboList,
type ComboTreeEdit,
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit";
import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */
function treeError(error: unknown): Error {
if (!(error instanceof ApiError))
return error instanceof Error ? error : new Error(String(error));
const d = error.detail as { errors?: string[]; stale?: unknown } | string | null;
const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`;
return error.status === 409 ? new StaleError(text) : new Error(text);
}
export interface LaborLibraryOptions {
scope: LaborScope;
canEdit: boolean;
/** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */
copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[];
/** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */
reveal: () => Promise<void>;
}
export interface LaborLibrary {
/** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */
root: HTMLElement;
/** 좌측 패널 나무 */
tree: HTMLElement;
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
add: HTMLElement;
/** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */
back: () => Promise<void>;
refresh: () => Promise<void>;
/** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */
tools: HTMLElement | null;
/** 메인 칸 「저장 안 한 고침」 손잡이 */
bind: (setDirty: (dirty: boolean) => void) => void;
}
export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
const { scope } = opt;
let setDirty: (dirty: boolean) => void = () => {};
let keys = new Set<string>(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나
const took = (list: ComboList): ComboList => {
keys = new Set(list.줄.map((c) => c.키));
editor.setList(list.줄);
return list;
};
const editor = createLaborEditor({
scope,
canEdit: opt.canEdit,
onDirty: (dirty) => setDirty(dirty),
onSaved: async (key) => {
await tree.refresh();
tree.select(key);
},
});
const tree = createTreePanel({
layer: scope.layer,
owner: scope.owner,
canEdit: opt.canEdit,
maxDepth: 2,
item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) },
api: {
fetch: async () => {
return toView(took(await fetchComboList(scope)));
},
edit: async (_layer, edit, 판) => {
const body = { ...edit, 판 } as ComboTreeEdit;
const got = await editComboTree(body, scope).catch((error: unknown) => {
throw treeError(error);
});
return toView(took(got), noteOf(edit, got.지운조합));
},
// 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장
many: async (_layer, body, 판) => {
const ops = body.대상.map((ref) => ({
op: body.op,
...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }),
...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}),
}));
const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => {
throw treeError(error);
});
return toView(took(got), { op: body.op, 지운항목: got.지운조합 });
},
// 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로
paste: async (layer, owner, clip, dest) => {
const to = { layer, owner } as LaborScope;
const pick = {
키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])),
폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])),
};
const got = await fetchComboList(to)
.then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to))
.catch((error: unknown) => {
throw treeError(error);
});
const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null);
return toView(took(here ? got : await fetchComboList(scope)));
},
},
clipKind: "일위대가",
copyTo: opt.copyTo,
onPick: async (item) => {
await opt.reveal();
tree.select(item.key);
void editor.open(item.key);
},
// 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로)
settle: async () => {
if (!editor.isDirty()) return "clean";
if (!(await showConfirmDialog(tc("BackAsk")))) return false;
editor.showList();
return "changed";
},
// 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록
onChanged: async () => {
const open = editor.current();
if (!open) return;
if (keys.has(open)) await editor.open(open);
else editor.showList();
},
});
// 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름
tree.search
.querySelector("input")
?.addEventListener("input", (event) =>
editor.setQuery((event.target as HTMLInputElement).value),
);
return {
root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }),
tree: tree.root,
add: tree.add,
back: async () => {
await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움
if (editor.current() === null) return;
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
editor.showList();
},
refresh: async () => {
const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6)
try {
await tree.refresh();
} finally {
stop();
}
},
tools: editor.newButton,
bind: (fn) => (setDirty = fn),
};
}
export interface LaborSectionOptions {
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
make: (reveal: () => Promise<void>) => LaborLibrary;
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
bar?: (add: HTMLElement, back: () => void) => HTMLElement;
/** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */
wrap?: (tree: HTMLElement) => HTMLElement;
}
export interface LaborSection {
title: string;
body: HTMLElement;
open: () => Promise<void>;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */
export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
let opened = false;
const mark = (on: boolean): void => {
opened = on;
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
library.bind(opt.show(tc("Library"), library.root, library.tools));
opt.onOpen();
mark(true);
await library.refresh();
}
// 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음
const library = opt.make(open);
void library.refresh();
const body = el("div", {
className: "m02-side__items ui-sidebar-list",
children: [
...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []),
opt.wrap ? opt.wrap(library.tree) : library.tree,
],
});
return { title: tc("Title"), body, open, clear: () => mark(false) };
}
@@ -12,7 +12,13 @@ import {
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
import {
getActiveLayer,
getActiveOwner,
type ItemLogic,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
export interface ComboLogic {
@@ -56,28 +62,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">;
@@ -95,15 +79,6 @@ export interface ComboLayerList extends ComboList {
layer: LaborScope["layer"];
owner: string | null;
}
export interface ComboTreeEdit {
op: "add-folder" | "rename" | "move" | "delete";
경로?: string[];
이름?: string;
새경로?: string[];
키?: string;
판: string;
}
async function request<T>(url: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m02${url}`, {
method,
@@ -197,43 +172,6 @@ export const deleteCombo = (
scope: LaborScope = SYSTEM_SCOPE,
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */
export const editComboTree = (
body: ComboTreeEdit,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> => request(at(scope, "/tree"), "POST", body);
/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */
export const editComboTreeMany = (
ops: Omit<ComboTreeEdit, "판">[],
판: string,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> =>
request(at(scope, "/tree-many"), "POST", { 판, ops });
/** 층 사이 복사(PLAN 30-3) — `from` 층의 조합 · 폴더를 이 층 `경로` 폴더에 새 키로 */
export const pasteCombos = (
from: LaborScope,
pick: { 키?: string[]; 폴더?: string[][] },
경로: string[],
판: string,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 새키: { 원본: string; 새키: string }[] }> =>
request(at(scope, "/paste"), "POST", {
from_layer: from.layer,
from_owner: from.owner,
...pick,
경로,
판,
});
/** 보기만 — 수량(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);
@@ -248,3 +186,117 @@ 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 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,
})),
};
}
/** 면적산출 문서의 알약 → {칸, 값}(식 칸 = 서버 `계산값` · 그 밖 = 칸 값 · 수만) — 서버 `area_values` 와 같은 규칙 */
export function areaLinks(area: unknown): Map<string, { 칸: string; 값: number | null }> {
const out = new Map<string, { 칸: string; 값: number | null }>();
const doc = (area && typeof area === "object" ? area : {}) as {
시트?: {
id?: string;
칸?: Record<string, { 값?: unknown }>;
연결?: { 키?: string; 칸?: string }[];
}[];
계산값?: Record<string, Record<string, { 수?: unknown }>>;
};
for (const sheet of doc.시트 ?? []) {
const solved = doc.계산값?.[sheet.id ?? ""] ?? {};
for (const link of sheet.연결 ?? []) {
if (!link.키 || !link.칸) continue;
const raw = solved[link.칸]?.수 ?? sheet.칸?.[link.칸]?.값;
const n =
raw === null || raw === undefined || raw === ""
? NaN
: Number(String(raw).replace(/,/g, ""));
out.set(link.키, { 칸: link.칸, 값: Number.isFinite(n) ? n : null });
}
}
return out;
}
/** calc 답 모양 · 수량 · 반영 짝 셈 = `M02_MasterTemplete_Labor_Calc.ts`(sub7 · ↔ `M02_Labor_Doc.py`) */
export type {
CalcLine as LaborCalcLine,
CalcMaterial as LaborCalcMat,
LaborCalc,
Money as LaborCost,
} from "./M02_MasterTemplete_Labor_Calc";
/** 지금 층이면 `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 & { 알약: unknown[] }> =>
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,113 @@
/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함.
*
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
export type QtySource = "손" | "칸" | "없음";
/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */
export type Qty = { 손: number } | null;
export interface CalcLine {
로직: string;
알약?: string | null;
분모?: string | null;
배: number;
기준: Money | null;
수량?: number | null;
수량출처?: QtySource;
반영?: Money | null;
까닭?: string | null;
결과?: unknown;
}
export interface CalcMaterial {
코드: string;
알약?: string | null;
기준단가: number | null;
수량?: number | null;
수량출처?: QtySource;
반영?: number | null;
까닭?: string | null;
}
export interface LaborCalc {
줄: CalcLine[];
자재: CalcMaterial[];
기준합?: Money;
반영합?: Money;
}
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
export interface LaborQtyDoc {
담은로직?: { 로직: string; 수량?: Qty }[];
자재?: { 코드: string; 수량?: Qty }[];
}
function num(raw: unknown): number | null {
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
const n = Number(String(raw).replace(/,/g, "").trim());
return String(raw).trim() !== "" && Number.isFinite(n) ? n : null;
}
function zero(): Money {
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
}
function pick(
hand: Qty | undefined,
key: string | null | undefined,
given: Record<string, number>,
): [number | null, QtySource] {
const own = hand ? num(hand.손) : null;
if (own !== null) return [own, "손"];
if (key && key in given) return [given[key], "칸"];
return [null, "없음"];
}
/** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */
export function reflect(base: Money, qty: number | null, times: number): Money | null {
if (qty === null) return null;
const out = zero();
for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times;
return out;
}
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */
export function settle(
result: LaborCalc,
doc: LaborQtyDoc,
given: Record<string, unknown>,
): LaborCalc {
const have: Record<string, number> = {};
for (const [k, raw] of Object.entries(given)) {
const v = num(raw);
if (v !== null) have[k] = v;
}
const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null]));
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
const base = zero();
const done = zero();
const add = (sum: Money, m: Money | null): void => {
if (m) for (const k of MONEY_KEYS) sum[k] += m[k];
};
const 줄 = result.줄.map((line) => {
const [qty, source] = pick(hands.get(line.로직), line.알약, have);
const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null;
add(base, line.기준);
add(done, own);
return { ...line, 수량: qty, 수량출처: source, 반영: own };
});
const 자재 = result.자재.map((one) => {
const [qty, source] = pick(mhands.get(one.코드), one.알약, have);
const price = num(one.기준단가);
const cost = price !== null && qty !== null ? price * qty : null;
if (price !== null) add(base, { ...zero(), 재료비: price, 계: price });
if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost });
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
});
return { 줄, 자재, 기준합: base, 반영합: done };
}
@@ -0,0 +1,324 @@
/* =============================================================================
* 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 { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
calcLabor,
fetchLogicInputs,
normalizeLabor,
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 } 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 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;
/** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */
let links = new Map<string, { 칸: string; 값: number | null }>();
const cells = (): Map<string, string> => new Map([...links].map(([k, v]) => [k, v.칸]));
const given = (): Record<string, number> =>
Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]])));
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, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
),
);
matHost.replaceChildren(
matTable(
{ mats: cur.자재, calc, fail, cells: cells() },
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);
};
/** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */
const changed = (qtyOnly = false): void => {
opt.onChanged(JSON.stringify(cur) !== saved);
if (qtyOnly && calc) {
calc = settle(calc, cur, given());
draw();
} else recalc();
};
/** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */
const linkedText = (pill: string | null | undefined, unit: string): string | null => {
const at = pill ? links.get(pill) : undefined;
if (!at) return null;
return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`;
};
/** 아직 안 받은 로직 입력 목록만 받음 */
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: linkedText(line?.알약, line?.분모 ?? ""),
unit: line?.분모 ?? "",
},
readOnly: !edit,
goArea: opt.goArea,
done: (next) => {
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값);
row.입력값 = values;
row.메모 = next.메모;
row.수량 = next.수량;
changed(qtyOnly);
},
});
}
function openMat(i: number): void {
const mat = cur.자재[i];
const line = matOf(mat.코드);
openQtyModal(
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
{
qty: mat.수량,
linked: linkedText(line?.알약, mat.단위 ?? ""),
unit: mat.단위 ?? "",
},
!edit,
opt.goArea,
(qty) => {
mat.수량 = qty;
changed(true);
},
);
}
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 Promise.all([
loadLogics(),
readTemplate(opt.layer, "area", opt.column, null).then(
(got) => (links = areaLinks(got.문서)),
() => undefined,
),
]).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;
결과?: unknown;
까닭?: 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) => typeof m.반영 === "number") ? "반영" : "기준" } : {}),
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,246 @@
/* =============================================================================
* 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.이름];
// 고르기 · 범위 없는 칸도 수로 읽히면 수로 둠(M01 넣을 값과 같게)
else
draft[input.이름] =
typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))
? Number(value)
: 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,287 @@
/* =============================================================================
* 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; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): string {
if (!line || line.수량 === null || line.수량 === undefined) return "";
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
return `${tc("Labor_Qty")} ${cell} = ${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;
/** 알약 키 → 이은 면적산출 칸(「B6」) */
cells?: ReadonlyMap<string, string>;
}
/** 담은 로직 표 — 보기 갈래는 `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?.분모 ?? "", data.cells);
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;
cells?: ReadonlyMap<string, string>;
}
/** 자재목록 표 — 보기 갈래는 `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: qtyText(line, mat.단위 ?? "", data.cells) },
}),
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("Labor_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;
@@ -1,45 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Tree.ts
* 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림.
* 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키).
* ========================================================================== */
import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch";
import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api";
/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */
export const pathOf = (c: ComboSummary): string[] =>
c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : [];
/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */
export const toView = (list: ComboList, note?: TreeNote): TreeView => ({
판: list.판,
폴더: list.폴더,
items: list.줄.map((c) => ({
key: c.키,
열: c.키,
경로: [...pathOf(c), c.이름],
이름: c.이름,
단위: c.단위,
})),
...(note ? { 알림: note } : {}),
});
/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */
export function noteOf(edit: TreeEdit, gone: string[]): TreeNote {
const at = edit.경로 ?? [];
const last = at[at.length - 1] ?? "";
if (edit.키 && edit.op !== "delete") {
const where = edit.op === "move" ? (edit.새경로 ?? []) : at;
return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone };
}
const path =
edit.op === "add-folder"
? [...at, edit.이름 ?? ""]
: edit.op === "rename"
? [...at.slice(0, -1), edit.이름 ?? ""]
: edit.op === "move"
? [...(edit.새경로 ?? []), last]
: undefined;
return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone };
}
@@ -0,0 +1,152 @@
/* =============================================================================
* M02_MasterTemplete_Labor_View.ts
* 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영).
* 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`).
* · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만)
* · `setQty({알약 키: 칸 값})` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 바로 곱함
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
areaLinks,
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
} from "./M02_MasterTemplete_Labor_Api";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
export interface LaborViewRow {
kind: "logic" | "material";
/** 로직 키 · 자재 코드 */
key: string;
/** 면적산출 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — calc 전이면 null */
pill: string | null;
name: string;
unit: string;
}
export interface LaborViewHandle {
root: HTMLElement;
/** 그 열 일위대가를 읽어 그림 — `수량` = 저장 안 한 면적 칸 값(없으면 저장된 면적산출) */
show: (layer: Layer, column: string | null, 수량?: Record<string, number>) => Promise<void>;
/** 면적 칸 값이 바뀜(알약 값 전부) — 받은 기준에 바로 곱함(서버 안 부름) */
setQty: (수량: Record<string, number>) => void;
/** 지금 문서(없으면 null) */
doc: () => LaborDoc | null;
destroy: () => void;
}
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
const root = el("div", { className: "m02-labview" });
const logics = new Map<string, ItemLogic>();
let doc: LaborDoc | null = null;
let calc: LaborCalc | null = null;
let fail: string | null = null;
let cells = new Map<string, string>();
let seq = 0;
const paint = (): void => {
if (!doc) return;
const d = doc;
const pick = (row: LaborViewRow): (() => void) | undefined =>
onRow ? () => onRow(row) : undefined;
const logicOpen = (i: number): void => {
const row = d.담은로직[i];
const line = calc?.줄.find((l) => l.로직 === row.로직);
pick({
kind: "logic",
key: row.로직,
pill: line?.알약 ?? null,
name: logics.get(row.로직)?.이름 ?? row.로직,
unit: line?.분모 ?? "",
})?.();
};
const matOpen = (i: number): void => {
const mat = d.자재[i];
const line = calc?.자재.find((m) => m.코드 === mat.코드);
pick({
kind: "material",
key: mat.코드,
pill: line?.알약 ?? null,
name: mat.이름 ?? mat.코드,
unit: mat.단위 ?? "",
})?.();
};
root.replaceChildren(
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
logicTable(
{ rows: d.담은로직, logics, calc, fail, cells },
onRow ? { open: logicOpen } : undefined,
),
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
);
};
const show = async (
layer: Layer,
column: string | null,
수량?: Record<string, number>,
): Promise<void> => {
const mine = ++seq;
doc = null;
calc = null;
fail = null;
if (!column) return void root.replaceChildren();
root.replaceChildren(el("p", { className: "m02-labrow__muted", text: tc("Loading") }));
try {
const got = await readTemplate(layer, "labor", column, null);
if (mine !== seq) return;
doc = normalizeLabor(got.문서, column);
const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
const [inputs, area] = await Promise.all([
fetchLogicInputs(want),
readTemplate(layer, "area", column, null).then(
(a) => a.문서,
() => null,
),
]);
if (mine !== seq) return;
for (const l of inputs.로직) logics.set(l.로직, l);
cells = new Map([...areaLinks(area)].map(([k, v]) => [k, v.칸]));
paint(); // 금액 「…」
calc = await calcLabor(layer, column, null, 수량);
} catch (error) {
if (mine !== seq) return;
if (!doc) {
root.replaceChildren(
el("p", {
className: "m02-labrow__empty",
text: error instanceof Error ? error.message : tc("Load_Failed"),
}),
);
return;
}
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
}
if (mine === seq) paint();
};
return {
root,
show,
setQty: (수량) => {
if (!doc || !calc) return;
calc = settle(calc, doc, 수량);
paint();
},
doc: () => doc,
destroy: () => {
seq += 1;
root.remove();
},
};
}
+81 -4
View File
@@ -79,7 +79,8 @@ export interface LayerAccess {
needCompany: boolean;
}
/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */
/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) ·
* 읽을 수 있나는 서버가 가름 */
export function accessFor(
layer: ManageLayer,
owner: string | null,
@@ -87,10 +88,86 @@ export function accessFor(
): LayerAccess {
if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false };
const own = ownOwner(layer, user);
if (owner && owner !== own) return { canWrite: false, needCompany: false };
if (owner && owner !== own) {
return { canWrite: manages(owner.split("/")[0], user), needCompany: false };
}
if (!own) return { canWrite: false, needCompany: true };
const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master;
return { canWrite: layer === "personal" || companyAdmin, needCompany: false };
return {
canWrite: layer === "personal" || manages(ownOwner("company", user), user),
needCompany: false,
};
}
/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */
export function manages(company: string | null, user: SessionUser | null): boolean {
if (!company || !user) return false;
if (user.role === "SYSTEM_ADMIN") return true;
const companyAdmin = user.role === "ADMIN" || !!user.is_master;
return companyAdmin && company === ownOwner("company", user);
}
/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */
export interface Source {
id: "master" | "company" | "personal" | "people" | "practice";
layer: ManageLayer;
/** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */
owner: string | null;
label: string;
}
/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭.
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */
export function readableSources(
user: SessionUser | null,
company: string | null,
opt: { practice?: boolean } = {},
): { tabs: Source[]; first: Source["id"] } {
const isSystem = user?.role === "SYSTEM_ADMIN";
const own = ownOwner("personal", user);
const tabs: Source[] = [
{ id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") },
...(company
? [
{
id: "company" as const,
layer: "company" as const,
owner: company === ownOwner("company", user) ? null : company,
label: L("M02_PickerCompany"),
},
]
: []),
...(own
? [
{
id: "personal" as const,
layer: "personal" as const,
owner: null,
label: L("M02_PickerPersonal"),
},
]
: []),
...(manages(company, user)
? [
{
id: "people" as const,
layer: "personal" as const,
owner: null,
label: L("M02_SourcePeople"),
},
]
: []),
...(opt.practice && isSystem
? [
{
id: "practice" as const,
layer: "system" as const,
owner: null,
label: L("M02_SourcePractice"),
},
]
: []),
];
return { tabs, first: isSystem || !own ? "master" : "personal" };
}
/** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */
@@ -0,0 +1,224 @@
/* =============================================================================
* M02_MasterTemplete_Practice.ts
* 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) —
* 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) ·
* [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인).
* 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
fetchCombo,
fetchComboList,
type ComboList,
type ComboOne,
} from "./M02_MasterTemplete_Labor_Api";
export interface PracticeSectionOptions {
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface PracticeSection {
title: string;
body: HTMLElement;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */
function reasonOf(detail: unknown, status: number): string {
if (typeof detail === "string") return detail;
const d = detail as { errors?: string[]; stale?: unknown } | null;
if (d?.errors?.length) return d.errors.join(" · ");
return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`;
}
/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */
async function dropPractice(판: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) return;
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(reasonOf(data.detail, res.status));
}
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-practice__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
});
const cells = (values: string[]): HTMLElement[] =>
values.map((v) => el("td", { text: v, attrs: { title: v } }));
export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: L("M02_PracticeRow"),
attrs: { type: "button", "data-practice-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
const view = el("div", { className: "m02-practice" });
let opened = false;
let nav = 0; // 늦게 온 응답 버림
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
row.addEventListener("click", () => void open());
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
opt.show(L("M02_PracticeTitle"), view);
opt.onOpen();
mark(true);
await drawList();
}
async function drawList(): Promise<void> {
const my = ++nav;
const stop = showLoading(view);
let list: ComboList;
try {
list = await fetchComboList();
} catch (error) {
stop();
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList()));
return;
} finally {
stop();
}
if (my !== nav) return;
const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" });
drop.disabled = !list.줄.length;
drop.addEventListener("click", () => void dropAll(list, drop));
const rows = list.줄.map((c) => {
const tr = el("tr", {
className: "m02-practice__row",
attrs: { "data-practice-key": c.키 },
children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]),
});
tr.addEventListener("click", () => void drawOne(c.키));
return tr;
});
view.replaceChildren(
el("div", {
className: "m02-practice__bar",
children: [
el("span", {
className: "m02-practice__note",
text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)),
}),
drop,
],
}),
rows.length
? table(
[
L("M02_PracticeKey"),
L("M02_Name"),
L("M02_PracticeGroup"),
L("M02_PracticeUnit"),
L("M02_PracticeLogics"),
],
rows,
)
: el("p", { className: "m02-practice__note", text: L("M02_PracticeEmpty") }),
);
}
/** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */
async function drawOne(key: string): Promise<void> {
const my = ++nav;
const stop = showLoading(view);
let one: ComboOne;
try {
one = await fetchCombo(key);
} catch (error) {
stop();
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key)));
return;
} finally {
stop();
}
if (my !== nav) return;
const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" });
back.addEventListener("click", () => void drawList());
const c = one.combo;
const info = [
[L("M02_PracticeKey"), c.키],
[L("M02_PracticeGroup"), c.구분 ?? ""],
[L("M02_PracticeUnit"), c.단위 ?? ""],
].map(([k, v]) => el("span", { className: "m02-practice__note", text: `${k} ${v}` }));
const lines = one.줄.map((x) =>
el("tr", {
children: cells([
String(x.차례),
x.로직,
x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""),
x.결과단위 ?? "",
x.메모 ?? "",
]),
}),
);
view.replaceChildren(
el("div", {
className: "m02-practice__bar",
children: [back, el("strong", { text: c.이름 }), ...info],
}),
...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []),
lines.length
? table(
[
L("M02_PracticeOrder"),
L("M02_PracticeKey"),
L("M02_Name"),
L("M02_PracticeUnit"),
L("M02_PracticeMemo"),
],
lines,
)
: el("p", { className: "m02-practice__note", text: L("M02_PracticeNoLogic") }),
);
}
/** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */
async function dropAll(list: ComboList, button: HTMLButtonElement): Promise<void> {
const n = String(list.줄.length);
if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete"))))
return;
if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return;
button.disabled = true;
try {
await dropPractice(list.판);
showToast(L("M02_PracticeDropped").replace("{n}", n), "success");
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
await drawList();
}
return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) };
}
@@ -5,25 +5,35 @@
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######)
personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######)
저장소 = `M02_MasterTemplete_Combo_Store`.
담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"])
def _door(request: Request, session: Session = Depends(verify_session)) -> None:
"""일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만."""
if "/logic-lib/" not in request.url.path and not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.")
router = APIRouter(
prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)]
)
class ComboBody(BaseModel):
@@ -31,43 +41,6 @@ class ComboBody(BaseModel):
판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409
class ComboTreeBody(BaseModel):
"""`{op: add-folder|rename|move|delete, 경로?, 이름?, 새경로?, 키?, 판}` — 폴더 깊이 2."""
op: str
경로: list[str] | None = None
이름: str | None = None
새경로: list[str] | None = None
키: str | None = None
판: str
class TreeManyBody(BaseModel):
"""여러 개 옮기기 · 지우기(PLAN 30-3) — `ops` = 나무 고침(`ComboTreeBody` 모양) · 저장 한 번."""
판: str
ops: list[dict[str, Any]]
class PasteBody(BaseModel):
"""층 사이 복사(PLAN 30-3) — 원본 층(`from_layer` · `from_owner`)의 조합 `키` · 폴더 `폴더` →
이 층 `경로` 폴더에 새 키로. `판` = 이 층 조합 표 판."""
from_layer: Layer
from_owner: str | None = None
키: list[str] = []
폴더: list[list[str]] = []
경로: list[str] = []
판: str = ""
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, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
@@ -134,18 +107,6 @@ def post_combo(
return _call(folder, combos.create, body.combo, body.판 or None)
@router.put("/layers/{layer}/combos/{key}")
def put_combo(
layer: Layer,
key: str,
body: ComboBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, combos.edit, key, body.combo, body.판 or "")
@router.delete("/layers/{layer}/combos/{key}")
def delete_combo(
layer: Layer,
@@ -157,50 +118,13 @@ def delete_combo(
return _call(_root(session, layer, owner, write=True), combos.delete, key, 판)
@router.post("/layers/{layer}/combos/tree")
def post_tree(
layer: Layer,
body: ComboTreeBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, combos.edit_tree, body.model_dump())
@router.post("/layers/{layer}/combos/tree-many")
def post_tree_many(
layer: Layer,
body: TreeManyBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, combos.edit_tree_many, body.model_dump())
@router.post("/layers/{layer}/combos/paste")
def post_paste(
layer: Layer,
body: PasteBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""원본 층 읽기 권한 · 이 층 쓰기 권한(M02_Layer_Access) — 마스터 붙이기 = 시스템 관리자."""
source = _call(_root(session, body.from_layer, body.from_owner), lambda: combos._load()[0])
folder = _root(session, layer, owner, write=True)
return _call(folder, combo_copy.paste, source, body.model_dump())
@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)
@router.delete("/practice")
def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict:
"""실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 ·
구조물이 쓰는 조합이 있으면 409."""
if not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만")
return _call(_root(session, "system", None, write=True), combos.drop_practice, 판)
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
@@ -230,3 +154,9 @@ def get_logic(key: str) -> dict:
def get_logic_auto(key: str) -> dict:
"""견본 입력 — 미리 보기용 자동값."""
return _read(m01.auto, key)
@router.get("/logic-lib/inputs", dependencies=[Depends(verify_session)])
def get_logic_inputs(keys: str = "") -> dict:
"""일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`)."""
return _read(labor_inputs.logic_inputs, keys.split(","))
@@ -10,13 +10,20 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층)
from __future__ import annotations
import logging
from collections.abc import Callable, Coroutine
from contextvars import ContextVar
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
import anyio.from_thread
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.routing import APIRoute
from pydantic import BaseModel
from common_util.common_util_audit import record_audit
from common_util.common_util_auth import verify_session
from config.config_db import get_db_pool
from M02_MasterTemplete import M02_Item_Paste as paste_store
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
@@ -29,7 +36,33 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import (
UseComboBody,
)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"])
logger = logging.getLogger(__name__)
#: 이 요청이 쓴 남의 층(`others_write`) — 길이 성공해 답한 뒤에만 시스템 로그(`NotedRoute`)
_WRITES: ContextVar[list[tuple[dict, access.Grant]] | None] = ContextVar("m02_writes", default=None)
class NotedRoute(APIRoute):
"""남의 층 쓰기 기록 = 성공한 쓰기만(39-1d) — 막히거나 실패한 부름(예외 · 4xx · 5xx)은 뺌."""
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
handler = super().get_route_handler()
async def run(request: Request) -> Response:
notes: list[tuple[dict, access.Grant]] = []
token = _WRITES.set(notes)
try:
response = await handler(request)
finally:
_WRITES.reset(token)
if response.status_code < 400:
for session, got in notes:
await _note(session, got)
return response
return run
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"], route_class=NotedRoute)
Layer = Literal["system", "company", "personal"]
Session = dict[str, Any]
@@ -71,14 +104,65 @@ class CopyItemBody(BaseModel):
key: str
async def _member(owner: str) -> bool:
"""층 주인이 있나 — 회사 `{C}` = 그 회사 · 개인 `{C}/{U}` = U 가 C 소속."""
company, _, user = owner.partition("/")
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
if user:
await cursor.execute(
"SELECT 1 FROM users WHERE id = %s AND company_id = %s AND deleted_at IS NULL",
(int(user), int(company)),
)
else:
await cursor.execute(
"SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (int(company),)
)
return await cursor.fetchone() is not None
async def _note(session: Session, got: access.Grant) -> None:
"""남의 층 쓰기 한 줄(39-6 · 시스템 로그) — 기록이 못 돼도 쓰기는 막지 않음."""
try:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await record_audit(
cursor,
actor_id=int(session["user_id"]),
action="M02_OTHER_LAYER_WRITE",
resource_type=f"m02_{got.layer}",
resource_ref=got.owner,
)
await connection.commit()
except Exception:
logger.exception("M02 남의 층 쓰기 기록 실패: %s %s", got.layer, got.owner)
async def others_write(session: Session, got: access.Grant, folder: Path) -> None:
"""남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 시스템 로그는 길이 성공한
뒤(`NotedRoute` · 그 밖의 길은 바로)."""
if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file():
if not await _member(str(got.owner)):
raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님")
items_store.ensure(folder)
pending = _WRITES.get()
if pending is None:
await _note(session, got)
else:
pending.append((session, got))
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
"""층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦."""
"""층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦 ·
남의 층 쓰기(맡은 회사 · 39-6)는 `others_write`."""
got = access.require(session, layer, owner, write=write)
if layer == "system":
return store.FOLDER
folder = access.folder(got)
folder = access.folder(got, create=write)
if got.mine:
items_store.ensure(folder)
elif write:
anyio.from_thread.run(others_write, session, got, folder)
elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file():
raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음")
return folder
@@ -299,15 +383,33 @@ def post_use_combo(
return _call(folder, branch.use_combo, column, body.종류, body.키, body.판)
def _copy_practice(column: str, key: str) -> dict[str, Any]:
"""실무문서 조합(마스터 UA) → 이 열 일위대가 문서(입력값 빈 값 · 39-1b)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
if not key.startswith("UA"):
raise store.StoreError(400, f"실무문서 조합 키가 아님 「{key}」")
return store.copy_from("labor", column, key, body=labor.from_combo(key))
@router.post("/layers/{layer}/{kind}/{column}/copy")
def post_copy(
layer: Layer,
kind: Literal["structures", "area"],
kind: Literal["structures", "labor", "area"],
column: str,
source: str = Query(alias="from"),
owner: str | None = None,
from_layer: Literal["system", "company", "personal", "practice"] | None = None,
from_owner: str | None = None,
session: Session = Depends(verify_session),
):
"""항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) ·
다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만)."""
folder = _root(session, layer, owner, write=True)
part = "structure" if kind == "structures" else kind
return _call(folder, store.copy_from, part, column, source)
if from_layer == "practice":
if part != "labor" or not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만")
return _call(folder, _copy_practice, column, source)
origin = _root(session, from_layer, from_owner) if from_layer else None
return _call(folder, store.copy_from, part, column, source, origin)
@@ -27,13 +27,16 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Table_Fill as fill
from M02_MasterTemplete import M02_Template_Layers as layers
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"])
router = APIRouter(
prefix="/api/m02", tags=["M02 MasterTemplete Layers"], route_class=items_router.NotedRoute
)
Layer = Literal["system", "company", "personal", "project"]
@@ -164,8 +167,10 @@ def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int:
async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None:
"""남의 개인 양식 = 그 회사를 맡은 사람만(39-6 · 층 길과 한 규칙) · 그 회사 사람이어야 함."""
if user_id == session.get("user_id"):
return
access.grant(session, "personal", f"{company_id}/{user_id}")
if await _user_company(user_id) != company_id:
raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.")
@@ -192,7 +197,12 @@ async def _layer_dir(
who = session["user_id"] if user_id is None else user_id
owner = str(company_id) if layer == "company" else f"{company_id}/{who}"
got = access.require(session, layer, owner, write=write)
return _ensured(access.folder(got)) if got.mine else access.folder(got)
folder = access.folder(got, create=write)
if got.mine:
return _ensured(folder)
if write: # 남의 층 쓰기(맡은 회사 · 39-6) — 구조물 길과 한 벌
await items_router.others_write(session, got, folder)
return folder
def _ensured(folder: Path) -> Path:
@@ -214,9 +224,9 @@ async def list_owners(
company: int | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> list[dict[str, Any]]:
"""남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌).
회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 ·
회사 관리자는 자기 회사만)."""
"""남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]`.
회사 = SYSTEM_ADMIN 만 · 층 자리가 있는 것만(자기 회사는 늘) ·
개인 = 그 회사를 맡은 사람(39-6)만 · 회사 사람 전부(나 맨 위 · 집계표 없으면 `비어있음`)."""
if layer == "company":
if not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.")
@@ -225,12 +235,16 @@ async def list_owners(
target = session.get("company_id") if company is None else company
if target is None:
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
mine = str(target) == str(session.get("company_id"))
if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))):
if not access.manages(session, target):
raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.")
me = session.get("user_id")
users = await _company_users(int(target))
rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me]
users.sort(key=lambda u: u["user_id"] != me) # 나 맨 위
return [
{"owner": owner, "name": u["name"], "비어있음": _empty(owner)}
for u in users
for owner in [f"{target}/{u['user_id']}"]
]
found = []
for owner, name in rows:
got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer))
@@ -242,6 +256,13 @@ async def list_owners(
return found
def _empty(owner: str) -> bool:
"""개인 층에 집계표가 없음 — 사람 나무 「비어 있음」(폴더를 안 만듦)."""
company, user = owner.split("/")
table = layers.personal_dir(company, user) / "table" / f"{store.STRUCTURE_TABLE}.json"
return not table.is_file()
@router.get("/layers/{layer}/templates")
async def list_layer(
layer: Layer,
@@ -519,7 +540,11 @@ async def list_sources(
)
return rows
users = await _company_users(company_id)
users = [ # 남의 개인 = 그 회사를 맡은 사람만(39-6)
u
for u in await _company_users(company_id)
if u["user_id"] == session.get("user_id") or access.manages(session, company_id)
]
projects = await _company_projects(company_id)
return {
"system": await asyncio.to_thread(layers.list_templates, layers.system_dir()),
+69 -19
View File
@@ -64,11 +64,12 @@ IN_USE = {
# (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면
PARTS = (
("structure", "도번", "SD", None),
("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장)
("area", "면적번", "SA", "면적산출"),
)
_KEY = {kind: key for kind, key, _, _ in PARTS}
SHEETS = ("area",) # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
_CODE = re.compile(r"^S[DA](\d{8}|-\d{6})$")
_CODE = re.compile(r"^S[DAU](\d{8}|-\d{6})$")
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
@@ -153,8 +154,9 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출을 번호와 같이
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
통합문서(area)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
번호 칸(도번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는
`M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도).
번호 칸(도번 · 일위대가번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
@@ -164,8 +166,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
origins = _take_origins(doc) if kind == "table" else {}
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
if kind in SHEETS:
doc = _server_values(doc)
doc = _server(kind, doc)
with _LOCK:
have = version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
@@ -177,10 +178,13 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
doc = {**doc, _KEY[kind]: number} if number else doc
atomic_write_json(path, doc)
info = _info(kind, path)
if kind == "area":
_refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도
if kind == "table" and name == STRUCTURE_TABLE:
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
for part in _KEY:
_ensure(part, column, source)
_refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
_copy_inputs(source, column)
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
prune_removed_structures()
@@ -280,6 +284,17 @@ def _retired() -> list[str]:
def _blank(kind: str, column: str) -> dict[str, Any]:
"""빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 /
면적산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
if kind == "labor":
return {
"종류": "일위대가",
"판": 1,
"열": column,
"단위": None,
"본뜬": None,
"담은로직": [],
"자재": [],
"계산값": {},
}
if kind == "structure":
return {
"양식": "구조물도면",
@@ -309,9 +324,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
base = _blank(kind, column)
if source and _path(kind, source).is_file():
got = json.loads(_path(kind, source).read_bytes())
base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}
if kind in SHEETS:
base = _server_values(base)
base = _server(kind, {**{k: v for k, v in got.items() if k != "계산값"}, "열": column})
with _LOCK:
if path.is_file():
return False
@@ -321,7 +334,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
def _take_origins(doc: Any) -> dict[str, str]:
"""표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) ·
그 열에 일위대가 키가 비었으면 원본 열 것을 받음."""
일위대가는 원본 열 문서를 새 번호로 복사(`_ensure` · 39-1b — 조합 키는 안 받음)."""
cols = (
[c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else []
)
@@ -334,34 +347,47 @@ def _take_origins(doc: Any) -> dict[str, str]:
if (root() / "structure" / f"{col['id']}.json").is_file():
continue
out[col["id"]] = source
col["일위대가"] = col.get("일위대가") or by_id[source].get("일위대가")
return out
def create_structure(column: str) -> dict[str, Any]:
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
면적산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리)."""
일위대가 · 면적산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
if column not in _column_ids():
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
if not _ensure("structure", column):
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
for kind in SHEETS:
_ensure(kind, column)
for kind in _KEY:
_ensure(kind, column) # 도면은 위에서 만듦 · 있는 것은 그대로
return read("structure", column)
def copy_from(kind: str, column: str, source: str) -> dict[str, Any]:
def copy_from(
kind: str,
column: str,
source: str,
source_root: Path | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은
따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂."""
따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 ·
`source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌
(실무문서 조합 → 일위대가 · 39-1b)."""
if kind not in _KEY:
raise StoreError(404, f"없는 종류 「{kind}」")
target = read(kind, column)["문서"]
doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}}
if kind in SHEETS:
doc = _server_values({k: v for k, v in doc.items() if k != "계산값"})
if body is None and source_root is not None:
with at(source_root):
body = read(kind, source)["문서"]
body = read(kind, source)["문서"] if body is None else body
doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}}
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"})
with _LOCK:
atomic_write_json(_path(kind, column), doc)
return _info(kind, _path(kind, column))
info = _info(kind, _path(kind, column))
if kind == "area":
_refresh_labor(column)
return info
def _copy_inputs(source: str, column: str) -> None:
@@ -454,6 +480,30 @@ def renumber() -> dict[str, list[str]]:
return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made}
def _server(kind: str, doc: Any) -> Any:
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`)."""
if kind in SHEETS:
return _server_values(doc)
if kind == "labor":
from M02_MasterTemplete import M02_Labor_Doc as labor
return labor.server(doc)
return doc
def _refresh_labor(column: str) -> None:
"""그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠."""
path = _path("labor", column)
if not path.is_file():
return
try:
doc = _server("labor", json.loads(path.read_bytes()))
except StoreError:
return
with _LOCK:
atomic_write_json(path, doc)
def _server_values(doc: Any) -> dict[str, Any]:
try:
return spreadsheet.with_server_values(doc)
@@ -196,6 +196,22 @@
color: var(--color-text-secondary);
}
/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */
.m02-struct__start {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-8);
padding-bottom: var(--spacing-16);
}
.m02-struct__start-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
padding: 0 var(--spacing-16);
}
/* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */
.m02-pick__list {
display: flex;
@@ -247,3 +263,14 @@
.m02-struct__sheet-grid .ss-links__list {
max-height: 8rem;
}
/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */
.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);
}
@@ -23,13 +23,14 @@ import {
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import {
copyArea,
copyStructure,
fetchLaborPills,
fetchStructureNumbers,
getActiveLayer,
readTemplate,
getActiveOwner,
saveTemplate,
type StructureNumbers,
type TreeEdit,
@@ -44,7 +45,8 @@ import {
type SheetKind,
} from "./M02_MasterTemplete_ItemCards";
import { layerSource } from "./M02_MasterTemplete_ItemSource";
import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick";
import { createInputsCard, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
import "./M02_MasterTemplete_Structure.css";
export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards";
@@ -63,10 +65,10 @@ const TEXT = {
overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로",
pickCopy: "{value} 복사해 가져오기",
copied: "복사해 가져옴",
acrossLater: "다른 곳(층 · 사람)에서 가져오기는 서버 길이 선 뒤 — 지금은 같은 곳만",
copyFailed: "복사 실패 — {value}",
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름",
noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음",
inputsFailed: "일위대가 입력값을 못 받음 — {value}",
noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
pillsFailed: "일위대가 알약을 못 받음 — {value}",
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
};
@@ -270,26 +272,26 @@ export const columnHead = (col: StructureColumn): string =>
export const structureLabel = (number: string, col: StructureColumn): string =>
[number, columnHead(col)].filter(Boolean).join(" ");
/** 면적산출 편집 화면 위 알약 막대 — 그 열 일위대가 입력값(물량).
/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽).
* 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */
export async function pillsFor(
_kind: SheetKind, // 종류는 면적산출 하나 — 부르는 쪽 모양 그대로
column: string,
): Promise<{ links: LinkOptions; notice?: string }> {
const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력
const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice });
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
() => null,
);
const cols = (table?.문서 as { 열?: { id: string; 일위대가?: string | null }[] } | null)?.열;
const combo = cols?.find((c) => c.id === column)?.일위대가;
if (!combo) return none(TEXT.noPrice);
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
try {
const got = await fetchComboInputs(combo);
const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own];
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo));
const { 알약 } = await fetchLaborPills(column);
const 항목 = 알약.map((p) => ({
키: p.키,
이름: p.이름,
단위: p.단위,
...(p.자재 ? { 자재: p.자재 } : {}),
고정: true,
}));
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills);
} catch (error) {
return none(fill(TEXT.inputsFailed, why(error)));
return none(fill(TEXT.pillsFailed, why(error)));
}
}
@@ -356,6 +358,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
warnOnFail: (id) => !!numbers[id],
extraCards: [inputs.root],
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
onImport: (kind) => void pickCopy(kind, shown ?? undefined),
// 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
sheetEdit: {
can: () => opts.isAdmin,
@@ -399,37 +403,31 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
return got ? got.key : null;
};
/** 다른 열의 도면 · 면적산출을 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */
const pickCopy = async (
kind: EditKind,
colId: string,
columns: StructureColumn[],
): Promise<void> => {
const field = PART_FIELD[kind];
/** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 ·
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */
const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise<void> => {
const word = PART_WORD[kind];
const copy = COPY[kind];
const to = columns.find((c) => c.id === colId);
const toNo = numbers[colId]?.[field];
const toNo = to && numbers[to.id]?.[PART_FIELD[kind]];
if (!to || !toNo) return void showToast(TEXT.saveFirst, "error");
const items = columns
.filter((c) => c.id !== colId && numbers[c.id]?.[field])
.map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) }));
const from = await choose(
`${fill(TEXT.pickCopy, word)} — ${toNo} ${columnHead(to)}`,
items,
false,
);
const src = columns.find((c) => c.id === from);
if (!from || !src) return;
const ask = TEXT.overwrite
.replace("{from}", `${numbers[from][field]} ${columnHead(src)}`)
.replace("{what}", word)
.replace("{to}", `${toNo} ${columnHead(to)}`);
if (!(await showConfirmDialog(ask))) return;
const target = `${toNo} ${columnHead(to)}`;
const here = { layer: getActiveLayer(), owner: getActiveOwner() };
const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner;
const picked = await pickHere({
title: `${fill(TEXT.pickCopy, word)} — ${target}`,
kind,
skip: (from, row) => same(from) && row.열 === to.id,
ask: (p) =>
TEXT.overwrite
.replace("{from}", `${p.from.label} ${pickedName(p)}`)
.replace("{what}", word)
.replace("{to}", target),
});
if (!picked || !("row" in picked)) return;
try {
await copy(colId, from);
if (!same(picked.from)) throw new Error(TEXT.acrossLater); // 층 넘는 복사 길(39-1b) 뒤에 이음
await COPY[kind](to.id, picked.row.열);
showToast(TEXT.copied, "success");
if (column === colId) void load(shown);
if (column === to.id) void load(shown);
} catch (error) {
showToast(fill(TEXT.copyFailed, why(error)), "error");
}
@@ -442,7 +440,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
): Promise<string | null> => {
if (!opts.isAdmin || !colId) return null;
if (row === "s:price") return pickPrice(colId, columns);
if (ROW_KIND[row]) await pickCopy(ROW_KIND[row], colId, columns);
if (ROW_KIND[row])
await pickCopy(
ROW_KIND[row],
columns.find((c) => c.id === colId),
);
return null;
};
@@ -35,6 +35,7 @@ import {
type TreeRef,
type TreeView,
} from "./M02_MasterTemplete_Api_Fetch";
import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
import {
@@ -81,18 +82,12 @@ export interface TreePanelOptions {
dest: string[],
) => Promise<TreeView>;
};
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
clipKind?: string;
/** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */
pickCombo?: () => Promise<{ key: string; label: string } | null>;
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
copyTo?: { layer: Layer; owner: string | null; label: string }[];
/** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */
copyTo?: CopyTarget[];
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
maxDepth?: number;
/** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */
item?: { add: string; name: string; onAdd?: (path: string[]) => void };
/** 항목을 누름 — 표에서 그 열을 고름 */
onPick: (item: TreeItem) => void;
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
@@ -113,6 +108,14 @@ export interface TreePanelHandle {
select: (colId: string | null) => void;
/** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */
newItem: () => void;
/** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */
folder: () => string[];
/** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */
copyIn: (
from: { layer: Layer; owner: string | null },
key: string,
dest: string[],
) => Promise<void>;
/** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */
editFromTable: (body: TreeEdit, colId: string | null) => Promise<void>;
}
@@ -123,7 +126,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const owner = opt.owner ?? null;
const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree };
const clipKind = opt.clipKind ?? "구조물";
const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임
let view: TreeView | null = null;
let text = "";
let activeCol: string | null = null;
@@ -213,13 +216,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return [
...(target.kind === "folder"
? [
...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth
? []
: [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]),
{
label: opt.item?.add ?? L("M02_TreeAddItem"),
run: () => void addItem(target.path),
},
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
]
: []),
...(isTop
@@ -428,8 +426,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
const gone = note?.지운항목 ?? [];
const made = note?.열;
const col =
typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null;
// 붙여넣기로 새 열이 하나면(복사 생성) 그 열
const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null;
const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
activeCol = col;
const parent = note?.경로 ? note.경로.slice(0, -1) : [];
parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1))));
@@ -466,7 +465,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
async function addItem(parent: string[]): Promise<void> {
if (!(await ready())) return;
if (opt.item?.onAdd) return opt.item.onAdd(parent);
// 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2)
const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : [];
const got = await askNewItem({
@@ -490,10 +488,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const got = await askText(L("M02_TreeRename"), [
{
key: "name",
label:
target.kind === "item"
? (opt.item?.name ?? L("M02_TreeItemName"))
: L("M02_TreeFolderName"),
label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"),
value: nameOf(target),
},
]);
@@ -548,18 +543,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
}
/** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */
async function copyTo(
list: Target[],
to: { layer: Layer; owner: string | null; label: string },
): Promise<void> {
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */
async function copyTo(list: Target[], to: CopyTarget): Promise<void> {
if (!api.paste || !list.length) return;
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
try {
await api.paste(to.layer, to.owner, clip, []);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, "");
const dest = await askCopyFolder(api.fetch, to, list.length);
if (!dest) return;
await api.paste(to.layer, to.owner, clip, dest);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
showToast(
L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where),
L("M02_TreeCopiedTo")
.replace("{n}", String(list.length))
.replace("{layer}", where)
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
"success",
);
} catch (error) {
@@ -658,13 +655,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
});
/** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */
const newItem = (): void => {
const folder = (): string[] => {
const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined;
void addItem(at?.kind === "folder" ? at.path : []);
return at?.kind === "folder" ? at.path : [];
};
const newItem = (): void => void addItem(folder());
const copyIn = async (
from: { layer: Layer; owner: string | null },
key: string,
dest: string[],
): Promise<void> => {
const run = api.paste;
if (!run || !opt.canEdit || !(await ready())) return;
await apply(() =>
run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest),
);
};
const editFromTable = async (body: TreeEdit, colId: string | null): Promise<void> => {
if (opt.canEdit && (await ready())) await edit(body, colId);
};
return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable };
return {
root,
search: search.root,
add: tools,
refresh,
select,
newItem,
folder,
copyIn,
editFromTable,
};
}
@@ -0,0 +1,26 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_CopyTo.ts
* 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null.
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
* ========================================================================== */
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
export interface CopyTarget {
layer: Layer;
owner: string | null;
/** 메뉴 글 — `{n}` = 개수 */
label: string;
}
export async function askCopyFolder(
fetchTree: (layer: Layer, owner: string | null) => Promise<TreeView>,
to: CopyTarget,
n: number,
): Promise<string[] | null> {
const view = await fetchTree(to.layer, to.owner).catch(() => null);
const folders = view ? listFolders(buildFolders(view)) : [];
return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []);
}
@@ -4,8 +4,8 @@
* 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임.
* 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝)
* 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사)
* 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무
* (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만
* 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무
* (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로
* 누르면 `go`(부르는 쪽이 해시로 다시 엶).
* ========================================================================== */
@@ -18,6 +18,7 @@ import {
accessFor,
buildNeedCompany,
layerOfRoot,
manages,
ownOwner,
type Root,
} from "./M02_MasterTemplete_Layer";
@@ -40,9 +41,9 @@ export interface LayerBarOptions {
export const companyOwner = (company: string | null, user: SessionUser | null): string | null =>
company && company !== ownOwner("company", user) ? company : null;
/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */
/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */
export const seesOthers = (company: string | null, user: SessionUser | null): boolean =>
user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user);
manages(company, user);
export function createLayerBar(opt: LayerBarOptions): HTMLElement {
const { user, active } = opt;
@@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement {
}
function button(root: Root, owner: string | null, people = false): HTMLButtonElement {
const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite;
const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite;
const name = L(
root === "master"
? "M02_PickerSystem"
@@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement {
box.replaceChildren(
...rows.flatMap((one) => {
const current = one.owner === opt.active;
const node = { id: one.owner, label: one.name, count: null, folder: true };
const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name;
const node = { id: one.owner, label, count: null, folder: true };
const row = createTreeRow(node, current ? "▾" : "▸", 0, () => {
if (!current) return opt.go(one.owner);
wrap.hidden = !wrap.hidden;
@@ -57,6 +57,7 @@ type Mods = Record<string, () => Promise<unknown>>;
const NUMBER_FIELD: Partial<Record<Kind, string>> = {
structure: "도번",
area: "면적번",
labor: "일위대가번",
};
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
// 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를
@@ -141,7 +142,8 @@ export function createMain(
...(extras.top ? [extras.top] : []),
el("div", {
className: "m02-main__head",
children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])],
// M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂)
children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])],
}),
...(extras.search ? [extras.search] : []),
notice,
@@ -372,6 +374,20 @@ export function createMain(
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
return;
}
if (at.kind === "labor") {
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
editor = mountLaborDetail(host, doc, {
layer: at.layer,
column: at.name,
readOnly,
onChanged: (d) => (dirty = d && !readOnly),
goArea: async () => {
if (await confirmLeave()) void open({ ...at, kind: "area" });
},
});
return;
}
if (at.kind === "area") {
// 면적산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
@@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { navigateTo, type MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch";
import {
accessFor,
buildReadOnlyBar,
@@ -27,7 +27,6 @@ import {
type OpenTarget,
type Root,
} from "./M02_MasterTemplete_Layer";
import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor";
import {
headRenameEdit,
pickCombo,
@@ -42,9 +41,22 @@ import {
seesOthers,
} from "./M02_MasterTemplete_TreePanel_Roots";
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side";
import { buildAllItems } from "./M02_MasterTemplete_ItemList";
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
import "./M02_MasterTemplete_UI_Style.css";
const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안";
const NEW_ASK =
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */
const ALL_TITLE = {
structure: "M02_KindStructure",
labor: "M02_KindLabor",
area: "M02_KindArea",
} as const satisfies Record<AllKind, string>;
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
const user = await fetchSessionUser().catch(() => null);
const entry = entryFromHash();
@@ -69,12 +81,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
// 「〇〇로 복사」(30-2) — 이 사람이 쓸 수 있는 자기 층(지금 보는 층은 뺌) 맨 위로 바로
// 「〇〇로 복사…」(30-2 · 39-6) — 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 보는 층은 뺌) ·
// 대상 폴더는 고르는 창
const here = owner === ownOwner(layer, user) ? null : owner;
const copyTo = (["master", "company", "personal"] as Root[]).flatMap((one) => {
const to = layerOfRoot(one);
if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];
const toOwner = one === "company" ? companyOwner(company, user) : null;
if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return [];
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }];
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
});
const tree = createTreePanel({
layer,
@@ -86,14 +101,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
settle: () => main.settle(),
onChanged: (colId) => main.openTable(layer, colId, true),
});
// 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 ·
// 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로)
let view: OpenTarget = entry.open;
const mark = (open: OpenTarget): void => {
view = open;
const query = new URLSearchParams(entryQuery({ ...entry, open }));
history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
};
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
if (!(await main.confirmLeave())) return;
@@ -102,9 +109,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
);
};
// 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 구조물 = 집계표 · 일위대가 = 조합 목록)
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
createLayerBar({
user,
@@ -127,16 +134,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
const main = createMain(
isAdmin,
() => void Promise.all([side.refresh(), tree.refresh()]),
() => {
side.paint();
// 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음)
if (main.current() && view === "일위대가") mark("구조물조합");
},
() => side.paint(),
{
top: readOnlyBar?.root,
search: tree.search,
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 나무 「항목 추가」와 같은 창
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {}),
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}),
onColumn: (column) => {
readOnlyBar?.setColumn(column);
tree.select(column);
@@ -150,6 +153,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
: {}),
},
);
/** 새 구조물 조합(39-5 · 답 2) — 새로 만들기 = 나무 「항목 추가」 창 · 복사 생성 = 탭 + 목록 →
* 지금 보는 층 · 나무에서 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 새 키 · 새 번호 */
const openNew = (): void => {
const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop");
main.showView(
L("M02_TreeNewItem"),
buildTwoWay({
note: NEW_NOTE.replace("{value}", where()),
onNew: () => tree.newItem(),
picker: { user, company, kind: "item" },
ask: (picked) =>
NEW_ASK.replace("{name}", pickedName(picked))
.replace("{from}", picked.from.label)
.replace("{to}", where()),
onCopy: async (picked) => {
if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder());
},
}),
);
};
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
const home: Selection = {
layer,
@@ -168,19 +191,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
confirmLeave: main.confirmLeave,
onOpen: () => {
onOpen();
mark("일위대가");
},
bar: (add, back) => layerBar("일위대가", add, back),
wrap: (panel) => peopleTree("일위대가", panel),
make: (reveal) =>
buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }),
}),
// 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`)
onAll: (kind) =>
void main.showView(
`${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`,
buildAllItems(kind, layer, owner, (row) => {
void main.open({
layer,
projectId: null,
kind: kind as Kind,
name: row.열,
label: row.이름,
});
}),
),
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -200,8 +224,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
root.innerHTML = "";
root.append(layout);
void main.open(home);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 층 ·
// 옵션 · 일위대가 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 구조물 조합 하위(세 층) ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
@@ -1,9 +1,10 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출) ·
* 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA + 같은 8자리) ·
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
* 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
@@ -38,20 +39,29 @@ import {
structureLabel,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import type { LaborSection } from "./M02_MasterTemplete_Labor";
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */
type ColumnKind = "structure" | "area";
const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure") },
{ kind: "area", key: "면적번", label: () => L("M02_KindArea") },
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 ·
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */
type ColumnKind = "structure" | "labor" | "area";
const COLUMN_KINDS: {
kind: ColumnKind;
key: keyof StructureNumbers;
label: () => string;
all: boolean;
}[] = [
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
{ kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true },
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
];
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
export type AllKind = "structure" | "labor" | "area";
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
const STUB_DRAWING = {
format: 6,
@@ -72,10 +82,10 @@ export interface SideOptions {
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */
labor?: (hooks: { onOpen: () => void }) => LaborSection;
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
tree?: HTMLElement[];
/** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */
onAll?: (kind: AllKind) => void;
}
export interface SideHandle {
@@ -83,7 +93,7 @@ export interface SideHandle {
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
reveal: (target: "옵션" | "도면" | "일위대가") => void;
}
@@ -103,6 +113,8 @@ export function buildSide(opt: SideOptions): SideHandle {
let items: TemplateInfo[] = [];
let columns: StructureColumn[] = [];
let numbers: Record<string, StructureNumbers> = {};
/** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */
let allPicked: AllKind | null = null;
let seq = 0;
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
@@ -134,9 +146,8 @@ export function buildSide(opt: SideOptions): SideHandle {
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (labor?.clear(), paint()),
onOpen: () => (practice?.clear(), (allPicked = null), paint()),
});
const labor = opt.labor?.({ onOpen: () => (options.clear(), paint()) });
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
@@ -144,12 +155,14 @@ export function buildSide(opt: SideOptions): SideHandle {
});
return sec;
};
// 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i]));
// 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
const summary = section(
L("M02_KindTable"),
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
el("hr", { className: "m02-side__rule" }),
...columnSections,
);
summary.classList.remove("is-collapsed");
summary.classList.add("m02-side__summary");
@@ -164,17 +177,24 @@ export function buildSide(opt: SideOptions): SideHandle {
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
}
: null;
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
const practice =
layer === "system" && opt.isAdmin
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (options.clear(), (allPicked = null), paint()),
})
: null;
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []),
summary,
...(extras ? [extras.옵션, extras.도면] : []),
...(practice ? [section(practice.title, practice.body)] : []),
],
});
attachCollapsible(listHost);
// 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침)
labor?.body.closest("section")?.classList.remove("is-collapsed");
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
@@ -214,9 +234,23 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
/** 구조물 도면 · 면적산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void {
/** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */
function allRow(kind: AllKind): HTMLElement {
const b = el("button", {
className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`,
text: L("M02_ItemsAll"),
attrs: { type: "button", "data-all": kind },
});
b.addEventListener("click", () => void selectAll(kind));
return b;
}
/** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void {
const { kind, key } = k;
const head = k.all ? [allRow(kind as AllKind)] : [];
body.replaceChildren(
...head,
...(columns.length
? columns.map((col) => {
const num = numbers[col.id]?.[key] || L("M02_StructureNone");
@@ -231,8 +265,7 @@ export function buildSide(opt: SideOptions): SideHandle {
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
}
const paintColumns = (): void =>
COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key));
const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k));
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
@@ -243,11 +276,21 @@ export function buildSide(opt: SideOptions): SideHandle {
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
function paint(): void {
if (opt.getOpen()) allPicked = null;
paintList();
paintColumns();
paintButtons();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) labor?.clear();
if (opt.getOpen()) practice?.clear();
}
async function selectAll(kind: AllKind): Promise<void> {
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
options.clear();
practice?.clear();
allPicked = kind;
opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림
paint();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
@@ -372,9 +415,12 @@ export function buildSide(opt: SideOptions): SideHandle {
}
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
// 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」
if (target === "일위대가") {
labor?.body.closest("section")?.classList.remove("is-collapsed");
return void labor?.open();
columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove(
"is-collapsed",
);
return void selectAll("labor");
}
extras?.[target].classList.remove("is-collapsed");
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
@@ -15,6 +15,53 @@
margin-left: var(--spacing-8);
}
/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */
.m02-side__rule {
margin: var(--spacing-8) 0;
border: 0;
border-top: 1px solid var(--color-border);
}
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
.m02-practice {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-practice__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-practice__bar > button:last-child:not(:first-child) {
margin-left: auto;
}
.m02-practice__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-practice__memo {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-wrap;
}
.m02-practice__row {
cursor: pointer;
}
.m02-practice__row:hover {
background: var(--color-mist-violet);
}
.m02-side__row {
display: flex;
flex-wrap: wrap;
@@ -42,7 +89,7 @@
overflow-wrap: anywhere; /* 번호 + 이름 — 잘리지 않게 줄바꿈 */
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
font-size: 18px; /* M01 머리 제목과 같음(39-5) */
}
.m02-main__head .ui-btn {
@@ -207,14 +254,6 @@
font-size: 11px;
}
/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */
.m02-labor {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-main__head + .m02-tree__search {
margin-bottom: var(--spacing-8);
}
@@ -9,7 +9,9 @@
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
종류 = 도면 · 면적 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용).
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
종류 = 도면 · 면적 · 입력연결 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
`refresh` = 옛 이름(재적용).
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
"""
@@ -31,7 +33,7 @@ from M02_MasterTemplete.M02_Item_Source import item_name
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
Kind = Literal["도면", "면적", "입력연결", "입력값", "고른자재"]
Kind = Literal["도면", "면적", "입력연결", "입력값", "고른자재", "일위대가"]
class WriteBody(BaseModel):
+3 -1
View File
@@ -27,7 +27,8 @@ from config import config_system
LAYERS = ("system", "company", "personal", "project")
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
KINDS = ("table", "drawing", "structure", "area")
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
KINDS = ("table", "drawing", "structure", "labor", "area")
TEMPLATES_DIRNAME = "templates"
INITIAL_DIRNAME = "_initial"
MANIFEST_NAME = "manifest.json"
@@ -168,6 +169,7 @@ _SKELETON = {
"table": ("열",),
"drawing": ("entities",),
"structure": ("도면.entities",),
"labor": ("담은로직",),
"area": ("시트",),
}
+2
View File
@@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import (
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router
@@ -649,6 +650,7 @@ app.include_router(m02_layers_router, dependencies=protected)
app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_combos_router, dependencies=protected)
app.include_router(m02_drawing_router, dependencies=protected)
app.include_router(m02_labor_router, dependencies=protected)
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
app.include_router(m02_project_items_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
+340
View File
@@ -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
}
}
}
@@ -4,6 +4,7 @@
* ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403)
* ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음)
* ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음
* ④ 일위대가(39-1c): 복사본 요약 · 원본 층 계산 요약 · 넣을 값 = 담은 로직 입력 전부
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
const fs = require("fs");
@@ -66,6 +67,21 @@ globalThis.fetch = async (url, init = {}) => {
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(init.body) : undefined;
state.calls.push({ method, url, body });
if (url.startsWith("/api/m02/logic-lib/inputs?keys=")) {
state.logicKeys = decodeURIComponent(url.split("=")[1]).split(",");
return reply(200, { 로직: INPUTS.로직, 빠진로직: [] });
}
if (/^\/api\/m02\/layers\/system\/labor\/rv\/calc$/.test(url)) {
return reply(200, {
줄: [
{ 로직: "GA1", 까닭: null },
{ 로직: "GA2", 까닭: "입력 없음" },
],
자재: [{ 코드: "MT000008" }],
기준합: { 계: 500 },
반영합: { 계: 1500 },
});
}
if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) {
state.inputsHits += 1;
return reply(200, INPUTS);
@@ -97,7 +113,13 @@ globalThis.fetch = async (url, init = {}) => {
const folder = body.structure.replace("#", "n");
state.folders[folder] = {
info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" },
parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null },
parts: {
면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] },
입력값: null,
일위대가: {
줄: { 일위대가번: "SU-000007", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }], 자재: [{}] },
},
},
};
return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" });
}
@@ -132,6 +154,8 @@ globalThis.fetch = async (url, init = {}) => {
{ 로직: "GA2", 입력: "지역" },
],
멈춤: ["GA1"],
기준합: { 계: 100 },
반영합: { 계: 300 },
});
if (method === "GET") {
const content = item.parts[part] ?? null;
@@ -226,6 +250,18 @@ async function main() {
out.calc = await items.calc(info.폴더);
out.calcMissing = await items.calc("없는폴더");
// 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부
out.laborCopy = await project.laborOf(row(), items, info.폴더);
out.laborOrigin = await project.laborOf(row(), null, null);
out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null);
const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} });
out.laborValues = {
keys: state.logicKeys,
link: valueData.link.줄.map((l) => `${l.로직}|${l.입력}|${l.자리}`),
shown: model.pickedGroups(valueData.list, valueData.link).length,
values: valueData.values,
};
// 입력 목록 — 같은 항목은 한 번만 받음
const first = await project.loadInputs(row());
await project.loadInputs(row());
+12 -7
View File
@@ -4,13 +4,18 @@ const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const local = (request) =>
request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request);
new Function("exports", "module", "require", js)(box.exports, box, local);
return box.exports;
}
const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
// 같은 이름(높이)의 입력이 두 로직에 있음
const list = {
+65
View File
@@ -0,0 +1,65 @@
/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고
* JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete");
const load = (name, stubs) => {
const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const need = (id) => (id in stubs ? stubs[id] : {});
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
};
const model = load("M02_MasterTemplete_TreePanel_Model.ts", {});
const m = load("M02_MasterTemplete_ItemList.ts", {
"./M02_MasterTemplete_TreePanel_Model": model,
});
const row = (열, 경로, extra = {}) => ({
key: `SS-${열}`,
열,
경로,
이름: 경로.join(" "),
단위: "m",
도번: `SD-${열}`,
면적번: `SA-${열}`,
수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" },
labor: null,
...extra,
});
const rows = [
row("c1", ["기슭막이", "메쌓기", "1.2m"], {
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 },
수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" },
}),
row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }),
row("c3", ["옹벽"], { 도번: "", 수정일: {} }),
];
const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r)));
const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`]));
process.stdout.write(
JSON.stringify({
heads: Object.fromEntries(
Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]),
),
cells: {
structure: cells("structure"),
area: cells("area"),
labor: cells("labor"),
item: cells("item"),
},
find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열),
findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열),
findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1),
page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))(
m.pageOf(many, m.ITEM_COLS.structure, "", 3),
),
pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page,
pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page,
}),
);
@@ -0,0 +1,21 @@
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"M02_MasterTemplete",
"M02_MasterTemplete_Labor_Calc.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값)));
@@ -0,0 +1,92 @@
/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
let pass = 0;
const failures: string[] = [];
const eq = (name: string, got: unknown, want: unknown): void => {
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
};
const book = (): Workbook =>
({
종류: "통합문서",
판: 1,
열: "t",
서식: [{}],
시트: [
{
id: "s1",
이름: "면적산출",
칸: { B1: { 값: 12.5 } },
연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }],
},
{ id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] },
],
활성: "s1",
}) as unknown as Workbook;
const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null);
const run = (b: Workbook, c: Command | null | false) => {
if (!c) return null;
return applyCommand(b, c).undo;
};
// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음
let b = book();
const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" };
let undo = run(
b,
linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }),
);
eq("새 연결", b.시트[0].연결?.[1], {
키: "자재|MT000008",
칸: "D2",
이름: "치즐",
단위: "EA",
자재: mt,
});
// 되돌리기 = 처음 그대로
run(b, undo);
eq("되돌림", links(b), links(book()));
// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것
b = book();
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
b = book();
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
b = book();
undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1"));
eq("시트 넘어 옮김", links(b), [
[
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
{ 키: "치기|㎥", 칸: "A1", 이름: "치기" },
],
null,
]);
run(b, undo);
eq("시트 넘어 되돌림", links(b), links(book()));
// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐
b = book();
run(b, linkCommand(b, "s1", "깔기|㎡", null));
eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]);
// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false
b = book();
eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null);
eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null);
eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false);
eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false);
eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false);
eq("문서 그대로", links(b), links(book()));
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
@@ -72,7 +72,11 @@ def test_새_구조물_도면은_면적_진짜_빈_시트_같은_8자리(client)
assert doc[key] == f"{head}-000002"
assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["pv_b"] == {"도번": "SD-000002", "면적번": "SA-000002"}
assert numbers["pv_b"] == {
"도번": "SD-000002",
"일위대가번": "SU-000002",
"면적번": "SA-000002",
}
@pytest.mark.parametrize(("kind", "key", "head"), [("area", "면적번", "SA")])
+44 -5
View File
@@ -82,7 +82,7 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out):
assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply"
assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로
assert out["reapply"]["info"]["마스터바뀜"] is False
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1}
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
@@ -137,14 +137,51 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀():
def test_선택_입력_카드는_sub2_파일_한_벌(out):
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일)."""
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은
값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄."""
assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists()
inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8")
values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8")
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value)
assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value)
assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
text = (UI / name).read_text(encoding="utf-8")
assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name
text = (UI / name).read_text(encoding="utf-8") # 모달은 값 카드를 바로 받음(39-1c)
assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name
def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
"""39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc ·
넣을 값 = 담은 로직 입력 전부."""
assert out["laborCopy"] == {
"번호": "SU-000007",
"로직": ["GA1", "GA2"],
"자재": 1,
"base": 100,
"reflected": 300,
"gaps": 2,
"stuck": 1,
}
assert out["laborOrigin"] == {
"번호": None,
"로직": ["GA1", "GA2"],
"자재": 1,
"base": 500,
"reflected": 1500,
"gaps": 0,
"stuck": 1,
}
assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404)
values = out["laborValues"]
assert values["keys"] == ["GA1", "GA2"]
assert values["link"] == ["GA1|지역|선택", "GA1|소량감률|선택", "GA2|지역|선택"]
assert values["shown"] == 2 and values["values"] == {"GA1": {}}
def test_모달_카드_차례_도면_일위대가_면적():
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
assert 'import("./ui_template_item_picker_labor")' in detail
assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail
def test_모달은_원본_층에_쓰는_길을_안_부름():
@@ -183,6 +220,8 @@ def test_소스가_700줄을_넘지_않음():
M02 / "M02_MasterTemplete_ItemSource.ts",
M02 / "M02_MasterTemplete_Inputs.ts",
M02 / "M02_MasterTemplete_Inputs_Model.ts",
M02 / "M02_Labor_Values.ts",
M02 / "M02_Labor_Values_Model.ts",
):
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
+11 -4
View File
@@ -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"]
+43
View File
@@ -176,3 +176,46 @@ def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) ->
assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0
listed = _get(client, "/api/m01/logics", owner="공용")["logics"]
assert made["key"] in [x["키"] for x in listed]
def test_일위대가가_담은_로직은_못_지운다(client: TestClient, tmp_path: Path, monkeypatch) -> None:
"""39-1d — 로직 지우기 검사 = 모든 층(마스터 · 회사 · 개인) labor 담은로직 + 실무문서(마스터
일위대가 조합) · 쓰는 곳을 알림 · 다 빼면 지워짐(임시 storage · 임시 마스터)."""
import json
from config import config_system
from M02_MasterTemplete import M02_Template_Layers as layers
system, storage = tmp_path / "system", tmp_path / "storage"
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
key = made["key"]
docs = {
"회사 3": storage / "3/templates/labor/item1.json",
"개인 3/15": storage / "3/15/templates/labor/item2.json",
"마스터": system / "labor/item3.json",
}
for at, path in enumerate(docs.values()):
path.parent.mkdir(parents=True)
doc = {"일위대가번": f"SU-00000{at}", "담은로직": [{"로직": SAMPLE}, {"로직": key}]}
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
combos = store.FOLDER / store.mcb.FILE
raw = json.loads(combos.read_text(encoding="utf-8"))
raw["줄"].append({**raw["줄"][0], "키": "UA999999", "담은로직": [{"로직": key, "메모": None}]})
combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
def delete(want: int) -> dict:
version = _get(client, "/api/m01/logic", key=key)["version"]
return _post(client, "/api/m01/logic/delete", {"key": key, "version": version}, want)
errors = " ".join(delete(422)["detail"]["errors"])
for where in docs:
assert f"일위대가 {where} 「SU-" in errors and key in errors # 어디서 쓰는지 알림
assert store.mcb.FILE in errors # 실무문서(마스터 일위대가 조합)
for path in docs.values(): # 일위대가에서 뺌 — 실무문서만 남음
path.write_text(json.dumps({"담은로직": [{"로직": SAMPLE}]}), encoding="utf-8")
assert "일위대가 " not in " ".join(delete(422)["detail"]["errors"])
raw["줄"].pop()
combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
assert delete(200)["file"] == make.OWN # 쓰는 곳 0 = 지워짐
@@ -73,3 +73,30 @@ def test_저장_안_한_카드_고침_물음():
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
def test_면적산출_카드_알약_줄_켬끔_기본_숨김():
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김
assert 'writeStateRaw("m02-card-pills", String(shown));' in cards
assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards
assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음)
css = _text(M02 / "M02_MasterTemplete_Structure.css")
assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css)
state = _text(ROOT / "A00_Common" / "b_page_state.ts")
assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state
# 상세(전체 편집기)는 켬끔 없음 — 늘 보임
assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts")
def test_면적산출_알약은_일위대가_pills_길():
"""39-4 D7 — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 · 선택 입력 알약 빠짐."""
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
pills = struct.split("export async function pillsFor")[1].split("async function cardLinks")[0]
area = pills # 재료산출 갈래는 없앰 — 몸통이 면적산출 알약
assert 'kind === "material"' not in pills
assert "await fetchLaborPills(column)" in area
assert "고정: true" in area and "...(p.자재 ? { 자재: p.자재 } : {})" in area
assert "inputPills" not in area and "fetchComboInputs" not in struct
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
assert "/layers/${activeLayer}/labor/${enc(column)}/pills" in api
+3 -3
View File
@@ -40,12 +40,12 @@ def test_저장_단추_알림_한_벌():
def test_네_카드가_틀을_씀_수정_단추_한_모양():
cards = _text("M02_MasterTemplete_ItemCards.ts")
assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards
assert "shell.tools.append(help, saver.button, edit);" in cards
assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5)
assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost)
assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임
assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards
inputs = _text("M02_MasterTemplete_Inputs.ts")
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드
inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts")
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로)
assert "shell.fail(" in inputs and "shell.loading()" in inputs
assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음
drawing = _text("M02_MasterTemplete_DrawingCard.ts")
+1 -1
View File
@@ -190,7 +190,7 @@ def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_
assert client.put(TABLE, json={"판": table["판"], "문서": doc}).status_code == 200
saved = next(c for c in client.get(TABLE).json()["문서"]["열"] if c["id"] == "c9")
assert "본뜬열" not in saved # 뗌
assert saved["일위대가"] == ua
assert not saved.get("일위대가") # 열 키는 안 받음 — 일위대가는 labor 문서로 복사(39-1b)
copy = client.get("/api/m02/templates/structure/c9").json()["문서"]
assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "c9"
numbers = client.get("/api/m02/structures/numbers").json()
+30 -165
View File
@@ -4,8 +4,7 @@
- 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA.
- 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙).
- 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409.
- 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409).
- 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과.
- 나무 고침 · 층 사이 붙여넣기 길은 없음(PLAN 39-2 · 일위대가는 구조물 열마다).
- 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에.
- 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음.
정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본.
@@ -54,9 +53,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
app.include_router(items_router.router)
app.include_router(combos_router.router)
app.dependency_overrides[verify_session] = lambda: session
# 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙
app.dependency_overrides[combos_router._door] = lambda: None
return {"client": TestClient(app), "session": session, "storage": storage}
def test_조합_길은_시스템_관리자만(world):
"""39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직은 로그인만."""
world["client"].app.dependency_overrides.pop(combos_router._door)
for role in ("USER", "ADMIN"):
client = _as(world, role)
assert client.get(URL.format("system")).status_code == 403
assert client.get("/api/m02/combos/all").status_code == 403
assert client.get("/api/m02/combos/key/UA000001").status_code == 403
assert _new(client, "personal", _combo("내 것"), 403) is not None
assert client.get("/api/m02/logic-lib/logics").status_code == 200
world["session"]["is_master"] = True
assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도
world["session"]["is_master"] = False
admin = _as(world, "SYSTEM_ADMIN")
assert admin.get(URL.format("system")).status_code == 200
assert admin.get(URL.format("company")).status_code == 200
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
world["session"].update(role=role, user_id=user)
return world["client"]
@@ -78,13 +97,6 @@ def _list(client: TestClient, layer: str, **params) -> dict:
return res.json()
def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict:
body.setdefault("판", _list(client, layer)["판"])
res = client.post(URL.format(layer) + "/tree", json=body)
assert res.status_code == want, res.text
return res.json()
# ── 키 · 자리 ─────────────────────────────────────────────────────────
def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world):
boss = _as(world, "ADMIN")
@@ -129,66 +141,12 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world):
branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key))
assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보
url = f"{URL.format('personal')}/{first['키']}"
assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409
now = _list(me, "personal")["판"]
res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now})
assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B
# ── 나무 ─────────────────────────────────────────────────────────────
def test_나무_폴더_깊이_2_이름_옮기기_지우기(world):
me = _as(world)
key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"]
_tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기")
_tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기")
_tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째")
_tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기")
_tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"])
got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"])
row = next(r for r in got["줄"] if r["키"] == key)
assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기")
got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축")
assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]]
assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축"
_tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"])
got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[])
assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음
row = next(r for r in got["줄"] if r["키"] == key)
assert (row["구분"], row["상세구분"]) == ("찰쌓기", None)
_tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판")
_new(me, "personal", _combo("다른 것"))
got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌")
assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌"
assert (
"이미 있음"
in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0]
)
other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것")
_tree(me, "personal", op="delete", 키=other)
got = _tree(me, "personal", op="delete", 경로=["찰쌓기"])
assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]]
def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world):
me = _as(world)
key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"]
tree = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)},
).json()
column = tree["알림"]["열"]
use = me.post(
f"/api/m02/layers/personal/structures/{column}/use-combo",
json={"종류": "일위대가", "키": key},
)
assert use.status_code == 200, use.text
got = _tree(me, "personal", 409, op="delete", 경로=["석축"])
assert got["detail"]["users"] == ["옹벽 찰쌓기"]
res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]})
assert res.status_code == 409
assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음
me.put(url, json={"combo": _combo("원본 고침")}).status_code == 405
) # 조합 고치기 은퇴(39-1d)
# ── 마스터 사본 ───────────────────────────────────────────────────────
def _판(client: TestClient) -> str:
return client.get("/api/m02/layers/personal/tree").json()["판"]
@@ -208,28 +166,6 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
return folder
def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy):
admin = _as(world, "SYSTEM_ADMIN")
before = _list(admin, "system")
first = before["줄"][0]["키"]
got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본")
assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본"
before = _list(admin, "system")
old = next(r["구분"] for r in before["줄"] if r["구분"]) # 정본 폴더 이름에 기대지 않음
assert [old] in before["폴더"]
_tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더")
got = _tree(admin, "system", op="rename", 경로=[old], 이름="시험 폴더")
assert ["시험 폴더"] in got["폴더"] and ["빈 폴더"] in got["폴더"]
assert [old] not in got["폴더"]
doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8"))
assert ["빈 폴더"] in doc["폴더"]
assert sum(r["구분"] == "시험 폴더" for r in doc["줄"]) == len(
[r for r in before["줄"] if r["구분"] == old]
)
assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로
_tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘")
# ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ─────────────────────────────────
def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world):
boss = _as(world, "ADMIN")
@@ -316,81 +252,10 @@ def test_담을_로직_읽기는_로그인이면_됨(world):
assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200
# ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ─────────────────────────────
def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict:
body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "")
res = client.post(URL.format(layer) + "/paste", json=body, params=params)
assert res.status_code == want, res.text
return res.json()
def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world):
user = _as(world)
master = _list(user, "system")["줄"][:3]
keys = [r["키"] for r in master]
got = _paste(user, "personal", {"from_layer": "system", "키": keys})
assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"]
rows = {r["키"]: r for r in got["줄"]}
for pair, src in zip(got["새키"], master):
one = rows[pair["새키"]]
assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"])
assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위
again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]})
assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)")
_paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음
_paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403)
_paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404)
stale = {"from_layer": "system", "키": keys, "판": "낡은판"}
assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409
def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world):
admin = _as(world, "ADMIN")
for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])):
_new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1]))
_tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳")
body = {"from_layer": "personal", "폴더": [["석축"]]}
got = _paste(admin, "company", body)
assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"]
assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]]
by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]}
assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기")
_tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳")
deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]}
assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"]
one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]}
got = _paste(admin, "company", one) # 한 단 폴더는 들어감
assert ["다른 곳", "찰쌓기"] in got["폴더"]
def test_여러_개_옮기기_지우기_한_번에(world):
me = _as(world)
keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")]
_tree(me, "personal", op="add-folder", 경로=[], 이름="석축")
판 = _list(me, "personal")["판"]
ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]]
res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops})
assert res.status_code == 200, res.text
assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None]
bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]}
assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404
assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀
판 = _list(me, "personal")["판"]
ops = [{"op": "delete", "키": k} for k in keys[1:]]
got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json()
assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1]
stale = {"판": "낡은판", "ops": ops}
assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409
def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy):
# ── 나무 길 없음(PLAN 39-2) ────────────────────────────────────────────
def test_나무_고침_붙여넣기_길은_없음(world):
"""PLAN 39-2 — 일위대가 조합 나무 길(`/tree` · `/tree-many` · `/paste`)을 지움."""
admin = _as(world, "SYSTEM_ADMIN")
up = _new(admin, "personal", _combo("개인 것"))["키"]
before = len(_list(admin, "system")["줄"])
got = _paste(admin, "system", {"from_layer": "personal", "키": [up]})
new = got["새키"][0]["새키"]
assert new.startswith("UA") and len(got["줄"]) == before + 1
row = next(r for r in got["줄"] if r["키"] == new)
assert row["본뜬키"] is None and row["이름"] == "개인 것"
doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8"))
assert m01.cm.check_form(combos.FILE, doc) == []
for tail in ("/tree", "/tree-many", "/paste"):
res = admin.post(URL.format("personal") + tail, json={"판": ""})
assert res.status_code in (404, 405), tail
+3 -3
View File
@@ -26,7 +26,7 @@ from resources.tester.test_m02_structure import write_sample_table
from resources.tester.test_m02_tree import Tree, _app
ROOT = Path(__file__).resolve().parents[2]
NEW = re.compile(r"^S[DAM]-\d{6}$")
NEW = re.compile(r"^S[DAMU]-\d{6}$")
@pytest.fixture()
@@ -164,7 +164,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
doc = json.loads(path.read_bytes())
path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8")
old = {cid: _numbers(system, cid) for cid in store._column_ids()}
assert old["rv"] == {"SD01010100", "SA01010100"}
assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100"}
first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1
with store.at(system):
got = store.read("table", store.STRUCTURE_TABLE)
@@ -191,7 +191,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"})
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음
assert _numbers(system, "y") == {f"SD{last}", f"SA{last}"}
assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}"}
def test_층마다_번호는_따로_파일은_열_id_로(world):
+207
View File
@@ -0,0 +1,207 @@
"""프로젝트 복사본 · 일위대가 문서(PLAN 39-1c · 임시 저장소 · 임시 마스터 사본).
- 층 `labor/<열>.json` 이 있으면 스냅샷 `일위대가.json` = {줄: 문서, 로직, 빠진로직, 알약} ·
단가 묶음에 자재 MT
- `입력값.json` 첫 값 = 문서 입력값(확인 거짓) · 재적용은 프로젝트에서 넣은 값이 이김
- 계산 = 수량(손 · 면적 알약 칸) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 기준 단가 × 수량 ·
반영합 = 손셈 ·
기준 · 반영이 원본 층 문서 계산(`M02_Labor_Doc.calc`)과 같음
- 일위대가 쓰기는 400(읽기만)
"""
from __future__ import annotations
import json
import shutil
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from config import config_system
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import SAMPLE
MASTER = config_system.PROJECT_ROOT / "resources/master_data"
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
COLUMN = "pp_len"
FOLDER = "pipe@40.0"
LOGICS = ("GF000176", "GC000088", "GF000113")
MATERIAL = "MT000008"
HAND = 3 # GC000088 손 수량
AREA_QTY = 7.5 # 자재 · GF000113 은 면적 알약 칸 값
MAT_QTY = 4
@pytest.fixture(scope="module")
def master_copy(tmp_path_factory) -> Path:
target = tmp_path_factory.mktemp("master_data")
for path in MASTER.glob("*.json"):
shutil.copy2(path, target / path.name)
return target
def _read(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _write(path: Path, content: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(content, ensure_ascii=False), encoding="utf-8")
def _first_input(code: str) -> tuple[str, Any]:
whole = m01.loaded()[1]
name, value = next(iter(m01.mau.values(whole, whole.logic(code)).items()))
return name, int(value) if isinstance(value, Decimal) and value % 1 == 0 else value
@pytest.fixture
def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]:
system = tmp_path / "master"
system.mkdir()
for name in (layers.COMBO_FILE,):
shutil.copy2(SAMPLE / name, system / name)
for kind in ("table", "structure", "area"):
shutil.copytree(SAMPLE / kind, system / kind)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(m01, "FOLDER", master_copy)
storage = tmp_path / "storage"
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
root = storage / "3" / "12" / "proj"
(root / "B05_Profile/route").mkdir(parents=True)
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
name, value = _first_input("GC000088")
doc = labor_doc.clean(
{
"열": COLUMN,
"단위": "m",
"담은로직": [
{"로직": LOGICS[0]},
{"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}},
{"로직": LOGICS[2]},
],
"자재": [{"코드": MATERIAL}],
}
)
_write(system / "labor" / f"{COLUMN}.json", doc)
pills = {p["키"]: p for p in labor_doc.pills(doc)}
area_pill = next(k for k, p in pills.items() if LOGICS[2] in p.get("로직", []))
area = _read(system / "area" / f"{COLUMN}.json")
sheet = area["시트"][0]
sheet["칸"] = {"A1": {"값": AREA_QTY}, "A2": {"값": MAT_QTY}}
sheet["연결"] = [
{"키": area_pill, "칸": "A1"},
{"키": labor_doc.material_key(MATERIAL), "칸": "A2"},
]
_write(system / "area" / f"{COLUMN}.json", area)
return {
"root": root,
"doc": doc,
"input": (name, value),
"pills": pills,
"area_pill": area_pill,
}
def _save(root: Path) -> dict[str, list[str]]:
_write(root / "B05_Profile/route/structures.json", {"revision": 1, "structures": []})
pipe = {"chainage_m": 40.0, "source": "user", "summary_item": PIPE}
_write(root / "B04_PreProcess/drainage/edits/pipe_points.json", {"points": [pipe]})
return snap.sync_snapshots(root)
def _fill_auto(root: Path) -> None:
"""빠진 입력을 자동값으로(확인 참) — 문서 입력값은 그대로 둠."""
current = copies.read_file(root, FOLDER, "입력값")
values = current["내용"]
whole = m01.loaded()[1]
for code in LOGICS:
for key, auto in m01.mau.values(whole, whole.logic(code)).items():
mine = values.setdefault(code, {})
if key not in mine and auto is not None:
mine[key] = {"값": labor_doc._json(auto), "확인": True}
copies.write_file(root, FOLDER, "입력값", current["판"], values)
def test_스냅샷_일위대가는_문서_알약_자재_단가(world):
root = world["root"]
assert _save(root)["만듦"] == [FOLDER]
folder = root / "templates/structures" / FOLDER
labor = _read(folder / "일위대가.json")
assert labor["줄"] == world["doc"]
assert [row["키"] for row in labor["로직"]] == list(LOGICS) and labor["빠진로직"] == []
assert {p["키"] for p in labor["알약"]} == set(world["pills"])
keys = {
row["키"] for data in _read(folder / "단가.json").values() for row in data.get("줄", [])
}
assert MATERIAL in keys and set(LOGICS) <= keys
def test_입력값_첫_값은_문서_입력값(world):
root = world["root"]
_save(root)
name, value = world["input"]
values = _read(root / "templates/structures" / FOLDER / "입력값.json")
assert values == {LOGICS[1]: {name: {"값": value, "확인": False}}}
def test_재적용은_프로젝트_값이_이김(world):
root = world["root"]
_save(root)
name, _ = world["input"]
current = copies.read_file(root, FOLDER, "입력값")
mine = {LOGICS[1]: {name: {"값": 99, "확인": True}}}
copies.write_file(root, FOLDER, "입력값", current["판"], mine)
got = copies.reapply(root, FOLDER)
assert got["값있음"] is True
assert _read(root / "templates/structures" / FOLDER / "입력값.json") == mine
def test_계산_반영_합은_손셈_원본과_같음(world):
root = world["root"]
_save(root)
_fill_auto(root)
got = copies.calc(root, FOLDER)
assert got["멈춤"] == [], got["줄"]
lines = {line["로직"]: line for line in got["줄"]}
hand, area = lines[LOGICS[1]], lines[LOGICS[2]]
assert (hand["수량"], hand["수량출처"]) == (HAND, "손")
assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸")
assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None
for line in (hand, area):
expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect)
(mat,) = got["자재"]
assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"]
assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY)
total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"]
assert got["반영합"]["계"] == pytest.approx(total)
# 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것
values = _read(root / "templates/structures" / FOLDER / "입력값.json")
doc = {
**world["doc"],
"담은로직": [
{**one, "입력값": {k: v["값"] for k, v in values.get(one["로직"], {}).items()}}
for one in world["doc"]["담은로직"]
],
}
given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY}
origin = labor_doc.calc(doc, given)
assert got["반영합"] == pytest.approx(origin["반영합"])
assert got["기준합"] == pytest.approx(origin["기준합"])
def test_일위대가_쓰기는_막힘(world):
root = world["root"]
_save(root)
current = copies.read_file(root, FOLDER, "일위대가")
assert current["내용"]["줄"]["열"] == COLUMN
with pytest.raises(ValueError, match="원본"):
copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"])
+96
View File
@@ -0,0 +1,96 @@
"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) ·
서버 목록 칸(임시 storage).
① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 ·
고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽.
② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 ·
합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
from resources.tester import test_m02_tree as base
from resources.tester.test_m02_tree import Tree, _as
world = base.world # 같은 임시 storage 판
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def ts() -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_item_list.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_종류마다_칸(ts):
assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"]
assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"]
assert ts["heads"]["labor"] == [
"일위대가번",
"이름",
"폴더",
"단위",
"담은 로직 수",
"합계",
"고친 날",
]
assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), [])
def test_칸_값_빈_번호_합계_날짜(ts):
s, labor = ts["cells"]["structure"], ts["cells"]["labor"]
assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"]
assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음)
assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림
assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음
assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날
def test_찾기_쪽(ts):
assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸)
assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음
assert ts["findNone"] == {"shown": [], "page": 1, "total": 0}
assert ts["page3"] == {"page": 3, "total": 65, "n": 5}
assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1
def test_서버_목록_칸_수정일_labor(world):
t = Tree(_as(world), "personal")
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m")
root = world["storage"] / "7" / "42" / "templates"
ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"])
assert set(ditch["수정일"]) >= {"structure", "area"}
assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO
assert (
ditch["labor"]["로직수"] == 0 and ditch["labor"]["합계"] is None
) # 새 열 = 빈 일위대가(39-1b)
(root / "labor").mkdir(exist_ok=True)
doc = {
"일위대가번": "SU-000001",
"담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}],
"계산값": {"반영합": {"계": 12345.6}},
}
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
(root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8")
rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6}
assert "labor" in rows[ditch["열"]]["수정일"]
assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None}
doc["계산값"] = {"반영합": {"계": "글"}}
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
again = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
assert again[ditch["열"]]["labor"]["합계"] is None
@@ -0,0 +1,71 @@
"""일위대가 수량 · 반영 · 합 거울 시험 — `M02_Labor_Doc.settle`(파이썬) ↔
`M02_MasterTemplete_Labor_Calc.ts` `settle`(TS · Node) 가 같은 답(CLAUDE.md 5장 ①).
기준은 서버가 한 번 셈(`calc`) · 그 답에 다른 알약 값을 넣어 둘이 같은지 봄.
"""
import json
import os
import subprocess
import pytest
from M02_MasterTemplete import M02_Labor_Doc as labor
HERE = os.path.dirname(os.path.abspath(__file__))
TEN, THOUSAND, PLAIN, MAT = "GC000421", "GF000017", "GC000422", "MT000008"
def _ts(answer: dict, doc: dict, values: dict) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def _same(a, b) -> None:
if isinstance(a, dict):
assert set(a) == set(b)
for k in a:
_same(a[k], b[k])
elif isinstance(a, list):
assert len(a) == len(b)
for x, y in zip(a, b):
_same(x, y)
elif isinstance(a, (int, float)) and not isinstance(a, bool):
assert b == pytest.approx(a, rel=1e-9)
else:
assert a == b
@pytest.mark.parametrize(
"values",
[
{}, # 수량 없음 — 반영 null · 손만
{"사석 수상고르기|㎡": 2.5, f"자재|{MAT}": "3"},
{"사석 수상고르기|㎡": "1,200", "나무식재|본": 7, f"자재|{MAT}": "글"}, # 쉼표 · 수 아님
],
)
def test_파이썬과_TS_가_같은_답(values):
doc = labor.clean(
{
"열": "rv",
"담은로직": [
{"로직": TEN, "수량": None},
{"로직": THOUSAND, "수량": {"손": 4}},
{"로직": PLAIN, "수량": None},
],
"자재": [{"코드": MAT, "수량": None}],
}
)
answer = labor.calc(doc, {})
keys = {line["알약"] for line in answer["줄"]}
assert {"사석 수상고르기|㎡", "나무식재|본"} <= keys # 알약 키가 시험 값과 맞음
py = labor.settle(answer, doc, values)
_same(py, _ts(answer, doc, values))
assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시
+134
View File
@@ -0,0 +1,134 @@
"""일위대가 복사(PLAN 39-1b B5 · B6) — 항목 하나 복사(같은 층 · 다른 층 · 실무문서 조합) ·
구조물 조합 복사 · 분류 더하기 = 새 일위대가번(조합 키는 안 받음).
시스템 층 = 견본 표(임시 폴더) · 조합 · 로직은 M01 마스터(읽기만) · 개인 층 = 임시 storage.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
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
TEN = "GC000421"
URL = "/api/m02/layers/{}/{}/{}/copy"
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
for cid in ("rv", "c1"):
store.create_structure(cid)
got = store.read("labor", "rv")
logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}]
store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic})
personal = layers.personal_dir(7, 42)
items_store.ensure(personal)
with store.at(personal):
got = store.read("table", store.STRUCTURE_TABLE)
got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}]
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app = FastAPI()
app.include_router(items_router.router)
app.dependency_overrides[verify_session] = lambda: session
return {"client": TestClient(app), "session": session, "system": system, "personal": personal}
def _as(world: dict, role: str = "USER") -> TestClient:
world["session"]["role"] = role
return world["client"]
def _labor(folder: Path, cid: str) -> dict:
return json.loads((folder / "labor" / f"{cid}.json").read_bytes())
def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world):
system = world["system"]
before = _labor(system, "c1")
got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", "labor", "c1") + "?from=rv")
assert got.status_code == 200
c1 = _labor(system, "c1")
assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"])
assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN)
assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈
def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world):
client, personal = _as(world), world["personal"]
url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system"
for _ in range(2): # 저장된 데이터도 다시 바꿈
assert client.post(url).status_code == 200
assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN
area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system"
assert client.post(area).status_code == 200
def test_권한_없는_원본_층_403_없는_원본_404(world):
client = _as(world)
other = URL.format("personal", "labor", "a") + "?from=x&from_layer=personal&from_owner=7/99"
assert client.post(other).status_code == 403
missing = URL.format("personal", "labor", "a") + "?from=없는열&from_layer=system"
assert client.post(missing).status_code == 404
# 시스템 층 쓰기는 시스템 관리자만
assert client.post(URL.format("system", "labor", "c1") + "?from=rv").status_code == 403
def test_실무문서_조합에서_일위대가로_시스템_관리자만(world):
url = URL.format("system", "labor", "c1") + "?from=UA000008&from_layer=practice"
assert _as(world).post(url).status_code == 403
admin = _as(world, "SYSTEM_ADMIN")
assert admin.post(url).status_code == 200
c1 = _labor(world["system"], "c1")
assert c1["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"}
assert len(c1["담은로직"]) == 14 and all(x["입력값"] == {} for x in c1["담은로직"])
area = URL.format("system", "area", "c1") + "?from=UA000008&from_layer=practice"
assert admin.post(area).status_code == 403 # 실무문서는 일위대가로만
bad = URL.format("system", "labor", "c1") + "?from=UP1-000001&from_layer=practice"
assert admin.post(bad).status_code == 400
def test_구조물_조합_복사는_새_일위대가번_조합_키는_안_받음(world):
system, personal = world["system"], world["personal"]
with store.at(system):
key = next(r["키"] for r in store.read_combos()["줄"] if r["열"] == "rv")
body = {"from_layer": "system", "key": key}
made = _as(world).post("/api/m02/layers/personal/items/copy", json=body).json()
got = _labor(personal, made["열"])
src = _labor(system, "rv")
assert got["담은로직"] == src["담은로직"] and got["일위대가번"] != src["일위대가번"]
assert got["일위대가번"].startswith("SU-")
table = json.loads((personal / "table" / f"{store.STRUCTURE_TABLE}.json").read_bytes())
assert next(c for c in table["열"] if c["id"] == made["열"]).get("일위대가") is None
def test_분류_더하기_복사도_새_번호로_일위대가_문서(world):
system = world["system"]
with store.at(system):
got = store.read("table", store.STRUCTURE_TABLE)
doc = got["문서"]
rv = next(c for c in doc["열"] if c["id"] == "rv")
doc["열"].append({**rv, "id": "rv2", "본뜬열": "rv", "일위대가": None})
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
new, src = _labor(system, "rv2"), _labor(system, "rv")
assert new["담은로직"] == src["담은로직"] and new["일위대가번"] != src["일위대가번"]
+57 -35
View File
@@ -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"])
+242
View File
@@ -0,0 +1,242 @@
"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값.
로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에.
"""
import json
import pytest
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_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
TEN = "GC000421" # 원/10㎡ — 배 10
THOUSAND = "GF000017" # 원/1,000본 — 배 1000
PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직
STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율
MAT = "MT000008" # 자재품목 · 물가정보 83000
def _doc(**extra):
return {"열": "rv", "담은로직": [], "자재": [], **extra}
def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
got = labor.clean(
_doc(
담은로직=[
{"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None}
],
자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}],
단위="개소",
)
)
assert got["담은로직"] == [
{"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
]
assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2}
assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None)
@pytest.mark.parametrize(
"bad",
[
_doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번
_doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직
_doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님
_doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림
_doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님
_doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번
"문서 아님",
],
)
def test_틀린_문서는_400(bad):
with pytest.raises(store.StoreError) as e:
labor.clean(bad)
assert e.value.status == 400
def test_빈_일위대가도_됨():
got = labor.server(_doc())
assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0
def test_PER_천_단위_쉼표는_배():
pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"]
assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10}
def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음():
doc = labor.clean(
_doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}])
)
got = labor.calc(doc, {})
ten, plain = got["줄"]
assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손")
for k in ("노무비", "재료비", "경비", "계"):
assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10)
assert plain["기준"] is None and plain["반영"] is None and "결과" in plain
assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"])
def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
doc = labor.clean(
_doc(
담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}],
자재=[{"코드": MAT, "수량": None}],
)
)
keys = {p["키"] for p in labor.pills(doc)}
ten_key = next(k for k in keys if k.endswith("|㎡"))
thousand_key = next(k for k in keys if k.endswith("|본"))
assert f"자재|{MAT}" in keys
got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"})
ten, thousand = got["줄"]
assert (ten["수량"], ten["수량출처"]) == (2.5, "칸")
assert (thousand["수량"], thousand["수량출처"]) == (1, "손")
mat = got["자재"][0]
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
nothing = labor.calc(doc, {})["줄"][0]
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
def test_면적_값_식_칸은_계산값_그_밖은_칸_값():
area = {
"시트": [
{
"id": "s1",
"칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}},
"연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}],
}
],
"계산값": {"s1": {"D2": {"수": "7.5"}}},
}
got = labor.area_values(area)
assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5}
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_구조물_만들면_빈_일위대가_같은_뒷자리(system):
doc = store.read("labor", "rv")["문서"]
assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:]
assert doc["담은로직"] == [] and doc["자재"] == []
def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system):
have = store.read("labor", "rv")
sent = {
**have["문서"],
"일위대가번": "SU-999999",
"담은로직": [{"로직": TEN, "수량": None}],
"계산값": {"브라우저": "값"},
}
store.write("labor", "rv", have["판"], sent)
doc = store.read("labor", "rv")["문서"]
assert doc["일위대가번"] == have["문서"]["일위대가번"]
assert doc["계산값"]["줄"][0]["수량출처"] == "없음"
pill = labor.pills(doc)[0]["키"]
area = store.read("area", "rv")
sheet = area["문서"]["시트"][0]
sheet["칸"] = {"B6": {"값": "5"}}
sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}]
store.write("area", "rv", area["판"], area["문서"])
line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0]
assert (line["수량"], line["수량출처"]) == (5, "칸")
assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10)
def test_틀린_일위대가_저장은_400_파일_그대로(system):
have = store.read("labor", "rv")
with pytest.raises(store.StoreError) as e:
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]})
assert e.value.status == 400
assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == []
# ── 길(`M02_Labor_Router`) ─────────────────────────────────────────────
@pytest.fixture()
def client(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_Template_Layers as layers
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
store.create_structure("rv")
app = FastAPI()
app.include_router(labor_router.router)
app.dependency_overrides[verify_session] = lambda: {
"user_id": 42,
"company_id": 7,
"role": "USER",
"is_master": False,
}
return TestClient(app)
def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client):
url = "/api/m02/layers/system/labor/rv/calc"
sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]}
got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json()
assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000)
assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"}
saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가
assert saved["줄"] == [] and saved["알약"] == []
assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []}
def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client):
url = "/api/m02/layers/{}/labor/{}/calc"
bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}}
assert client.post(url.format("system", "rv"), json=bad).status_code == 400
assert client.post(url.format("system", "없는열"), json={}).status_code == 404
other = client.post(url.format("personal", "rv") + "?owner=7/99", json={})
assert other.status_code == 403
def test_로직_지우기_검사는_모든_층_일위대가를_봄(tmp_path, monkeypatch):
from config import config_system
from M01_MasterData import M01_MasterData_Store_Cache as cache
system, storage = tmp_path / "system", tmp_path / "storage"
for folder, code in (
(system / "labor", STONE),
(storage / "7" / "templates" / "labor", TEN), # 회사 층
(storage / "7" / "42" / "templates" / "labor", STONE), # 개인 층
(storage / "7" / "42" / "p1" / "templates" / "labor", STONE), # 프로젝트 — 안 봄
):
folder.mkdir(parents=True)
body = {"담은로직": [{"로직": code}]}
(folder / "a.json").write_text(json.dumps(body), encoding="utf-8")
from M02_MasterTemplete import M02_Template_Layers as layers
monkeypatch.setattr(layers, "SYSTEM_ROOT", system) # 검사는 39-1d 한 벌(층 자리 = layers)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
class Gone: # STONE 을 지운 마스터
def logic(self, code):
if code == STONE:
raise cache.cm.mf.FormulaError("없음")
got = cache.labor_errors(Gone())
assert len(got) == 2 and all(STONE in x for x in got)
assert any("마스터" in x for x in got) and any("개인 7/42" in x for x in got)
+119
View File
@@ -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"))
+39 -108
View File
@@ -1,124 +1,55 @@
"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정.
"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 ·
일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA).
못박는 것
- 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키.
- 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 ·
지우기는 고름 없음.
옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts";
const src = fs.readFileSync(process.argv[1] + file, "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" });
const list = {
판: "v1",
폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]],
줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null),
row("UP1-000003", "다", null, "무시")],
};
const view = m.toView(list);
const notes = [
{ op: "add-folder", 경로: ["석축"], 이름: "메쌓기" },
{ op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" },
{ op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] },
{ op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] },
{ op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" },
{ op: "delete", 경로: ["석축"], 키: "UP1-000002" },
{ op: "delete", 경로: ["빈"] },
].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : []));
console.log(JSON.stringify({ view, notes }));
"""
M02 = ROOT / "M02_MasterTemplete"
@pytest.fixture(scope="module")
def got():
if shutil.which("node") is None:
pytest.skip("node 가 없음")
proc = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _text(name: str) -> str:
return (M02 / name).read_text("utf-8")
def test_조합이_나무_항목으로(got):
items = got["view"]["items"]
assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]]
assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"]
assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]]
assert "알림" not in got["view"]
def test_일위대가_나무_파일과_이음이_없음():
assert not (M02 / "M02_MasterTemplete_Labor.ts").exists()
assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists()
page = _text("M02_MasterTemplete_UI_Page.ts")
assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page
side = _text("M02_MasterTemplete_UI_Side.ts")
assert "LaborSection" not in side and "labor?." not in side
def test_나무_고침_알림(got):
add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"]
assert add["경로"] == ["석축", "메쌓기"] and "열" not in add
assert rename["경로"] == ["석축", "찰"]
assert move["경로"] == ["찰쌓기"]
assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"]
assert rename_item["경로"] == ["석축", "나나"]
assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"]
assert "경로" not in delete_folder
def test_나무_부품에_일위대가_전용_칸_없음():
panel = _text("M02_MasterTemplete_TreePanel.ts")
for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"):
assert gone not in panel, gone
def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
"""브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) ·
일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시
`open=일위대가`(다시 그리지 않고 주소만)."""
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
assert (
'bar: (add, back) => layerBar("일위대가", add, back),' in page
and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
)
labor = page.split("buildLaborSection({")[1].split("make:")[0]
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림)
assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page
def test_하위_차례_전체_항목_이름():
side = _text("M02_MasterTemplete_UI_Side.ts")
kinds = side.split("const COLUMN_KINDS")[1].split("];")[0]
order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")]
assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출
assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds
# 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길
assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side
api = _text("M02_MasterTemplete_Api_Fetch.ts")
assert '| "labor";' in api and "일위대가번: string;" in api
assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음
assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side
assert 'return void selectAll("labor");' in side # 해시 open=일위대가
locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8")
assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale
assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale
css = _text("M02_MasterTemplete_UI_Style.css")
assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css
def test_일위대가_나무에_여러_개_복사_붙여넣기_연결():
"""PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 ·
붙임 종류는 따로(구조물과 안 섞임)."""
labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8")
assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor
assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기
assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록
def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표():
"""PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선).
머리 줄도 공용 표 모양."""
edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8")
assert (
"newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit
)
assert (
'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit
) # 목록 안 옛 자리
labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8")
assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
assert (
'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))'
in main
)
css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8")
assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css
def test_층_단추는_구조물_조합_한_벌():
page = _text("M02_MasterTemplete_UI_Page.ts")
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
+101
View File
@@ -0,0 +1,101 @@
"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델).
못박는 것
- 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」.
- 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`).
- 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 ·
모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만).
화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만.
"""
from __future__ import annotations
import json
import shutil
import subprocess
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 M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router
ROOT = Path(__file__).resolve().parents[2]
A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비
@pytest.fixture()
def client() -> TestClient:
app = FastAPI()
app.include_router(combos_router.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"}
return TestClient(app)
def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None:
res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"})
assert res.status_code == 200, res.text
got = res.json()
assert [x["로직"] for x in got["로직"]] == [B, A]
assert got["빠진로직"] == ["GF999999"]
one = got["로직"][1]
assert one["이름"] and one["결과단위"]
assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"])
# 옛 선택 입력 목록과 같은 몸
assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"]
def test_빈_키는_빈_목록(client: TestClient) -> None:
got = client.get("/api/m02/logic-lib/inputs").json()
assert got == {"로직": [], "빠진로직": []}
SCRIPT = r"""
const fs = require("fs");
const path = require("path");
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 };
const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] };
const size = { 이름: "규격", 고르기: [25, 40] };
const area = { 이름: "지역" };
const inputs = [height, kind, size, area];
console.log(JSON.stringify({
stored: [
m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"),
m.storedOf(size, "40"), m.storedOf(area, "서울"),
],
outside: [
m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""),
m.isOutside(height, "abc"), m.isOutside(area, "9"),
],
known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }),
line: m.valueLine(inputs, { 높이: 2, 규격: 40 }),
lineEmpty: m.valueLine(inputs, {}),
}));
"""
def test_값_셈() -> None:
node = shutil.which("node")
if not node:
pytest.skip("node 없음")
out = subprocess.run(
[node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
assert got["stored"] == [None, 2.5, "막돌", 40, "서울"]
assert got["outside"] == [True, False, False, True, False]
assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]}
assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음
assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님
+60 -11
View File
@@ -16,6 +16,7 @@ from fastapi import HTTPException
from config import config_system
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -55,16 +56,16 @@ def test_권한_표():
}
assert got == {
"일반": ["읽기", "읽기", "403", "수정", "403", "403"],
"회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"],
"마스터": ["읽기", "수정", "403", "수정", "읽기", "403"],
"시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"],
"회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6)
"마스터": ["읽기", "수정", "403", "수정", "수정", "403"],
"시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음
}
# 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409
assert access.grant(USER, "personal", "7/42").write is True
assert _answer(SYSADMIN, "personal", "9") == "400"
assert _answer({**USER, "company_id": None}, "company", None) == "409"
with pytest.raises(HTTPException) as caught:
access.require(ADMIN, "personal", "7/43", write=True)
access.require(USER, "company", None, write=True)
assert caught.value.status_code == 403
@@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
monkeypatch.setattr(layers_router, "_company_users", company_users)
monkeypatch.setattr(layers_router, "_companies", companies)
notes: list[tuple[int, str, str | None]] = []
async def member(owner: str) -> bool:
company, _, user = owner.partition("/")
return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9)
async def note(session: dict[str, Any], got: Any) -> None:
notes.append((session["user_id"], got.layer, got.owner))
monkeypatch.setattr(items_router, "_member", member)
monkeypatch.setattr(items_router, "_note", note)
session: dict[str, Any] = dict(USER)
return {"client": _app(session), "session": session, "storage": storage}
return {"client": _app(session), "session": session, "storage": storage, "notes": notes}
def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
@@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
assert client.get(tree).status_code == 200
assert client.get(table).status_code == 200 # 양식 길도 같은 규칙
body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]}
assert client.post(tree, json=body).status_code == 403 # 읽기만
assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6)
assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄
assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200
session.update(USER)
assert client.get(tree).status_code == 403
assert client.get(table).status_code == 403
assert (
client.post(tree, json={**body, "이름": "몰래"}).status_code == 403
) # 일반 = 남 개인 막힘
session.update(SYSADMIN)
assert client.get(tree).status_code == 200
assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만
body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]}
assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나
def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]):
"""39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 ·
읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP)."""
client, session, storage = world["client"], world["session"], world["storage"]
session.update(ADMIN)
add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""}
assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404
assert not (storage / "7/44").exists()
assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403
assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦
assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403
first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add)
assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침
assert (storage / "7/44/templates/table/구조물집계표.json").is_file()
add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"]
assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200
session.update(SYSADMIN)
assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403
assert not (storage / "5").exists() # 없는 회사
add["판"] = ""
assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409
assert (storage / "9/templates/table/구조물집계표.json").is_file()
assert [n[2] for n in world["notes"]] == ["7/44"] # 성공한 쓰기만(39-1d) · 409 · 403 은 안 남김
def test_남의_층_목록(world: dict[str, Any]):
@@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]):
people = "/api/m02/layers/personal/owners"
assert client.get(people).status_code == 403 # 일반 사용자
assert client.get("/api/m02/layers/company/owners").status_code == 403
(storage / "7/43/templates/table").mkdir(parents=True)
(storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8")
session.update(ADMIN)
assert client.get(people).json() == [
{"owner": "7/43", "name": "u43"}
] # 본인 · 44(자리 없음) 뺌
assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시
{"owner": "7/42", "name": "u42", "비어있음": True},
{"owner": "7/43", "name": "u43", "비어있음": False},
{"owner": "7/44", "name": "u44", "비어있음": True},
]
assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦
assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람
session.update(SYSADMIN)
assert client.get("/api/m02/layers/company/owners").json() == [
{"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도
{"owner": "9", "name": "c9"},
]
assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}]
assert client.get(f"{people}?company=9").json() == [
{"owner": "9/90", "name": "u90", "비어있음": True}
]
+77 -21
View File
@@ -26,11 +26,12 @@ def _text(path: Path) -> str:
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_층별_고칠_권한():
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만."""
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침."""
text = _text(LAYER)
own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M)
found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M)
assert own and found, "ownOwner · accessFor 를 못 찾음"
manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M)
assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
@@ -38,6 +39,8 @@ def test_층별_고칠_권한():
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
+ own.group(0).replace("export ", "")
+ found.group(0).replace("export ", "")
+ "\n"
+ manages.group(0).replace("export ", "")
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));"
)
@@ -54,7 +57,8 @@ def test_층별_고칠_권한():
["personal", None, user], ["personal", None, admin], ["personal", None, orphan],
["personal", None, sysadmin],
["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin],
["personal", "9/90", sysadmin],
["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin],
["company", "9", admin],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
@@ -69,7 +73,8 @@ def test_층별_고칠_권한():
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만
ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침
ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403)
] # fmt: skip
@@ -87,6 +92,55 @@ def test_주소_인자를_라우트에서_뗌():
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_가져올_곳_탭():
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 ·
일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
text = _text(LAYER)
parts = [
re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M),
re.search(r"^export function manages[\s\S]*?^}", text, re.M),
re.search(r"^export interface Source[\s\S]*?^}", text, re.M),
re.search(r"^export function readableSources[\s\S]*?^}", text, re.M),
]
assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
" is_master?: boolean }\n"
"const L = (key: string): string => key;\n"
+ "\n".join(p.group(0).replace("export ", "") for p in parts if p)
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {"
+ "\n const got = readableSources(u, c, { practice });"
+ "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];"
+ "\n})));"
)
sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1}
admin = {"id": "5", "role": "ADMIN", "company_id": 3}
master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True}
user = {"id": "7", "role": "USER", "company_id": 3}
orphan = {"id": "8", "role": "USER", "company_id": None}
cases = [
[sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False],
[user, "3", True], [orphan, None, False],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄
["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님
["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음
["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자
["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음
["master", ["master"]], # 회사 없음
] # fmt: skip
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_볼_회사는_시스템_관리자만_고름():
"""29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 ·
@@ -132,11 +186,13 @@ def test_층_단추_셋_남의_회사_사람_나무():
assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰
assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무
assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기
assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots
assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6)
assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침
assert 'L("M02_PeopleEmpty")' in roots
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
assert 'peopleTree("구조물조합", tree.root)' in page
assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page
assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert (
"api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel
@@ -219,17 +275,20 @@ def test_읽기만_표는_단추_고치기_모두_막음():
def test_켜진_층_단추는_목록으로_복사_끌기():
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 ·
저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) ·
끌어다 옮기기는 쓸 수 있는 층만."""
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) ·
30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만."""
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
# 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창
assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page
assert "(to === layer && toOwner === here)) return [];" in page
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
M02 / "M02_MasterTemplete_TreePanel.ts"
)
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
@@ -242,27 +301,24 @@ def test_구조물_조합_제목_줄_새_항목():
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main
assert (
"children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]"
"children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]"
in main
)
assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert (
"...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {})"
in page
)
assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {})" in page
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert 'void addItem(at?.kind === "folder" ? at.path : []);' in panel
assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder())
assert "const newItem = (): void => void addItem(folder());" in panel
assert ".m02-main__new {\n margin-left: auto;" in _text(
M02 / "M02_MasterTemplete_UI_Style.css"
)
def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움():
"""31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸
"""31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸
자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로)."""
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side
assert 'summary.classList.remove("is-collapsed");' in side
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
mark = drag.split("const dropMark = ")[1].split("};")[0]
+9 -2
View File
@@ -67,7 +67,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
rows = client.get("/api/m02/items").json()
ss = store.read_combos()["줄"]
assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001"
assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS}
assert set(rows[0]) == {
"key",
"names",
"수정일", # 목록 칸(39-5)
"labor",
*items_router.items_store.ITEM_FIELDS,
}
assert set(rows[0]["names"]) == {"일위대가", "도번", "면적번"}
assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos()
# 시스템 층 쓰기는 SYSTEM_ADMIN 만
@@ -154,7 +160,8 @@ def test_회사_항목을_개인으로_복사(world):
made = got.json()
assert made["열"] == "cw" and made["key"] == "SP42-000001" # 응답 = 개인 층 새 키(바로 고름)
mine_rows = user.get("/api/m02/items?layer=personal").json()
assert mine_rows[0]["key"] == made["key"] and mine_rows[0]["names"]["일위대가"] == ua_name
assert mine_rows[0]["key"] == made["key"]
assert mine_rows[0]["names"]["일위대가"] is None # 조합 키는 안 받음 — 일위대가 문서로(39-1b)
mine = "/api/m02/layers/personal"
copy = user.get(f"{mine}/templates/structure/cw").json()["문서"]
assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw"
+59
View File
@@ -0,0 +1,59 @@
"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기.
못박는 것
- `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 ·
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로.
- 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만.
정본(마스터)은 읽기만 · 쓰기는 임시 사본.
"""
from __future__ import annotations
import json
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
def _drop(client, 판: str, want: int) -> dict:
res = client.delete("/api/m02/practice", params={"판": 판})
assert res.status_code == want, res.text
return res.json()
def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811
admin = _as(world, "SYSTEM_ADMIN")
before = _list(admin, "system")
assert before["줄"] # 사본에 옛 조합이 있음
for role in ("USER", "ADMIN"):
_drop(_as(world, role), before["판"], 403)
admin = _as(world, "SYSTEM_ADMIN")
_drop(admin, "옛판", 409)
used = before["줄"][0]["키"]
monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else [])
got = _drop(admin, before["판"], 409)
assert "항목 하나" in json.dumps(got, ensure_ascii=False)
assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움
monkeypatch.setattr(combo_store, "combo_users", lambda k: [])
got = _drop(admin, before["판"], 200)
assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"])
doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8"))
assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == []
again = _list(admin, "system")
assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로
def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만():
side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
assert 'layer === "system" && opt.isAdmin' in side
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래
view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8")
assert view.count("showConfirmDialog(") == 2
assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음
+6 -2
View File
@@ -64,8 +64,12 @@ def client(tmp_path, monkeypatch):
def _num(code: str) -> dict:
"""두 번호 — 같은 8자리."""
return {"도번": f"SD{code}", "면적번": f"SA{code}"}
"""열 번호 — 같은 뒷자리(일위대가번 SU · 39장)."""
return {
"도번": f"SD{code}",
"일위대가번": f"SU{code}",
"면적번": f"SA{code}",
}
def test_새로_도번_자동_409_없는_열(client):
+1 -1
View File
@@ -86,7 +86,7 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지
assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True)
# 번호는 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA 가 섬
numbers = client.get("/api/m02/structures/numbers").json()
assert {k: rows["new1"][k] for k in ("도번", "면적번")} == numbers["new1"]
assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번")} == numbers["new1"]
assert rows["new1"]["도번"].startswith("SD")
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로
+9 -11
View File
@@ -120,17 +120,15 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
assert side.index("옵션: section(options.title") < side.index(
'도면: section(L("M02_KindDrawing")'
)
# 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식
order = [
"...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]"
" : []),",
" summary,",
" ...(extras ? [extras.옵션, extras.도면] : []),",
]
assert "\n".join(order) in side
# 일위대가를 열면 이 줄 고름 지움 / 이 줄을 열면 일위대가 고름 지움
assert "onOpen: () => (options.clear(), paint())" in side
assert "onOpen: () => (labor?.clear(), paint())" in side
# 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
assert (
order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice")
)
# 실무문서를 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 실무문서 · … 지움(자재 조합 없앰)
assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side
assert "onOpen: () => (practice?.clear(), (allPicked = null), paint())" in side
assert "matset" not in side
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
+24 -10
View File
@@ -46,7 +46,7 @@ def test_소스가_700줄을_넘지_않음():
[
(
"M02_MasterTemplete_Api_Fetch.ts",
["createStructure", "fetchStructureNumbers", "readTemplate"],
["createStructure", "fetchStructureNumbers"], # readTemplate 은 39-4 에서 뺌
PANEL,
),
# 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함
@@ -77,8 +77,11 @@ def test_sub2_가_부르는_이름과_모양():
rf"export const {name} = async \(column: string, from: string\): Promise<void>", body
), name
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
assert re.search(r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*\}", body)
assert re.search(r'export type Kind = [^;]*"area";', body)
assert re.search(
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}",
body,
)
assert re.search(r'export type Kind = [^;]*"area" \| "labor";', body) # 재료산출 없앰
def test_집계표만_컨테이너를_붙임():
@@ -152,9 +155,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만
# 구조물 도면 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰
# 구조물 도면 · 일위대가 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰 ·
# 층 단추 · 나무와 하위 사이 수평선(39-2)
summary = side.split("const summary = section(")[1].split(");")[0]
assert "COLUMN_KINDS.map" in summary and "matset" not in side
assert (
summary.index("opt.tree")
< summary.index("m02-side__rule")
< summary.index("...columnSections")
)
assert "matset" not in side
assert "const columnSections = COLUMN_KINDS.map(" in side
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
ROOT / "ui_template" / "ui_template_locale_m2.ts"
)
@@ -182,7 +192,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐():
"""카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림."""
side = _text(SIDE)
assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄
assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side)
assert re.search(
r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*"
r"paintList\(\);\s*paintColumns\(\);",
side,
) # 양식이 열리면 「전체 항목」 고름도 지움(39-2)
for fn in ("async function select(", "async function selectColumn("):
body = side.split(fn)[1].split("\n }\n")[0]
assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음
@@ -190,8 +204,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐():
# 옵션 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 옵션 고름 지움
assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN))
assert "if (opt.getOpen()) options.clear();" in side.split("function paint(): void {")[1]
# 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리)
assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts"))
# 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움)
assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts")
def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘():
@@ -211,8 +225,8 @@ def test_표_도구_막대에_줄_더하기_줄_지우기_손_입력_열_없음(
def test_상세_화면_돌아가기는_집계표로_그_열_고른_채():
main = _text(MAIN)
assert "children: [title, back, slot, reload, save, " in main # 제목 바로 오른쪽
assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 면적산출만
assert "children: [back, title, slot, reload, save, " in main # M01 꼴 — 제목 앞(39-5)
assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 일위대가 · 면적산출만
body = main.split('back.addEventListener("click"')[1]
assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body
assert "sheet?.selectColumn(column)" in body
+29 -5
View File
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
from common_util import common_util_storage
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import sample_table
@@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict
assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company"
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None:
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(
world: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
client, session = world["client"], world["session"]
notes: list[str] = []
async def note(_session: dict[str, Any], got: Any) -> None:
notes.append(got.owner)
monkeypatch.setattr(items_router, "_note", note)
body = {"to": "personal", "종류": "table", "이름": "구조물집계표"}
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}"
assert client.get(mine).status_code == 200
session.update(user_id=43)
theirs = mine + "&user_id=42"
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6)
assert client.get(theirs).status_code == 403
session["role"] = "ADMIN"
assert client.get(theirs).status_code == 200
@@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42",
json={"판": None, "문서": {"열": []}},
)
assert put.status_code == 403
assert not (world["storage"] / "7/42/templates/table/내표.json").exists()
assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그
assert (world["storage"] / "7/42/templates/table/내표.json").is_file()
assert notes == ["7/42"]
session["role"] = "USER"
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
put = client.put(
@@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
assert got.status_code == 200
assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6)
session["role"] = "ADMIN"
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴
assert client.get(mine.replace(P1, P_OTHER)).status_code == 403
far = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90}
@@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str
sources = client.get(f"/api/m02/projects/{P1}/sources").json()
assert [row["project_id"] for row in sources["project"]] == [P2]
assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"}
for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6)
layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []})
assert [row["user_id"] for row in sources_of(client)] == [42]
world["session"]["role"] = "ADMIN"
assert [row["user_id"] for row in sources_of(client)] == [42, 43]
def sources_of(client: TestClient) -> list[dict[str, Any]]:
return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"]
def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None:
@@ -0,0 +1,68 @@
"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4).
순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 —
새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소.
손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts"
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_setLink_순수_길():
with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir:
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
[
"node",
str(VITE),
"build",
"--configLoader",
"runner",
"--ssr",
entry_rel,
"--outDir",
outdir,
],
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
assert run.stdout.strip(), run.stderr
result = json.loads(run.stdout)
assert result["fail"] == 0, result["failures"]
assert result["pass"] == 13
def test_손잡이_setLink_몸():
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
assert "if (readOnly) return false;" in body
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
assert "loadLinks();" in body
+15
View File
@@ -269,3 +269,18 @@
.ui-modal.ui-item-ask {
width: min(28rem, 92vw);
}
/* 일위대가 카드(39-1c) — 담은 로직 · 자재 수 · 기준 합 · 반영 합 글 */
.ui-item-picker__labor-line {
margin: 0 0 var(--spacing-4);
color: var(--color-text-body);
font-size: 13px;
}
.ui-item-picker__labor-line.is-strong {
font-weight: var(--font-weight-medium);
}
.ui-item-picker__labor-line.is-warn {
color: var(--color-danger);
}
+39 -33
View File
@@ -1,8 +1,9 @@
/* =============================================================================
* ui_template_item_picker_detail.ts
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 셋**(도면 · 면적산출 · 선택 입력).
* 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 ·
* 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드
* (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
* 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값).
*
* 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고,
* 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」
@@ -12,15 +13,12 @@
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
import {
readInputLinks,
readTemplate,
type InputLinks,
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs";
import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { LaborCardHandle } from "./ui_template_item_picker_labor";
import { askFields } from "./ui_template_item_picker_ask";
import {
folderLines,
@@ -32,7 +30,8 @@ import {
type PickerRow,
} from "./ui_template_item_picker_data";
import {
loadInputs,
laborOf,
laborValueData,
PART_OF,
projectItems,
sameItem,
@@ -115,6 +114,7 @@ const TEXT = {
도면: "도면",
면적: "면적산출",
입력연결: "선택 입력 자리",
일위대가: "일위대가",
} as Record<string, string>,
};
@@ -134,6 +134,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
let infos: ProjectItemInfo[] | null = null;
let cards: ItemCardsHandle | null = null;
let inputs: InputsValueHandle | null = null;
let labor: LaborCardHandle | null = null;
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
/** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */
let info: ProjectItemInfo | null = null;
@@ -294,34 +295,36 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
opts.onEdit({ kind, row, info, items, projectId: opts.projectId });
}
async function paintInputs(row: PickerRow, mine: number): Promise<void> {
if (!inputs) return;
const stop = inputs.loading(); // 카드 안 「불러오는 중」(M02 31-7)
/** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */
async function paintLabor(row: PickerRow, mine: number): Promise<void> {
if (!inputs || !labor) return;
const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7)
try {
const list = await loadInputs(row);
let link: InputLinks | null;
let values: ValueDoc = {};
if (info && items) {
// 복사본 것(가져온 때 그대로) — 값도 복사본에서
link = (await items.read(info.폴더, "입력연결")) as InputLinks | null;
values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
} else {
link = await readInputLinks(layerOf(row), row.key).catch(() => null);
}
stop();
if (mine === seq) inputs.set({ list, link, values });
const copy = info && items ? info.폴더 : null;
const summary = await laborOf(row, copy ? items : null, copy);
const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {};
const data = summary ? await laborValueData(summary.로직, values) : null;
stops.forEach((stop) => stop());
if (mine !== seq) return;
labor.set(summary);
inputs.set(data);
} catch (error) {
stop();
if (mine === seq) inputs.set(null, why(error), () => void paintInputs(row, seq));
stops.forEach((stop) => stop());
if (mine !== seq) return;
const retry = (): void => void paintLabor(row, seq);
labor.set(null, why(error), retry);
inputs.set(null, why(error), retry);
}
}
async function ensureCards(): Promise<void> {
if (cards && inputs) return;
const [cardModule, inputModule] = await Promise.all([
if (cards && inputs && labor) return;
const [cardModule, inputModule, laborModule] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"),
import("../M02_MasterTemplete/M02_Labor_Values"),
import("./ui_template_item_picker_labor"),
]);
labor = laborModule.createLaborCard();
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
cards = cardModule.createItemCards({
source: () => source,
@@ -330,6 +333,8 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
warnOnFail: () => false,
extraCards: [inputs.root],
});
// 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에
cards.root.insertBefore(labor.root, cards.root.children[1] ?? null);
cardsHost.replaceChildren(cards.root);
}
@@ -342,6 +347,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
cardsHost.replaceChildren();
cards = null;
inputs = null;
labor = null;
return;
}
paintHead(row);
@@ -358,14 +364,14 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
other = own && !info ? own : null;
source = makeSource(row);
await ensureCards();
if (mine !== seq || !cards || !inputs) return;
if (mine !== seq || !cards || !inputs || !labor) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "선택 입력 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
void paintInputs(row, mine);
void paintLabor(row, mine);
})();
}

Some files were not shown because too many files have changed in this diff Show More