Merge origin/main_laptop_1 into sub_laptop_4
This commit is contained in:
@@ -195,3 +195,72 @@
|
||||
transform: translate(-100%, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */
|
||||
.ss-link.is-here {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-link.is-pop {
|
||||
animation: ss-link-pop 1.2s ease-out 1 both;
|
||||
}
|
||||
|
||||
@keyframes ss-link-pop {
|
||||
0%,
|
||||
40%,
|
||||
80% {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||
}
|
||||
20%,
|
||||
60%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */
|
||||
.ss-link.is-orphan {
|
||||
border: 1px dashed var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss-link__x {
|
||||
margin-left: 2px;
|
||||
padding: 0 4px;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-link__x:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.ss-link-cell.is-orphan {
|
||||
outline: 1px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */
|
||||
.ss-link-range {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-link-range__tag {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
bottom: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import "./spreadsheet_links.css";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
@@ -53,6 +53,11 @@ export interface LinkOptions {
|
||||
onItemsChange?: (항목: LinkItem[]) => void;
|
||||
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
|
||||
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
|
||||
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
|
||||
* 목록에 없는 연결 알약에 ⚠ + [×] */
|
||||
확정?: boolean;
|
||||
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
|
||||
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
|
||||
}
|
||||
|
||||
export interface Links extends PartHandle {
|
||||
@@ -74,6 +79,8 @@ const TEXT = {
|
||||
fail: "자재 목록을 못 받음",
|
||||
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
|
||||
dup: "이미 목록에 있는 자재",
|
||||
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
|
||||
orphanX: "연결 풀기",
|
||||
};
|
||||
|
||||
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
|
||||
@@ -146,6 +153,8 @@ export function mountLinks(
|
||||
let dragging: LinkItem | null = null;
|
||||
const dropMark = el("div", { className: "ss-link-drop" });
|
||||
let pick: HTMLElement | null = null;
|
||||
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
|
||||
let lastAt = "";
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
|
||||
@@ -204,6 +213,8 @@ export function mountLinks(
|
||||
item: LinkItem,
|
||||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||||
hue: number | undefined,
|
||||
orphan: boolean,
|
||||
here: string,
|
||||
) {
|
||||
const where = hit
|
||||
? hit.sheet.id === ctx.selection.시트
|
||||
@@ -211,15 +222,30 @@ export function mountLinks(
|
||||
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
|
||||
: "";
|
||||
const b = el("button", {
|
||||
className: hit ? "ss-link is-linked" : "ss-link",
|
||||
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
|
||||
className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${
|
||||
here ? ` is-here${here === "new" ? " is-pop" : ""}` : ""
|
||||
}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free,
|
||||
"data-key": item.키,
|
||||
},
|
||||
children: [
|
||||
el("span", { text: item.이름 }),
|
||||
el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }),
|
||||
...(item.단위 ? [el("small", { text: item.단위 })] : []),
|
||||
...(where ? [el("code", { text: where })] : []),
|
||||
],
|
||||
});
|
||||
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
|
||||
if (orphan && !ctx.readOnly) {
|
||||
const x = el("span", {
|
||||
className: "ss-link__x",
|
||||
text: "×",
|
||||
attrs: { role: "button", title: TEXT.orphanX },
|
||||
});
|
||||
x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back()));
|
||||
b.append(x);
|
||||
}
|
||||
b.draggable = !ctx.readOnly;
|
||||
b.addEventListener("dragstart", (e) => {
|
||||
dragging = item;
|
||||
@@ -233,13 +259,13 @@ export function mountLinks(
|
||||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||||
const unlink = (): void => {
|
||||
function unlink(): void {
|
||||
if (hit)
|
||||
put(
|
||||
hit.sheet,
|
||||
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
|
||||
);
|
||||
};
|
||||
}
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
|
||||
@@ -307,7 +333,17 @@ export function mountLinks(
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
);
|
||||
const hues = linkHues(map.keys());
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키)));
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts.모드 === "입력" && opts.확정 === true;
|
||||
// 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new")
|
||||
const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`;
|
||||
const fresh = at !== lastAt;
|
||||
lastAt = at;
|
||||
const pills = shown.map((item) => {
|
||||
const hit = map.get(item.키);
|
||||
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
|
||||
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
|
||||
});
|
||||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||||
const more = el("button", {
|
||||
className: "ss-link ss-link--add",
|
||||
@@ -318,6 +354,8 @@ export function mountLinks(
|
||||
pills.push(more);
|
||||
}
|
||||
bar.replaceChildren(...pills);
|
||||
if (fresh)
|
||||
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
@@ -325,6 +363,8 @@ export function mountLinks(
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
const hues = linkHues(found().keys());
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts?.모드 === "입력" && opts.확정 === true;
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
@@ -340,6 +380,7 @@ export function mountLinks(
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
const marks = [tint, dot];
|
||||
if (sure && !listed.has(first.키)) tint.classList.add("is-orphan");
|
||||
if (list.length > 1) {
|
||||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||||
count.style.left = `${box.x + box.w}px`;
|
||||
@@ -350,6 +391,22 @@ export function mountLinks(
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
|
||||
for (const mark of opts?.표시범위 ?? []) {
|
||||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||||
if (!g) continue;
|
||||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||||
const end = slot(ctx, g.r1, g.c1).box;
|
||||
if (!box.w || !box.h) continue;
|
||||
const frame = el("div", {
|
||||
className: "ss-link-range",
|
||||
attrs: { "data-range": mark.범위 },
|
||||
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
|
||||
});
|
||||
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
|
||||
layer.append(frame);
|
||||
dots.push(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||||
@@ -360,6 +417,11 @@ export function mountLinks(
|
||||
const names = a1
|
||||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||||
: [];
|
||||
if (hit && hit.r >= 0 && hit.c >= 0)
|
||||
for (const m of opts?.표시범위 ?? []) {
|
||||
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
|
||||
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
|
||||
}
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
tipped = names.length > 0;
|
||||
|
||||
@@ -221,19 +221,21 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l
|
||||
|
||||
|
||||
def _labor_files() -> list[tuple[str, Path]]:
|
||||
"""모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일)."""
|
||||
"""모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.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]
|
||||
return [
|
||||
(f"{what} {where}", path)
|
||||
for kind, what in (("labor", "일위대가"), ("promote", "단가승격"))
|
||||
for where, path in layers.layer_files(kind)
|
||||
]
|
||||
|
||||
|
||||
def labor_errors(whole) -> list[str]:
|
||||
"""일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림.
|
||||
전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌."""
|
||||
"""일위대가 · 단가승격 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) — 로직 지우기를 막음 ·
|
||||
어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은 `save` 가 고치기 전 검사와
|
||||
견주어 뺌."""
|
||||
out = []
|
||||
for where, path in _labor_files():
|
||||
try:
|
||||
@@ -243,10 +245,12 @@ def labor_errors(whole) -> list[str]:
|
||||
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 ""
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
whole.logic(key)
|
||||
except cm.mf.FormulaError:
|
||||
out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌")
|
||||
out.append(f"{where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,16 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
|
||||
|
||||
|
||||
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
|
||||
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
|
||||
_NUMBER = re.compile(r"(?<![\d-])(\d+(?:-\d+)+)(?![\d-])(?:\s+(주[①-⑳]?|\d{1,2})(?![\d-]))?")
|
||||
_ESCAPE = re.compile(r"\\([.\-*_()#\[\]>~`])") # 마크다운 이스케이프 `\.` `\-` …
|
||||
_COMMENT = re.compile(r"<!--.*?-->") # 글 안 주석(글자층 표시) — 글자만 남김
|
||||
_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
"""원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `."""
|
||||
text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip()
|
||||
return text[1:].strip() if text.startswith(">") else text
|
||||
|
||||
|
||||
def _blocks(lines: list[str]) -> list[dict]:
|
||||
@@ -117,10 +126,15 @@ def _blocks(lines: list[str]) -> list[dict]:
|
||||
if not text or text.startswith("<!--"):
|
||||
joinable = False
|
||||
continue
|
||||
if not text.startswith(("#", "|")):
|
||||
text = _plain(text)
|
||||
if not text:
|
||||
joinable = False
|
||||
continue
|
||||
if text.startswith("#"):
|
||||
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
|
||||
elif text.startswith("|"):
|
||||
cells = [c.strip() for c in text.strip("|").split("|")]
|
||||
cells = [_plain(c) for c in text.strip("|").split("|")]
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
|
||||
continue # 머리 · 몸 가름 줄
|
||||
if not joinable:
|
||||
@@ -134,6 +148,44 @@ def _blocks(lines: list[str]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _mark(blocks: list[dict], item: str) -> bool:
|
||||
"""출처 항 · 주 번호(「1」 · 「주③」 · 「주」)에 드는 조각에 hit 표시 — 못 찾으면 False(절 통째만)."""
|
||||
texts = [b.get("p") or b.get("text") or "" for b in blocks]
|
||||
if item.startswith("주"):
|
||||
note = next((i for i, t in enumerate(texts) if t.startswith("[주]")), None)
|
||||
if note is None:
|
||||
return False
|
||||
start, stop = note, len(blocks)
|
||||
if len(item) > 1: # 「주③」 — [주] 아래 ③ 줄 하나(다음 ① ~ ⑳ 줄 앞까지)
|
||||
start = next(
|
||||
(i for i in range(note + 1, len(texts)) if texts[i].startswith(item[1])), None
|
||||
)
|
||||
if start is None:
|
||||
return False
|
||||
stop = next(
|
||||
(i for i in range(start + 1, len(texts)) if texts[i][:1] in _CIRCLED), len(blocks)
|
||||
)
|
||||
else: # 「1」 — 「1. …」 제목 · 줄 ~ 다음 제목 · 「2. …」 앞(줄이면 [주] 앞도)
|
||||
head = re.compile(rf"{item}[.)]\s")
|
||||
start = next((i for i, t in enumerate(texts) if head.match(t)), None)
|
||||
if start is None:
|
||||
return False
|
||||
under = "h" in blocks[start]
|
||||
stop = next(
|
||||
(
|
||||
i
|
||||
for i in range(start + 1, len(blocks))
|
||||
if "h" in blocks[i]
|
||||
or (not under and re.match(r"\d+[.)]\s|\[주\]", texts[i]))
|
||||
or (under and re.match(r"\d+[.)]\s", texts[i]) and "p" in blocks[i])
|
||||
),
|
||||
len(blocks),
|
||||
)
|
||||
for b in blocks[start:stop]:
|
||||
b["hit"] = True
|
||||
return True
|
||||
|
||||
|
||||
def _section(book: str, division: str, number: str) -> dict | None:
|
||||
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
|
||||
parts = number.split("-")
|
||||
@@ -178,8 +230,14 @@ def source(src: str) -> dict:
|
||||
if book == "건설품셈"
|
||||
else ""
|
||||
)
|
||||
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
|
||||
return {"source": src, "found": [x for x in found if x]}
|
||||
found = []
|
||||
for number, item in dict.fromkeys(_NUMBER.findall(src)):
|
||||
one = _section(book, division, number)
|
||||
if one:
|
||||
if item and _mark(one["blocks"], item):
|
||||
one["item"] = item
|
||||
found.append(one)
|
||||
return {"source": src, "found": found}
|
||||
|
||||
|
||||
# ── 표 조건 값 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,3 +55,9 @@
|
||||
.m01lab-src__table td {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 출처가 가리킨 항 · 주 — 옅은 바탕 + 왼쪽 막대 */
|
||||
.m01lab-src__hit {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
box-shadow: -3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -26,15 +26,24 @@ interface Block {
|
||||
text?: string;
|
||||
p?: string;
|
||||
table?: string[][];
|
||||
/** 출처가 가리킨 항 · 주 — 강조 */
|
||||
hit?: boolean;
|
||||
}
|
||||
interface Found {
|
||||
number: string;
|
||||
matched: string;
|
||||
file: string;
|
||||
blocks: Block[];
|
||||
item?: string;
|
||||
}
|
||||
|
||||
function blockNode(b: Block): HTMLElement {
|
||||
const node = rawNode(b);
|
||||
if (b.hit) node.classList.add("m01lab-src__hit");
|
||||
return node;
|
||||
}
|
||||
|
||||
function rawNode(b: Block): HTMLElement {
|
||||
if (b.table)
|
||||
return el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
@@ -89,11 +98,20 @@ export function createSourceView(): SourceView {
|
||||
...found.flatMap((f) => [
|
||||
el("p", {
|
||||
className: "m01-logic__muted m01lab-src__file",
|
||||
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
|
||||
text: [
|
||||
f.file,
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
);
|
||||
requestAnimationFrame(() =>
|
||||
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
}
|
||||
|
||||
/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */
|
||||
function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
|
||||
export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
|
||||
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
|
||||
if (!answer.ok) {
|
||||
return [
|
||||
|
||||
@@ -244,10 +244,15 @@ def load_prices(prices: dict[str, Any]) -> Any:
|
||||
# ── 계산(25-2e) ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _logic_keys(labor: dict[str, Any]) -> list[str]:
|
||||
"""담은 로직 키 — 참조 줄(로직 없음 · 50-3)은 뺌."""
|
||||
return [key for key in mcb.logic_keys(labor.get("줄") or {}) if key]
|
||||
|
||||
|
||||
def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]:
|
||||
"""담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`."""
|
||||
out = []
|
||||
for key in mcb.logic_keys(labor.get("줄") or {}):
|
||||
for key in _logic_keys(labor):
|
||||
try:
|
||||
row = snapshot.logic(key)
|
||||
except mf.FormulaError:
|
||||
@@ -277,7 +282,7 @@ def calc(
|
||||
blocked = {gap["로직"] for gap in gaps}
|
||||
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
||||
lines, stuck = [], []
|
||||
for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1):
|
||||
for at, key in enumerate(_logic_keys(labor), start=1):
|
||||
head: dict[str, Any] = {"차례": at, "로직": key}
|
||||
if key in blocked:
|
||||
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
|
||||
@@ -306,10 +311,37 @@ def calc(
|
||||
"계": got["계"],
|
||||
}
|
||||
)
|
||||
for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로
|
||||
if line["ok"]:
|
||||
for name in mf.COST_ITEMS:
|
||||
sums[name] += line[name]
|
||||
else:
|
||||
stuck.append(line["참조"]["키"])
|
||||
lines.append(line)
|
||||
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
|
||||
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
|
||||
|
||||
|
||||
def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]:
|
||||
"""참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값
|
||||
(입력값은 승격 때 고정)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
copies = labor.get("참조사본") or {}
|
||||
expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref)))
|
||||
out = []
|
||||
for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1):
|
||||
if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)):
|
||||
continue
|
||||
got = expand(one["참조"])
|
||||
head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")}
|
||||
if got.get("기준") is None:
|
||||
out.append({**head, "ok": False, "까닭": got.get("까닭")})
|
||||
continue
|
||||
out.append({**head, "ok": True, "갈래": "돈", **got["기준"]})
|
||||
return out
|
||||
|
||||
|
||||
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
|
||||
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
@@ -318,8 +350,12 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An
|
||||
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
|
||||
if "참조" in line: # 알약 키 = ref_id · 배 1(50-3)
|
||||
head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1}
|
||||
base.append({**head, "분모": line.get("분모"), "기준": money})
|
||||
continue
|
||||
pill = pill_of.get(line["로직"]) or {}
|
||||
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
|
||||
base.append({**head, "분모": pill.get("단위"), "기준": money})
|
||||
mats = []
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
|
||||
@@ -89,10 +90,13 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
|
||||
def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 —
|
||||
복사본 계산이 마스터를 안 읽게)."""
|
||||
복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로)."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
return {**logic_rows(doc), "알약": labor_doc.pills(doc)}
|
||||
rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)}
|
||||
copies = refs.frozen(doc)
|
||||
return {**rows, "참조사본": copies} if copies else rows
|
||||
|
||||
|
||||
def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -142,7 +146,10 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
|
||||
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 {}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
|
||||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
|
||||
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
|
||||
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
"""
|
||||
@@ -19,6 +22,7 @@ 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_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
mf = m01.cm.mf
|
||||
@@ -67,11 +71,27 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
if not isinstance(doc, dict):
|
||||
raise _bad("일위대가 문서가 묶음 아님")
|
||||
whole = m01.loaded()[1]
|
||||
lines, seen = [], set()
|
||||
lines, seen, here = [], set(), None
|
||||
for at, one in enumerate(doc.get("담은로직") or [], start=1):
|
||||
where = f"단가산출 로직 {at}째"
|
||||
if not isinstance(one, dict):
|
||||
raise _bad(f"{where} · 묶음 아님")
|
||||
if one.get("참조") is not None and not one.get("로직"):
|
||||
here = here or refs.here()
|
||||
ref = refs.check_ref(one["참조"], where, here)
|
||||
if refs.ref_id(ref) in seen:
|
||||
raise _bad(f"{where} · 같은 참조를 두 번 담음 「{ref['키']}」")
|
||||
seen.add(refs.ref_id(ref))
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
lines.append(
|
||||
{
|
||||
"참조": ref,
|
||||
"별칭": alias,
|
||||
"메모": str(memo) if memo else None,
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
}
|
||||
)
|
||||
continue
|
||||
code = str(one.get("로직") or "")
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
@@ -84,16 +104,11 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
given = one.get("입력값") or {}
|
||||
if not isinstance(given, dict):
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo, alias = one.get("메모"), one.get("별칭")
|
||||
if alias is not None and not isinstance(alias, str):
|
||||
raise _bad(f"{where} · 별칭은 글")
|
||||
alias = (alias or "").strip()
|
||||
if len(alias) > ALIAS_MAX:
|
||||
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
"별칭": alias or None,
|
||||
"별칭": alias,
|
||||
"메모": 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),
|
||||
@@ -133,6 +148,15 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _alias(alias: Any, where: str) -> str | None:
|
||||
if alias is not None and not isinstance(alias, str):
|
||||
raise _bad(f"{where} · 별칭은 글")
|
||||
alias = (alias or "").strip()
|
||||
if len(alias) > ALIAS_MAX:
|
||||
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
|
||||
return alias or None
|
||||
|
||||
|
||||
def from_combo(key: str) -> dict[str, Any]:
|
||||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||||
@@ -161,8 +185,10 @@ def material_key(code: str) -> str:
|
||||
|
||||
|
||||
def pills(doc: dict) -> list[dict[str, Any]]:
|
||||
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」."""
|
||||
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) +
|
||||
자재 알약 「자재|MT키」."""
|
||||
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)]
|
||||
mats = [
|
||||
{
|
||||
"키": material_key(m["코드"]),
|
||||
@@ -230,16 +256,25 @@ def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | Non
|
||||
return None, "없음"
|
||||
|
||||
|
||||
def _is_ref(one: Any) -> bool:
|
||||
return isinstance(one, dict) and isinstance(one.get("참조"), dict)
|
||||
|
||||
|
||||
def line_id(one: dict) -> Any:
|
||||
"""줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`)."""
|
||||
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
|
||||
|
||||
|
||||
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 []}
|
||||
hands = {line_id(one): 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)
|
||||
qty, source = _pick(hands.get(line_id(line)), line.get("알약"), have)
|
||||
base = _money(line.get("기준"))
|
||||
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
|
||||
bases.append(base)
|
||||
@@ -277,6 +312,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
|
||||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
|
||||
whole = m01.loaded()[1]
|
||||
expand = refs.Expand(whole)
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
@@ -284,6 +320,9 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
}
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
if _is_ref(one):
|
||||
lines.append(ref_line(one["참조"], expand(one["참조"])))
|
||||
continue
|
||||
code = one["로직"]
|
||||
pill = by_logic.get(code) or {}
|
||||
line = {
|
||||
@@ -292,17 +331,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"분모": 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})
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"))})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
@@ -315,6 +344,30 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` ·
|
||||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
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(given or {})})
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||||
if "결과" in got:
|
||||
return {"결과": got["결과"], "기준": None, "까닭": None}
|
||||
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
|
||||
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
|
||||
|
||||
|
||||
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
|
||||
"""참조 줄 계산 답 — 분모 = 가리킨 문서 단위 · 배 1 · `안` = 사전조합 안 줄(한 층)."""
|
||||
line = {"참조": ref, "이름": got.get("이름"), "알약": refs.ref_id(ref), "분모": got.get("단위")}
|
||||
line.update({"배": 1, "기준": got.get("기준"), "까닭": got.get("까닭")})
|
||||
if "줄" in got: # 단가승격 = 원 로직 키 · 표(줄 누름 상세 · 50-3 ④)
|
||||
line.update({"원로직": got.get("로직"), "줄": got["줄"]})
|
||||
return {**line, "안": got["안"]} if "안" in got else line
|
||||
|
||||
|
||||
def _json(value: Any) -> Any:
|
||||
"""Decimal → JSON 수(정수면 int)."""
|
||||
if isinstance(value, Decimal):
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""M02 일위대가 조합 서고 — 단가승격 · 사전조합 · 참조(PLAN 50-3 · 조사 갈래 A).
|
||||
|
||||
자리 = 층 양식 길 새 종류 둘(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키:
|
||||
promote `{층}/promote/SP-######.json` 단가승격
|
||||
`{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` —
|
||||
줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때)
|
||||
precombo `{층}/precombo/SC-######.json` 사전조합
|
||||
`{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}]}` —
|
||||
승격 · 사전조합만 · 수량은 손(기본 1) · 값 = Σ 안 줄 값 × 수량
|
||||
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
|
||||
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
|
||||
같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭.
|
||||
지우기 = 쓰는 곳(모든 층 일위대가 · 사전조합)이 있으면 409 · 층 사이 복사 = 대상 층이 못 읽는
|
||||
참조는 그 문서도 같이 복사(새 키 · `본뜬`) · 프로젝트 = 참조사본으로 굳힘(`frozen`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
PREFIX = {"promote": "SP", "precombo": "SC"}
|
||||
TITLE = {"promote": "단가승격", "precombo": "사전조합"}
|
||||
DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다)
|
||||
REF_HEAD = "참조|"
|
||||
_KEY = re.compile(r"^(SP|SC)-(\d{6})$")
|
||||
_OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
|
||||
|
||||
Ref = dict[str, Any]
|
||||
Here = tuple[str, str | None]
|
||||
|
||||
|
||||
def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, text)
|
||||
|
||||
|
||||
def kind_of(key: str) -> str:
|
||||
return "promote" if str(key).startswith("SP") else "precombo"
|
||||
|
||||
|
||||
def ref_id(ref: Ref) -> str:
|
||||
"""참조 한 줄의 이름표 — 알약 키 · 줄 가름(짝 `Labor_Calc.ts` `lineId`)."""
|
||||
return f"{REF_HEAD}{ref.get('층')}|{ref.get('주인') or ''}|{ref.get('키')}"
|
||||
|
||||
|
||||
# ── 자리 ──────────────────────────────────────────────────────────────
|
||||
def where(folder: str | Path) -> Here:
|
||||
"""층 폴더 → (층, 주인) — 저장소 밖 · 프로젝트는 ("project", None)."""
|
||||
path = Path(folder).resolve()
|
||||
if path in (Path(store.FOLDER).resolve(), layers.system_dir().resolve()):
|
||||
return "system", None
|
||||
try:
|
||||
parts = path.relative_to(layers.storage_root()).parts
|
||||
except ValueError:
|
||||
return "project", None
|
||||
if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME:
|
||||
return "company", parts[0]
|
||||
if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME:
|
||||
return "personal", f"{parts[0]}/{parts[1]}"
|
||||
return "project", None
|
||||
|
||||
|
||||
def here() -> Here:
|
||||
return where(store.root())
|
||||
|
||||
|
||||
def folder_of(layer: str, owner: str | None) -> Path:
|
||||
if layer == "system":
|
||||
return Path(store.FOLDER)
|
||||
if layer == "company":
|
||||
return layers.company_dir(owner or "")
|
||||
company, user = str(owner).split("/", 1)
|
||||
return layers.personal_dir(company, user)
|
||||
|
||||
|
||||
def ref_path(ref: Ref) -> Path:
|
||||
return folder_of(ref["층"], ref.get("주인")) / kind_of(ref["키"]) / f"{ref['키']}.json"
|
||||
|
||||
|
||||
def read_ref(ref: Ref) -> dict[str, Any] | None:
|
||||
try:
|
||||
got = json.loads(ref_path(ref).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return got if isinstance(got, dict) else None
|
||||
|
||||
|
||||
def readable(at: Here, ref: Ref) -> bool:
|
||||
"""`at` 층이 `ref` 를 가리켜도 되나 — 위 층만(프로젝트는 아무것도 · 굳힌 사본만 씀)."""
|
||||
layer, owner = at
|
||||
if layer == "project":
|
||||
return False
|
||||
if ref["층"] == "system":
|
||||
return True
|
||||
if ref["층"] == "company":
|
||||
return layer in ("company", "personal") and str(owner).split("/")[0] == ref["주인"]
|
||||
return layer == "personal" and owner == ref["주인"]
|
||||
|
||||
|
||||
def check_ref(raw: Any, at: str, here_: Here) -> Ref:
|
||||
"""참조 꼴 · 위 층만 · 있는 문서 — 아니면 400."""
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad(f"{at} · 참조는 {{층, 주인, 키}}")
|
||||
layer, owner, key = raw.get("층"), raw.get("주인"), str(raw.get("키") or "")
|
||||
if layer == "system":
|
||||
owner = None
|
||||
elif layer not in _OWNER or not _OWNER[layer].match(str(owner or "")):
|
||||
raise _bad(f"{at} · 참조 층 · 주인 꼴이 틀림")
|
||||
if not _KEY.match(key):
|
||||
raise _bad(f"{at} · 참조 키는 SP-###### · SC-######")
|
||||
ref = {"층": layer, "주인": None if owner is None else str(owner), "키": key}
|
||||
if not readable(here_, ref):
|
||||
raise _bad(f"{at} · 위 층만 가리킴 — 「{key}」({layer} {owner or ''})")
|
||||
if read_ref(ref) is None:
|
||||
raise _bad(f"{at} · 없는 참조 「{key}」")
|
||||
return ref
|
||||
|
||||
|
||||
# ── 서고 문서 정리 ─────────────────────────────────────────────────────
|
||||
def check_key(kind: str, name: str) -> None:
|
||||
found = _KEY.match(name)
|
||||
if not found or found.group(1) != PREFIX[kind]:
|
||||
raise _bad(f"{TITLE[kind]} 키는 {PREFIX[kind]}-######")
|
||||
|
||||
|
||||
def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
"""서고 문서를 서버가 받는 꼴로 — `doc["키"]` = 파일 이름(부르는 쪽이 넣음)."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
raise _bad("서고 문서가 묶음 아님")
|
||||
key = str(doc.get("키") or "")
|
||||
check_key(kind, key)
|
||||
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
|
||||
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
|
||||
if kind == "promote":
|
||||
lines = doc.get("담은로직") or []
|
||||
if len(lines) != 1 or not isinstance(lines[0], dict) or not lines[0].get("로직"):
|
||||
raise _bad("단가승격은 단가산출 로직 한 줄")
|
||||
line = labor.clean({"담은로직": lines})["담은로직"][0]
|
||||
pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0]
|
||||
out.update(
|
||||
이름=name or pill.get("이름") or line["로직"],
|
||||
단위=unit or pill.get("단위") or None,
|
||||
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
|
||||
담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}],
|
||||
)
|
||||
else:
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
return out
|
||||
|
||||
|
||||
def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
at_, seen, lines = here(), set(), []
|
||||
for n, one in enumerate(doc.get("담은로직") or [], start=1):
|
||||
at = f"사전조합 {n}째"
|
||||
if not isinstance(one, dict) or one.get("로직") or one.get("참조") is None:
|
||||
raise _bad(f"{at} · 사전조합은 단가승격 · 사전조합만 담음")
|
||||
ref = check_ref(one["참조"], at, at_)
|
||||
if ref_id(ref) in seen:
|
||||
raise _bad(f"{at} · 같은 참조를 두 번 담음 「{ref['키']}」")
|
||||
seen.add(ref_id(ref))
|
||||
memo = one.get("메모")
|
||||
lines.append(
|
||||
{
|
||||
"참조": ref,
|
||||
"수량": labor._qty(one.get("수량"), at) or {"손": 1},
|
||||
"메모": str(memo) if memo else None,
|
||||
}
|
||||
)
|
||||
me = {"층": at_[0], "주인": at_[1], "키": key}
|
||||
for one in lines:
|
||||
_no_loop(one["참조"], [me, one["참조"]])
|
||||
return lines
|
||||
|
||||
|
||||
def _no_loop(ref: Ref, trail: list[Ref]) -> None:
|
||||
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
|
||||
if len(trail) > DEPTH:
|
||||
raise _bad(f"참조가 {DEPTH}단을 넘음 — " + " → ".join(r["키"] for r in trail), 422)
|
||||
for one in (read_ref(ref) or {}).get("담은로직") or []:
|
||||
child = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(child, dict) or not child.get("키"):
|
||||
continue
|
||||
if ref_id(child) == ref_id(trail[0]):
|
||||
raise _bad("순환 — " + " → ".join(r["키"] for r in [*trail, child]), 422)
|
||||
_no_loop(child, [*trail, child])
|
||||
|
||||
|
||||
def next_key(kind: str) -> str:
|
||||
"""지금 층 그 종류의 다음 키(가장 큰 번호 + 1)."""
|
||||
folder = store.root() / kind
|
||||
have = [int(m.group(2)) for p in folder.glob("*.json") if (m := _KEY.match(p.stem))]
|
||||
return f"{PREFIX[kind]}-{max(have, default=0) + 1:06d}"
|
||||
|
||||
|
||||
def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""지금 층에 새 키로 씀 → `store.write` 정보(+ 키) — 그새 남이 같은 키를 잡으면 다음 키."""
|
||||
for _ in range(5):
|
||||
key = next_key(kind)
|
||||
try:
|
||||
return store.write(kind, key, "", {**doc, "키": key})
|
||||
except store.StoreError as e:
|
||||
if e.status != 409 or not isinstance(e.detail, dict):
|
||||
raise
|
||||
raise _bad("새 키를 못 잡음 — 다시 누름", 409)
|
||||
|
||||
|
||||
def summary(folder: Path, kind: str, key: str) -> dict[str, Any]:
|
||||
"""층 목록 한 줄에 붙일 것 — `{제목, 단위}`."""
|
||||
try:
|
||||
doc = json.loads((Path(folder) / kind / f"{key}.json").read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return {"제목": doc.get("이름"), "단위": doc.get("단위")}
|
||||
|
||||
|
||||
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
|
||||
def users(ref: Ref) -> list[str]:
|
||||
"""`ref` 를 담은 일위대가 · 사전조합 — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌)."""
|
||||
rid, out = ref_id(ref), []
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")):
|
||||
for at, path in layers.layer_files(kind):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
lines = doc.get("담은로직") if isinstance(doc, dict) else None
|
||||
if any(
|
||||
isinstance(one, dict)
|
||||
and isinstance(one.get("참조"), dict)
|
||||
and ref_id(one["참조"]) == rid
|
||||
for one in lines or []
|
||||
):
|
||||
name = doc.get("일위대가번") or doc.get("이름") or path.stem
|
||||
out.append(f"{what} {at} 「{name}」")
|
||||
return out
|
||||
|
||||
|
||||
def refuse_used(folder: Path, kind: str, key: str) -> None:
|
||||
"""쓰는 곳이 있으면 409 + 이름(서고 종류만)."""
|
||||
if kind not in PREFIX:
|
||||
return
|
||||
layer, owner = where(folder)
|
||||
found = users({"층": layer, "주인": owner, "키": key})
|
||||
if found:
|
||||
raise _bad(f"쓰는 곳이 있어 못 지움 「{key}」 — " + " · ".join(found), 409)
|
||||
|
||||
|
||||
# ── 층 사이 복사 ──────────────────────────────────────────────────────
|
||||
def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
|
||||
"""복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로
|
||||
같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈. 원본 층 읽기 권한은 부르는 쪽 몫."""
|
||||
target, done = here(), {}
|
||||
|
||||
def move(ref: Ref, depth: int) -> Ref:
|
||||
if readable(target, ref):
|
||||
return ref
|
||||
if ref_id(ref) in done:
|
||||
return done[ref_id(ref)]
|
||||
if depth > DEPTH or not readable(source, ref):
|
||||
raise _bad(f"복사할 수 없는 참조 「{ref.get('키')}」")
|
||||
got = read_ref(ref)
|
||||
if got is None:
|
||||
raise _bad(f"없는 참조 「{ref['키']}」")
|
||||
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1), "본뜬": ref}
|
||||
made = create(kind_of(ref["키"]), body)
|
||||
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
|
||||
return done[ref_id(ref)]
|
||||
|
||||
return {**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}
|
||||
|
||||
|
||||
def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]:
|
||||
return [
|
||||
{**one, "참조": move(one["참조"], depth)}
|
||||
if isinstance(one, dict) and isinstance(one.get("참조"), dict)
|
||||
else one
|
||||
for one in lines or []
|
||||
]
|
||||
|
||||
|
||||
# ── 펼침(계산) ────────────────────────────────────────────────────────
|
||||
class Expand:
|
||||
"""참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 ·
|
||||
깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본)."""
|
||||
|
||||
def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = read_ref) -> None:
|
||||
self.whole, self.read, self.memo = whole, read, {}
|
||||
|
||||
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
rid = ref_id(ref)
|
||||
if rid in trail:
|
||||
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
|
||||
return self._stop(ref, "순환 — " + " → ".join(keys))
|
||||
if len(trail) >= DEPTH:
|
||||
return self._stop(ref, f"참조가 {DEPTH}단을 넘음")
|
||||
if rid not in self.memo:
|
||||
doc = self.read(ref)
|
||||
if not isinstance(doc, dict):
|
||||
return self._stop(ref, f"없는 참조 「{ref['키']}」")
|
||||
if kind_of(ref["키"]) == "promote":
|
||||
self.memo[rid] = self._promote(doc)
|
||||
else:
|
||||
self.memo[rid] = self._combo(doc, (*trail, rid))
|
||||
return self.memo[rid]
|
||||
|
||||
@staticmethod
|
||||
def _stop(ref: Ref, why: str) -> dict[str, Any]:
|
||||
return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why}
|
||||
|
||||
def _promote(self, doc: dict) -> dict[str, Any]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값"))
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"로직": line.get("로직"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": why,
|
||||
"줄": got.get("줄"), # 원 로직 표(줄 누름 상세 · 50-3 ④) — 원 로직 결과단위 그대로
|
||||
}
|
||||
|
||||
def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
inner, rows, why = [], [], None
|
||||
for one in doc.get("담은로직") or []:
|
||||
ref = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(ref, dict):
|
||||
continue
|
||||
sub = self(ref, trail)
|
||||
qty = labor._number((one.get("수량") or {}).get("손"))
|
||||
qty = Decimal(1) if qty is None else qty
|
||||
keep = ("이름", "단위", "기준", "까닭")
|
||||
inner.append({"참조": ref, "수량": qty, **{k: sub.get(k) for k in keep}})
|
||||
if sub.get("기준") is None:
|
||||
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
|
||||
continue
|
||||
rows.append({k: v * qty for k, v in sub["기준"].items()})
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"기준": None if why else labor._sum(rows),
|
||||
"까닭": why,
|
||||
"안": inner,
|
||||
}
|
||||
|
||||
|
||||
def pill(ref: Ref, read: Callable[[Ref], dict | None] = read_ref) -> dict[str, Any]:
|
||||
"""참조 줄 면적산출 알약 — 키 = `ref_id` · 이름 · 단위 = 가리킨 문서 것."""
|
||||
doc = read(ref) or {}
|
||||
return {
|
||||
"키": ref_id(ref),
|
||||
"이름": doc.get("이름") or ref["키"],
|
||||
"단위": doc.get("단위") or "",
|
||||
"로직": [],
|
||||
"참조": ref,
|
||||
}
|
||||
|
||||
|
||||
# ── 프로젝트 굳힘 ─────────────────────────────────────────────────────
|
||||
def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""일위대가 문서가 닿는 참조 문서 전부 `{ref_id: 문서}` — 가져올 때 값으로 굳힘
|
||||
(원본이 바뀌어도 프로젝트는 그대로)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def walk(lines: Any, depth: int) -> None:
|
||||
for one in lines or []:
|
||||
ref = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(ref, dict) or ref_id(ref) in out or depth > DEPTH:
|
||||
continue
|
||||
got = read_ref(ref)
|
||||
if got is not None:
|
||||
out[ref_id(ref)] = got
|
||||
walk(got.get("담은로직"), depth + 1)
|
||||
|
||||
walk(doc.get("담은로직"), 1)
|
||||
return out
|
||||
|
||||
|
||||
def frozen_logics(copies: dict[str, Any]) -> list[str]:
|
||||
"""굳힌 단가승격이 담은 로직 키 — 프로젝트 단가 묶음에 넣음."""
|
||||
return [
|
||||
str(line["로직"])
|
||||
for rid, doc in copies.items()
|
||||
if kind_of(rid.rsplit("|", 1)[-1]) == "promote"
|
||||
for line in (doc.get("담은로직") or [])[:1]
|
||||
if isinstance(line, dict) and line.get("로직")
|
||||
]
|
||||
@@ -1,19 +1,22 @@
|
||||
"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`.
|
||||
|
||||
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
|
||||
여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌).
|
||||
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
|
||||
지우기는 양식 길 그대로). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
@@ -62,3 +65,39 @@ def labor_pills(
|
||||
) -> dict:
|
||||
"""면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본)."""
|
||||
return _call(_root(session, layer, owner), _pills, column)
|
||||
|
||||
|
||||
class LibraryBody(BaseModel):
|
||||
"""새로 = `문서` · 복사 = `본뜬` {층, 주인, 키}(원본 읽기 권한 · 못 읽는 참조는 같이 복사)."""
|
||||
|
||||
문서: dict[str, Any] | None = None
|
||||
본뜬: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]:
|
||||
return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source))
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/library/{kind}")
|
||||
def new_library(
|
||||
layer: Layer,
|
||||
kind: Literal["promote", "precombo"],
|
||||
body: LibraryBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""서고 새로 · 복사 — 키는 서버가 그 층 다음 번호(SP · SC) · 답 `이름` = 새 키."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
if body.본뜬 is None:
|
||||
return _call(folder, refs.create, kind, body.문서 or {})
|
||||
src = body.본뜬
|
||||
if src.get("층") not in ("system", "company", "personal"):
|
||||
raise HTTPException(status_code=400, detail="본뜬 층이 틀림")
|
||||
got = access.grant(session, src["층"], src.get("주인")) # 원본 읽기 권한
|
||||
ref = {"층": got.layer, "주인": got.owner, "키": str(src.get("키") or "")}
|
||||
if refs.kind_of(ref["키"]) != kind:
|
||||
raise HTTPException(status_code=400, detail="본뜬 종류가 틀림")
|
||||
doc = refs.read_ref(ref) if refs._KEY.match(ref["키"]) else None
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail=f"없는 {refs.TITLE[kind]} 「{ref['키']}」")
|
||||
return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref)
|
||||
|
||||
@@ -9,8 +9,9 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor";
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) ·
|
||||
* 「단가승격」 = promote · 「사전조합」 = precombo(일위대가 조합 서고 · 50-3 · 키가 이름) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "promote" | "precombo";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -125,6 +126,19 @@ export const deleteLayerTemplate = (
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
/** 서고 새로 · 복사(50-3) — 새로 = `문서` · 복사 = `본뜬` {층, 주인, 키} · 키는 서버가 줌(답 `이름`) */
|
||||
export type LibraryBody =
|
||||
{ 문서: Record<string, unknown> } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } };
|
||||
export const newLibrary = (
|
||||
layer: Layer,
|
||||
kind: "promote" | "precombo",
|
||||
body: LibraryBody,
|
||||
): Promise<TemplateInfo> =>
|
||||
call(owned(layer, `/layers/${layer}/library/${kind}`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
getActiveOwner,
|
||||
readTemplate,
|
||||
type Layer,
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */
|
||||
@@ -71,6 +77,8 @@ const TEXT = {
|
||||
noPlace: "이 도면 화면은 표 놓기를 아직 못 함",
|
||||
stale: "면적산출 값이 바뀐 표 {n}개",
|
||||
update: "업데이트",
|
||||
tag: "{id} 도면",
|
||||
tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}",
|
||||
updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음",
|
||||
} as const;
|
||||
|
||||
@@ -112,6 +120,22 @@ const sameValues = (a: Record<string, string> = {}, b: Record<string, string> =
|
||||
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
||||
};
|
||||
|
||||
/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */
|
||||
export async function sentRanges(at: Selection): Promise<NonNullable<LinkOptions["표시범위"]>> {
|
||||
try {
|
||||
const got = await readTemplate(at.layer, "structure", at.name, at.projectId);
|
||||
const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? [];
|
||||
return rows.map((row) => ({
|
||||
시트: row.시트,
|
||||
범위: row.범위,
|
||||
이름: fill(TEXT.tag, "id", row.연결),
|
||||
설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface AreaToDrawingOptions {
|
||||
/** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */
|
||||
open: (sel: Selection) => Promise<void>;
|
||||
|
||||
@@ -176,7 +176,7 @@ 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)}
|
||||
body = store._server(kind, body)
|
||||
body = store._server(kind, body, source)
|
||||
with store._LOCK:
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 구분 칸(50-4) — 넉 자 · 흐린 글 */
|
||||
.m02-labrow__kind {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 줄 상세(50-4) — 표 아래 · 고른 줄만 */
|
||||
.m02-labor__linedet {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
|
||||
.m02-labrow__alias {
|
||||
min-width: 7rem;
|
||||
@@ -259,6 +270,12 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__kind {
|
||||
grid-column: 1 / -1;
|
||||
order: 1;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__no,
|
||||
.m02-labrow .m02-labrow__unit:not([data-label]),
|
||||
.m02-labrow .m02-labrow__code,
|
||||
@@ -322,6 +339,7 @@
|
||||
|
||||
/* 옵션 값 줄 — 한 줄 통째 */
|
||||
.m02-labrow__optrow .m02-labrow__no,
|
||||
.m02-labrow__sum .m02-labrow__kind,
|
||||
.m02-labrow__sum .m02-labrow__alias {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
call,
|
||||
getActiveLayer,
|
||||
getActiveOwner,
|
||||
type ItemLogic,
|
||||
@@ -118,9 +119,11 @@ export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
|
||||
|
||||
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
|
||||
export type LaborQty = { 손: number } | null;
|
||||
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
|
||||
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) ·
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3) = `참조` 있고 `로직` 빈 글 · 줄 가름은 `lineId` */
|
||||
export interface LaborRow {
|
||||
로직: string;
|
||||
참조?: LaborRef;
|
||||
/** 사용자가 지은 이름(44-6 · 빈 것 = null) */
|
||||
별칭?: string | null;
|
||||
메모: string | null;
|
||||
@@ -154,11 +157,13 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
열: d.열 ?? column,
|
||||
단위: d.단위 ?? null,
|
||||
담은로직: (d.담은로직 ?? []).map((r) => ({
|
||||
로직: r.로직,
|
||||
로직: r.로직 ?? "",
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
별칭: r.별칭 ?? null,
|
||||
메모: r.메모 ?? null,
|
||||
입력값: r.입력값 ?? {},
|
||||
수량: r.수량 ?? null,
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
@@ -223,6 +228,24 @@ export const calcLabor = (
|
||||
...(수량 ? { 수량 } : {}),
|
||||
});
|
||||
|
||||
/** 서고 한 줄(50-3) — 키 = SP-· SC-###### · 제목 · 단위 = 문서 것 */
|
||||
export interface LibraryRow {
|
||||
이름: string;
|
||||
제목?: string | null;
|
||||
단위?: string | null;
|
||||
}
|
||||
/** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */
|
||||
export const fetchLibrary = async (
|
||||
ref: Omit<LaborRef, "키">,
|
||||
kind: "promote" | "precombo",
|
||||
): Promise<LibraryRow[]> => {
|
||||
const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : "";
|
||||
const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>(
|
||||
`/layers/${ref.층}/templates${q}`,
|
||||
);
|
||||
return got.양식.filter((r) => r.종류 === kind);
|
||||
};
|
||||
|
||||
/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */
|
||||
export const fetchLogicInputs = (
|
||||
keys: string[],
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
|
||||
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
|
||||
@@ -11,8 +14,17 @@ export type QtySource = "손" | "칸" | "없음";
|
||||
/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */
|
||||
export type Qty = { 손: number } | null;
|
||||
|
||||
/** 참조 — 단가승격(SP) · 사전조합(SC) 문서(50-3) */
|
||||
export interface LaborRef {
|
||||
층: string;
|
||||
주인: string | null;
|
||||
키: string;
|
||||
}
|
||||
|
||||
export interface CalcLine {
|
||||
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
|
||||
로직: string;
|
||||
참조?: LaborRef;
|
||||
알약?: string | null;
|
||||
분모?: string | null;
|
||||
배: number;
|
||||
@@ -22,6 +34,19 @@ export interface CalcLine {
|
||||
반영?: Money | null;
|
||||
까닭?: string | null;
|
||||
결과?: unknown;
|
||||
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
|
||||
줄?: LogicLine[];
|
||||
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④) */
|
||||
이름?: string | null;
|
||||
원로직?: string | null;
|
||||
안?: {
|
||||
참조: LaborRef;
|
||||
수량: number;
|
||||
이름?: string | null;
|
||||
단위?: string | null;
|
||||
기준: Money | null;
|
||||
까닭?: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CalcMaterial {
|
||||
@@ -47,7 +72,7 @@ export interface LaborCalc {
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
|
||||
export interface LaborQtyDoc {
|
||||
담은로직?: { 로직: string; 수량?: Qty }[];
|
||||
담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty }[];
|
||||
}
|
||||
|
||||
@@ -57,6 +82,12 @@ function num(raw: unknown): number | null {
|
||||
return String(raw).trim() !== "" && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」(짝 `M02_Labor_Doc.line_id`) */
|
||||
export function lineId(one: { 로직?: string; 참조?: LaborRef }): string | undefined {
|
||||
if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`;
|
||||
return one.로직;
|
||||
}
|
||||
|
||||
function zero(): Money {
|
||||
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
|
||||
}
|
||||
@@ -91,7 +122,7 @@ export function settle(
|
||||
const v = num(raw);
|
||||
if (v !== null) have[k] = v;
|
||||
}
|
||||
const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null]));
|
||||
const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null]));
|
||||
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
|
||||
const base = zero();
|
||||
const done = zero();
|
||||
@@ -99,7 +130,7 @@ export function settle(
|
||||
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 [qty, source] = pick(hands.get(lineId(line)), line.알약, have);
|
||||
const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null;
|
||||
add(base, line.기준);
|
||||
add(done, own);
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
* M02_MasterTemplete_Labor_Detail.ts
|
||||
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
|
||||
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
|
||||
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) · [옵션] = 입력 모달(`Labor_Input`) ·
|
||||
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 표 아래 그 줄 상세(50-4 · M01 시험계산 표) ·
|
||||
* 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) ·
|
||||
* [확인] = 화면 캐시만 · 저장은 [저장]
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
@@ -15,22 +18,29 @@ import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import { answerView } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
|
||||
import type { CalcAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import {
|
||||
getActiveLayer,
|
||||
getActiveOwner,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type ItemLogic,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { lineId, MONEY_KEYS, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
areaLinks,
|
||||
calcLabor,
|
||||
fetchLogicInputs,
|
||||
normalizeLabor,
|
||||
type LaborCalc,
|
||||
type LaborCalcLine,
|
||||
type LaborCost,
|
||||
type LaborDoc,
|
||||
type LaborQty,
|
||||
type LaborRow,
|
||||
@@ -43,7 +53,7 @@ import {
|
||||
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { kindOf, logicTable, matTable, qtyShort } 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";
|
||||
@@ -64,6 +74,8 @@ export interface LaborDetailOptions {
|
||||
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
|
||||
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
|
||||
card?: boolean;
|
||||
/** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */
|
||||
mode?: "promote" | "precombo";
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
@@ -82,6 +94,8 @@ export function mountLaborDetail(
|
||||
const cur = normalizeLabor(doc, opt.column);
|
||||
const saved = JSON.stringify(cur);
|
||||
const edit = !opt.readOnly;
|
||||
const lib = opt.mode ?? null;
|
||||
const promote = lib === "promote";
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
@@ -131,13 +145,116 @@ export function mountLaborDetail(
|
||||
if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root);
|
||||
}
|
||||
|
||||
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
|
||||
const lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key);
|
||||
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
||||
|
||||
/** 고른 줄 상세(50-4) — 단가산출 = M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) ·
|
||||
* 단가승격 = 원 로직 표 + 원 로직 키 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층 + 기준 ·
|
||||
* 셋 다 아래 반영 합 · 고른 줄 없으면 비움 */
|
||||
const lineDet = el("div", {
|
||||
className: "m02-labor__linedet",
|
||||
attrs: { "data-labor-linedet": "" },
|
||||
});
|
||||
const sums = (title: string, money: Partial<LaborCost> | null | undefined): HTMLElement[] => [
|
||||
el("h4", { text: title }),
|
||||
el("dl", {
|
||||
className: "m01-logic__sums",
|
||||
children: MONEY_KEYS.flatMap((k) => [
|
||||
el("dt", { text: k === "계" ? tc("Prev_Sum") : k }),
|
||||
el("dd", {
|
||||
className: "m01-logic__money",
|
||||
text: money ? formatMoney(money[k] ?? 0) : "—",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
];
|
||||
/** 사전조합 안 줄 — 구분 · 이름 · 수량 · 금액 넷(= 그 줄 1 단위 기준 × 수량) */
|
||||
const innerTable = (inner: NonNullable<LaborCalcLine["안"]>): HTMLElement =>
|
||||
el("table", {
|
||||
className: "m01-logic__grid",
|
||||
attrs: { "data-labor-inner": "" },
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [tc("Sub"), tc("Col_Name"), tc("Labor_Qty"), ...MONEY_KEYS].map((h) =>
|
||||
el("th", { text: h === "계" ? tc("Prev_Sum") : h }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", {
|
||||
children: inner.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: kindOf({ 로직: "", 참조: x.참조 }) }),
|
||||
el("td", {
|
||||
text: `${x.참조.키} ${x.이름 ?? ""}${x.까닭 ? ` · ${x.까닭}` : ""}`.trim(),
|
||||
}),
|
||||
el("td", { text: `${x.수량} ${x.단위 ?? ""}`.trim() }),
|
||||
...MONEY_KEYS.map((k) =>
|
||||
el("td", {
|
||||
className: "m01-logic__money",
|
||||
text: x.기준 ? formatMoney(x.기준[k] * x.수량) : "—",
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
function showLine(): void {
|
||||
if (!picked) return void lineDet.replaceChildren();
|
||||
const row = cur.담은로직.find((r) => lineId(r) === picked);
|
||||
const line = lineOf(picked);
|
||||
const logic = logics.get(picked);
|
||||
const name = row?.참조 ? (line?.이름 ?? "") : (logic?.이름 ?? "");
|
||||
const head = el("h4", {
|
||||
text: tc("Labor_LineDetail").replace("{value}", `${row?.참조?.키 ?? picked} ${name}`.trim()),
|
||||
});
|
||||
if (!line)
|
||||
return void lineDet.replaceChildren(
|
||||
head,
|
||||
el("p", { className: "m01-logic__muted", text: fail ?? "…" }),
|
||||
);
|
||||
const stop: CalcAnswer | null = line.까닭 ? { ok: false, reason: line.까닭 } : null;
|
||||
const unit = `${tc("Labor_LineBase")} (1 ${line.분모 ?? ""})`.trim();
|
||||
const body: HTMLElement[] = !row?.참조
|
||||
? answerView(
|
||||
stop ?? {
|
||||
ok: true,
|
||||
lines: line.줄,
|
||||
sums: line.기준 ?? undefined,
|
||||
result: line.결과,
|
||||
middle: {},
|
||||
},
|
||||
false,
|
||||
(logic?.결과단위 ?? "").startsWith("원"),
|
||||
)
|
||||
: line.안
|
||||
? [
|
||||
innerTable(line.안),
|
||||
...(stop ? answerView(stop, false, true) : []),
|
||||
...sums(unit, line.기준),
|
||||
]
|
||||
: [
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${tc("Labor_FromLogic")} ${line.원로직 ?? "—"}`,
|
||||
}),
|
||||
...answerView(stop ?? { ok: true, lines: line.줄, middle: {} }, false, true),
|
||||
...sums(unit, line.기준),
|
||||
];
|
||||
// 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음
|
||||
const applied = line.기준 ? sums(tc("Labor_LineApplied"), line.반영) : [];
|
||||
lineDet.replaceChildren(head, ...body, ...applied);
|
||||
}
|
||||
|
||||
const drawDg = (): void => {
|
||||
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
|
||||
if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음
|
||||
const data = buildLaborDiagram({
|
||||
rows: cur.담은로직,
|
||||
rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함
|
||||
logics: [...logics.values()],
|
||||
calc,
|
||||
mats: cur.자재.length,
|
||||
@@ -161,7 +278,7 @@ export function mountLaborDetail(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
@@ -172,7 +289,9 @@ export function mountLaborDetail(
|
||||
}
|
||||
: { open: pick, option: openRow },
|
||||
),
|
||||
lineDet,
|
||||
);
|
||||
showLine();
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
{ mats: cur.자재, calc, fail, cells: cells() },
|
||||
@@ -277,7 +396,7 @@ export function mountLaborDetail(
|
||||
|
||||
/** 아직 안 받은 로직 입력 목록만 받음 */
|
||||
const loadLogics = async (): Promise<void> => {
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); // 참조 줄 = 로직 없음
|
||||
if (!want.length) return;
|
||||
try {
|
||||
const got = await fetchLogicInputs(want);
|
||||
@@ -293,15 +412,18 @@ export function mountLaborDetail(
|
||||
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
||||
changed();
|
||||
}
|
||||
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · 화면 캐시 안 바뀜 */
|
||||
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) ·
|
||||
* 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */
|
||||
function pick(i: number): void {
|
||||
picked = cur.담은로직[i].로직;
|
||||
const key = lineId(cur.담은로직[i]) ?? "";
|
||||
picked = picked === key ? null : key;
|
||||
for (const tr of logicHost.querySelectorAll<HTMLElement>("[data-labor-logic],[data-labor-opt]"))
|
||||
tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked);
|
||||
showLine();
|
||||
showSource();
|
||||
}
|
||||
function removeRow(i: number): void {
|
||||
if (cur.담은로직[i].로직 === picked) picked = null;
|
||||
if (lineId(cur.담은로직[i]) === picked) picked = null;
|
||||
cur.담은로직.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
@@ -337,9 +459,9 @@ export function mountLaborDetail(
|
||||
/** 수량산출 칸 누름 — 로직 줄 */
|
||||
function openQty(i: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
const line = lineOf(row.로직);
|
||||
const line = lineOf(lineId(row) ?? "");
|
||||
openQtyFor(
|
||||
`${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(),
|
||||
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
|
||||
row,
|
||||
line,
|
||||
line?.분모 ?? "",
|
||||
@@ -352,6 +474,18 @@ export function mountLaborDetail(
|
||||
openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? "");
|
||||
}
|
||||
|
||||
/** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */
|
||||
const library = {
|
||||
layer: opt.layer,
|
||||
owner: opt.layer === getActiveLayer() ? getActiveOwner() : null,
|
||||
pick: (ref: LaborRef, name: string) => {
|
||||
const id = lineId({ 참조: ref });
|
||||
if (cur.담은로직.some((r) => lineId(r) === id))
|
||||
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
|
||||
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량: null });
|
||||
changed();
|
||||
},
|
||||
};
|
||||
const addLogic = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
@@ -361,7 +495,7 @@ export function mountLaborDetail(
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
|
||||
void loadLogics().then(() => changed());
|
||||
}),
|
||||
}, library),
|
||||
});
|
||||
const addMat = createButton({
|
||||
label: tc("Labor_AddMat"),
|
||||
@@ -415,6 +549,16 @@ export function mountLaborDetail(
|
||||
],
|
||||
});
|
||||
|
||||
// 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름)
|
||||
const nameField = createInputField({ type: "text", label: tc("Lib_Name") });
|
||||
nameField.root.dataset.laborField = "이름";
|
||||
nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? "");
|
||||
nameField.input.readOnly = !edit;
|
||||
nameField.input.addEventListener("input", () => {
|
||||
(cur as { 이름?: string }).이름 = nameField.input.value.trim();
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
|
||||
const editor = (body: HTMLElement): HTMLElement =>
|
||||
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
|
||||
host.replaceChildren(
|
||||
@@ -429,13 +573,17 @@ export function mountLaborDetail(
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [...(lib ? [nameField.root] : []), unit.root, origin.root],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
|
||||
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
|
||||
...(opt.card ? [] : [dgBox]),
|
||||
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 고르기는 고르기 창 탭(50-3 ④)
|
||||
section(tc("Logics_Title"), opt.card || lib ? null : addLogic, logicHost),
|
||||
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
|
||||
...(opt.card || lib ? [] : [dgBox]),
|
||||
],
|
||||
}),
|
||||
),
|
||||
@@ -444,19 +592,28 @@ export function mountLaborDetail(
|
||||
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
|
||||
void Promise.all([
|
||||
loadLogics(),
|
||||
readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
lib // 서고 모드엔 면적산출 없음
|
||||
? undefined
|
||||
: readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
]).then(() => {
|
||||
if (alive) recalc();
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => cur,
|
||||
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
|
||||
getDoc: () => {
|
||||
if (!lib) return cur;
|
||||
const out: Record<string, unknown> = { ...cur, 종류: promote ? "단가승격" : "사전조합" };
|
||||
delete out.열;
|
||||
delete out.자재;
|
||||
return out as unknown as LaborDoc;
|
||||
},
|
||||
saveArea: async () => {
|
||||
if (!area || !areaDirty) return false;
|
||||
const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Pick.ts
|
||||
* [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음)
|
||||
* [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) ·
|
||||
* 탭 단가산출 · 단가승격 · 사전조합(50-3 ④) — 서고 탭은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) +
|
||||
* 그 층 목록 · 고르면 참조 {층 · 주인 · 키} · 프로젝트 · 카드엔 서고 탭 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -12,10 +14,16 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchLibLogics as fetchLogics,
|
||||
fetchLibLogicSubs as fetchLogicSubs,
|
||||
fetchLibrary,
|
||||
type LibraryRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { ownOwner } from "./M02_MasterTemplete_Layer";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -23,7 +31,33 @@ import "./M02_MasterTemplete_Labor.css";
|
||||
const opts = (values: string[]): { value: string; text: string }[] =>
|
||||
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void): void {
|
||||
type Scope = Omit<LaborRef, "키">;
|
||||
/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */
|
||||
export interface LibraryPick {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
pick: (ref: LaborRef, name: string) => void;
|
||||
}
|
||||
|
||||
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
|
||||
async function upper(layer: Layer, owner: string | null): Promise<Scope[]> {
|
||||
if (layer === "project") return [];
|
||||
const own =
|
||||
layer === "system"
|
||||
? null
|
||||
: (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null)));
|
||||
const out: Scope[] = [{ 층: "system", 주인: null }];
|
||||
if (own) out.push({ 층: "company", 주인: own.split("/")[0] });
|
||||
if (own && layer === "personal") out.push({ 층: "personal", 주인: own });
|
||||
return out;
|
||||
}
|
||||
const LAYER_TEXT = {
|
||||
system: "Lib_System",
|
||||
company: "Lib_Company",
|
||||
personal: "Lib_Personal",
|
||||
} as const;
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void {
|
||||
const subs: SubBrief[] = [];
|
||||
let all: LogicSummary[] = [];
|
||||
const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") });
|
||||
@@ -76,6 +110,108 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void {
|
||||
detail.select.disabled = d.length === 0;
|
||||
});
|
||||
|
||||
/* 서고 탭(50-3 ④) — 탭 · 층 단추 · 목록 */
|
||||
const filters = el("div", {
|
||||
className: "m01-logic__filters",
|
||||
children: [
|
||||
sub.root,
|
||||
detail.root,
|
||||
searchField.root,
|
||||
createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }),
|
||||
],
|
||||
});
|
||||
const tabs = el("div", { className: "ui-btn-group", attrs: { "data-pick-tabs": "" } });
|
||||
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
|
||||
let tab: "logic" | "promote" | "precombo" = "logic";
|
||||
let scopes: Scope[] = [];
|
||||
let scope: Scope | null = null;
|
||||
let seq = 0;
|
||||
const libList = async (): Promise<void> => {
|
||||
if (!lib || !scope || tab === "logic") return;
|
||||
const mine = ++seq;
|
||||
const at = scope;
|
||||
list.replaceChildren();
|
||||
hint.textContent = tc("Loading");
|
||||
let rows: LibraryRow[] = [];
|
||||
try {
|
||||
rows = await fetchLibrary(at, tab);
|
||||
} catch (error) {
|
||||
if (mine === seq)
|
||||
hint.textContent = error instanceof Error ? error.message : tc("Load_Failed");
|
||||
return;
|
||||
}
|
||||
if (mine !== seq) return;
|
||||
hint.textContent = rows.length ? `${rows.length}` : tc("Pick_None");
|
||||
list.replaceChildren(
|
||||
...rows.map((x) => {
|
||||
const row = el("button", {
|
||||
className: "m01-logic__pick-row m01-logic__pick-row--button",
|
||||
attrs: { type: "button", "data-pick": x.이름 },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__stack",
|
||||
children: [
|
||||
el("strong", { text: x.제목 || x.이름 }),
|
||||
el("span", { className: "m01-logic__muted", text: x.이름 }),
|
||||
],
|
||||
}),
|
||||
el("span", { text: x.단위 ?? "" }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
close();
|
||||
lib.pick({ ...at, 키: x.이름 }, x.제목 || x.이름);
|
||||
});
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const drawLayers = (): void =>
|
||||
layerBar.replaceChildren(
|
||||
...scopes.map((x) =>
|
||||
createButton({
|
||||
label: tc(LAYER_TEXT[x.층 as keyof typeof LAYER_TEXT]),
|
||||
variant: x === scope ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
scope = x;
|
||||
drawLayers();
|
||||
void libList();
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const drawTabs = (): void => {
|
||||
tabs.replaceChildren(
|
||||
...(["logic", "promote", "precombo"] as const).map((k) =>
|
||||
createButton({
|
||||
label: tc(
|
||||
k === "logic" ? "Kind_Logic" : k === "promote" ? "Kind_Promote" : "Lib_Precombo",
|
||||
),
|
||||
variant: k === tab ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
tab = k;
|
||||
drawTabs();
|
||||
filters.style.display = k === "logic" ? "" : "none"; // 공용 꼴 display 가 hidden 을 이김
|
||||
layerBar.style.display = k === "logic" ? "none" : "";
|
||||
if (k === "logic") {
|
||||
list.replaceChildren();
|
||||
hint.textContent = tc("Pick_Hint");
|
||||
} else void libList();
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
layerBar.style.display = "none";
|
||||
if (lib)
|
||||
void upper(lib.layer, lib.owner).then((got) => {
|
||||
scopes = got;
|
||||
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
|
||||
if (!got.length) return;
|
||||
drawTabs();
|
||||
drawLayers();
|
||||
});
|
||||
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01-combo",
|
||||
attrs: { role: "dialog", "aria-label": tc("Pick_Title") },
|
||||
@@ -87,15 +223,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void {
|
||||
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-logic__filters",
|
||||
children: [
|
||||
sub.root,
|
||||
detail.root,
|
||||
searchField.root,
|
||||
createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }),
|
||||
],
|
||||
}),
|
||||
tabs,
|
||||
filters,
|
||||
layerBar,
|
||||
hint,
|
||||
list,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Rows.ts
|
||||
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
|
||||
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
|
||||
* 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
|
||||
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
|
||||
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
LaborMat,
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -34,8 +35,16 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
계: "Prev_Sum",
|
||||
};
|
||||
|
||||
/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
|
||||
const SPAN = 4 + COSTS.length;
|
||||
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
|
||||
const SPAN = 5 + COSTS.length;
|
||||
|
||||
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
|
||||
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
|
||||
const key = row.참조?.키 ?? row.로직;
|
||||
return tc(
|
||||
key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic",
|
||||
);
|
||||
};
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
@@ -226,11 +235,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
// 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
|
||||
const tools = !!(move || remove || option);
|
||||
const lineOf = (key: string): LaborCalcLine | undefined =>
|
||||
data.calc?.줄.find((l) => l.로직 === key);
|
||||
data.calc?.줄.find((l) => lineId(l) === key);
|
||||
const trs = data.rows.flatMap((row, i) => {
|
||||
const id = lineId(row) ?? "";
|
||||
const logic = data.logics.get(row.로직);
|
||||
const line = lineOf(row.로직);
|
||||
const unit = logic?.결과단위 ?? "";
|
||||
const line = lineOf(id);
|
||||
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
|
||||
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const note = values.text;
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
@@ -247,18 +258,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
const sel = data.selected === row.로직 ? " is-selected" : "";
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
const tr = el("tr", {
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
attrs: { "data-labor-logic": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", { className: "m02-labrow__kind", text: kindOf(row) }),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: row.로직 }),
|
||||
el("strong", { text: logic?.이름 ?? row.로직 }),
|
||||
el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }),
|
||||
el("strong", {
|
||||
text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
|
||||
}),
|
||||
...(values.empty
|
||||
? [
|
||||
el("small", {
|
||||
@@ -294,7 +308,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
...(move
|
||||
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
|
||||
: []),
|
||||
...(option ? [small(tc("Labor_Option"), () => option(i))] : []),
|
||||
...(option && !row.참조 ? [small(tc("Labor_Option"), () => option(i))] : []),
|
||||
...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []),
|
||||
],
|
||||
}),
|
||||
@@ -309,7 +323,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
const opt = el("tr", {
|
||||
className: (on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow") + sel,
|
||||
attrs: { "data-labor-opt": row.로직 },
|
||||
attrs: { "data-labor-opt": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: "" }),
|
||||
el("td", {
|
||||
@@ -331,6 +345,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: "" }),
|
||||
el("td", { className: "m02-labrow__kind", text: "" }),
|
||||
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
|
||||
el("td", { className: "m02-labrow__alias", text: "" }),
|
||||
el("td", { className: "m02-labrow__qty", text: "" }),
|
||||
@@ -352,6 +367,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
: [];
|
||||
const heads = [
|
||||
tc("Col_Order"),
|
||||
tc("Sub"),
|
||||
tc("Col_Name"),
|
||||
tc("Col_Alias"),
|
||||
tc("Labor_QtyCol"),
|
||||
|
||||
@@ -84,6 +84,19 @@ const TEXT = {
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
Labor_AppliedSum: ["반영 금액", "Applied amount"],
|
||||
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
|
||||
// 구분 · 줄 상세(50-4)
|
||||
Kind_Logic: ["단가산출", "Logic"],
|
||||
Kind_Promote: ["단가승격", "Promoted"],
|
||||
Kind_Combo: ["일위조합", "Combination"],
|
||||
Labor_LineDetail: ["상세 — {value}", "Detail — {value}"],
|
||||
Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"],
|
||||
Labor_LineBase: ["기준", "Base"],
|
||||
Labor_FromLogic: ["원 로직", "Source logic"],
|
||||
Lib_Precombo: ["사전조합", "Pre-combination"],
|
||||
Lib_System: ["마스터", "Master"],
|
||||
Lib_Company: ["회사", "Company"],
|
||||
Lib_Personal: ["개인", "Personal"],
|
||||
Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"],
|
||||
Labor_Empty: ["빈 값 {n}", "{n} empty"],
|
||||
Labor_Lost: [
|
||||
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
|
||||
@@ -105,6 +118,7 @@ const TEXT = {
|
||||
In_Ok: ["확인", "OK"],
|
||||
In_Cancel: ["취소", "Cancel"],
|
||||
In_Close: ["닫기", "Close"],
|
||||
Lib_Name: ["이름", "Name"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type LaborCalc,
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { lineId, 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";
|
||||
@@ -64,12 +64,13 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
onRow ? () => onRow(row) : undefined;
|
||||
const logicOpen = (i: number): void => {
|
||||
const row = d.담은로직[i];
|
||||
const line = calc?.줄.find((l) => l.로직 === row.로직);
|
||||
const id = lineId(row) ?? "";
|
||||
const line = calc?.줄.find((l) => lineId(l) === id);
|
||||
pick({
|
||||
kind: "logic",
|
||||
key: row.로직,
|
||||
key: id,
|
||||
pill: line?.알약 ?? null,
|
||||
name: logics.get(row.로직)?.이름 ?? row.로직,
|
||||
name: (row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? row.참조?.키 ?? row.로직,
|
||||
unit: line?.분모 ?? "",
|
||||
})?.();
|
||||
};
|
||||
@@ -111,7 +112,7 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
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 want = doc.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k));
|
||||
const [inputs, area] = await Promise.all([
|
||||
fetchLogicInputs(want),
|
||||
readTemplate(layer, "area", column, null).then(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A
|
||||
export type ManageLayer = "system" | "company" | "personal";
|
||||
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
|
||||
export type Root = "master" | "company" | "personal";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합";
|
||||
|
||||
export interface Entry {
|
||||
root: Root;
|
||||
@@ -27,7 +27,7 @@ export interface Entry {
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"];
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"];
|
||||
|
||||
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_auth import verify_session
|
||||
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_Labor_Ref as refs
|
||||
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
|
||||
@@ -278,6 +279,8 @@ async def list_layer(
|
||||
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
|
||||
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
|
||||
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
|
||||
if row["종류"] in layers.LIBRARY_KINDS: # 서고(50-3) — 보이는 이름 · 단위
|
||||
row.update(refs.summary(folder, row["종류"], row["이름"]))
|
||||
return {"층": layer, "양식": rows}
|
||||
|
||||
|
||||
@@ -324,6 +327,8 @@ async def save_layer_template(
|
||||
session, layer, project_id=project_id, user_id=user_id, owner=owner, write=True
|
||||
)
|
||||
document = body.문서
|
||||
if layer == "project" and kind in layers.LIBRARY_KINDS:
|
||||
raise HTTPException(status_code=400, detail="프로젝트는 서고 문서를 안 둠 — 굳힌 사본만")
|
||||
try:
|
||||
layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀
|
||||
except ValueError as error:
|
||||
@@ -406,6 +411,10 @@ async def delete_layer_template(
|
||||
layers.check_name(name)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
try: # 서고 문서를 담은 일위대가 · 사전조합이 있으면 409(50-3)
|
||||
await asyncio.to_thread(refs.refuse_used, folder, kind, name)
|
||||
except store.StoreError as error:
|
||||
raise HTTPException(status_code=error.status, detail=error.detail) from error
|
||||
if not 판:
|
||||
raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.")
|
||||
try:
|
||||
|
||||
@@ -153,6 +153,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
if kind in layers.LIBRARY_KINDS: # 서고 문서 키 = 파일 이름(50-3)
|
||||
doc = {**doc, "키": name}
|
||||
origins = _take_origins(doc) if kind == "table" else {}
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
@@ -186,6 +188,10 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
|
||||
path = _path(kind, name)
|
||||
if (kind, name) in IN_USE:
|
||||
raise StoreError(409, f"쓰는 양식이라 못 지움 「{name}」 — {IN_USE[(kind, name)]}")
|
||||
if kind in layers.LIBRARY_KINDS:
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
refs.refuse_used(root(), kind, name)
|
||||
with _LOCK:
|
||||
if not path.is_file():
|
||||
raise StoreError(404, f"없는 양식 「{name}」")
|
||||
@@ -387,7 +393,7 @@ def copy_from(
|
||||
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 != "계산값"})
|
||||
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}, source_root)
|
||||
with _LOCK:
|
||||
atomic_write_json(_path(kind, column), doc)
|
||||
info = _info(kind, _path(kind, column))
|
||||
@@ -493,14 +499,21 @@ def renumber() -> dict[str, list[str]]:
|
||||
return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made}
|
||||
|
||||
|
||||
def _server(kind: str, doc: Any) -> Any:
|
||||
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`)."""
|
||||
def _server(kind: str, doc: Any, source: Path | None = None) -> Any:
|
||||
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`) ·
|
||||
서고 문서 정리(`M02_Labor_Ref` · 50-3) · `source` = 층 사이 복사의 원본 층 폴더(지금 층이
|
||||
못 읽는 참조는 같이 복사)."""
|
||||
if kind in SHEETS:
|
||||
return _server_values(doc)
|
||||
if kind == "labor":
|
||||
if kind in ("labor", *layers.LIBRARY_KINDS):
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
return labor.server(doc)
|
||||
if source is not None and isinstance(doc, dict) and kind != "promote":
|
||||
doc = refs.carry(doc, refs.where(source))
|
||||
if kind == "labor":
|
||||
return labor.server(doc)
|
||||
return refs.clean_doc(kind, doc)
|
||||
return doc
|
||||
|
||||
|
||||
|
||||
@@ -160,7 +160,9 @@ export async function pillsFor(
|
||||
...(p.자재 ? { 자재: p.자재 } : {}),
|
||||
고정: true,
|
||||
}));
|
||||
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills);
|
||||
// 받기 성공 = 목록 확정 — 이 목록에 없는 연결 알약만 ⚠(받기 실패 때는 표시 안 함)
|
||||
const links: LinkOptions = { 항목, 모드: "입력", 확정: true };
|
||||
return 항목.length ? { links } : { ...none(TEXT.noPills), links };
|
||||
} catch (error) {
|
||||
return none(fill(TEXT.pillsFailed, why(error)));
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
|
||||
import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
|
||||
import { helpParts } from "./M02_MasterTemplete_CardShell";
|
||||
import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
export interface Selection {
|
||||
@@ -273,13 +274,14 @@ export function createMain(
|
||||
): Promise<void> {
|
||||
const mine = seq;
|
||||
try {
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
const [{ createSpreadsheet }, pills, sent] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
pillsFor(kind, at.name),
|
||||
sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions = pills.links;
|
||||
const links: LinkOptions = { ...pills.links, 표시범위: sent };
|
||||
const handle: SpreadsheetHandle = createSpreadsheet(
|
||||
host,
|
||||
doc as Parameters<typeof createSpreadsheet>[1],
|
||||
@@ -378,19 +380,24 @@ export function createMain(
|
||||
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
|
||||
return;
|
||||
}
|
||||
if (at.kind === "labor") {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
|
||||
if (at.kind === "labor" || isPrecombo(at.kind)) {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 ·
|
||||
// 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키)
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
const mode = isPrecombo(at.kind) ? at.kind : undefined;
|
||||
const labor = mountLaborDetail(host, doc, {
|
||||
layer: at.layer,
|
||||
column: at.name,
|
||||
readOnly,
|
||||
onChanged: (d) => (dirty = d && !readOnly),
|
||||
goArea: async () => {
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
goArea: mode
|
||||
? null
|
||||
: async () => {
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
...(mode ? { mode } : {}),
|
||||
});
|
||||
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)
|
||||
editor = {
|
||||
|
||||
@@ -105,13 +105,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement | null, reselect: () => void): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: entry.root, owner },
|
||||
company,
|
||||
go: (next, nextOwner) => void go(next, nextOwner, open),
|
||||
add,
|
||||
...(add ? { add } : {}),
|
||||
reselect,
|
||||
});
|
||||
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
|
||||
@@ -184,6 +184,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
// 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침
|
||||
precomboBar: [layerBar("일위대가조합", null, () => undefined)],
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 ·
|
||||
* `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
@@ -31,6 +32,7 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, PRECOMBO_TITLE } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
@@ -58,6 +60,8 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */
|
||||
precomboBar?: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -66,7 +70,7 @@ export interface SideHandle {
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
reveal: (target: "옵션" | "도면") => void;
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -130,6 +134,17 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인)
|
||||
const precombo = buildPrecomboSection({
|
||||
layer,
|
||||
canEdit: canEdit(layer, opt.isAdmin),
|
||||
bar: opt.precomboBar ?? [],
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
isOpen: (kind, name) => isPicked(kind, name),
|
||||
refresh: () => refresh(),
|
||||
});
|
||||
const precomboSec = section(PRECOMBO_TITLE, precombo.body);
|
||||
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
|
||||
const extras =
|
||||
layer === "system"
|
||||
@@ -151,6 +166,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
precomboSec,
|
||||
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
@@ -205,6 +221,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
precombo.paint(items);
|
||||
paintButtons();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
@@ -228,6 +245,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
precombo.paint(items);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -307,8 +325,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면"): void => {
|
||||
const sec = extras?.[target];
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => {
|
||||
const sec = target === "일위대가조합" ? precomboSec : extras?.[target];
|
||||
if (!sec) return; // 옵션 보이기는 시스템 관리자만
|
||||
sec.classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
|
||||
* 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) ·
|
||||
* 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기.
|
||||
* 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음).
|
||||
* 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
getActiveOwner,
|
||||
newLibrary,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type LibraryBody,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
|
||||
|
||||
/** 종류 · 화면 이름(= 문서 `종류` 글 · 키 SP- · SC- 는 서버가 줌) */
|
||||
const PARTS: { kind: PrecomboKind; label: string }[] = [
|
||||
{ kind: "promote", label: "단가승격" },
|
||||
{ kind: "precombo", label: "사전조합" },
|
||||
];
|
||||
export const PRECOMBO_TITLE = "일위대가 조합";
|
||||
export const isPrecombo = (kind: Kind): kind is PrecomboKind =>
|
||||
kind === "promote" || kind === "precombo";
|
||||
|
||||
/** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */
|
||||
type Info = TemplateInfo & { 제목?: string };
|
||||
export const precomboLabel = (info: Info): string =>
|
||||
info.제목 ? `${info.이름} ${info.제목}` : info.이름;
|
||||
|
||||
export interface PrecomboOptions {
|
||||
layer: Layer;
|
||||
canEdit: boolean;
|
||||
/** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */
|
||||
bar: HTMLElement[];
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
isOpen: (kind: Kind, name: string) => boolean;
|
||||
/** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
body: HTMLElement;
|
||||
/** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */
|
||||
paint: (items: TemplateInfo[]) => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
|
||||
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
let items: Info[] = [];
|
||||
const lists = new Map<PrecomboKind, HTMLElement>();
|
||||
const buttons = new Map<PrecomboKind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
|
||||
const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind);
|
||||
const current = (kind: PrecomboKind): Info | undefined =>
|
||||
mine(kind).find((i) => opt.isOpen(kind, i.이름));
|
||||
|
||||
const select = async (info: Info): Promise<void> => {
|
||||
if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
layer: opt.layer,
|
||||
projectId: null,
|
||||
kind: info.종류,
|
||||
name: info.이름,
|
||||
label: precomboLabel(info),
|
||||
});
|
||||
};
|
||||
|
||||
/** 새로 — 서버가 그 층 다음 키로 씀 → 엶 */
|
||||
const create = async (kind: PrecomboKind, body: LibraryBody): Promise<void> => {
|
||||
const key = (await newLibrary(opt.layer, kind, body)).이름;
|
||||
showToast(L("M02_Created"), "success");
|
||||
await opt.refresh();
|
||||
const info = mine(kind).find((i) => i.이름 === key);
|
||||
if (info) await select(info);
|
||||
};
|
||||
|
||||
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
name.input.value = start;
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (body, close) => {
|
||||
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
name.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) ok.click();
|
||||
});
|
||||
body.append(name.root, ok);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 단가승격 새로 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것(입력값은 상세에서 고정) */
|
||||
const newPromote = (): void =>
|
||||
openLogicPicker((x) => {
|
||||
void opt.confirmLeave().then((go) =>
|
||||
!go
|
||||
? undefined
|
||||
: create("promote", {
|
||||
문서: {
|
||||
이름: x.이름,
|
||||
단위: x.결과단위 || null,
|
||||
담은로직: [{ 로직: x.키, 입력값: {} }],
|
||||
},
|
||||
}).catch(failed),
|
||||
);
|
||||
});
|
||||
/** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */
|
||||
const newPrecombo = (): void =>
|
||||
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
await create("precombo", { 문서: { 이름: name, 단위: null, 담은로직: [] } });
|
||||
});
|
||||
|
||||
/** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */
|
||||
const copy = (kind: PrecomboKind): void => {
|
||||
const from = current(kind);
|
||||
if (!from) return;
|
||||
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const made = await newLibrary(opt.layer, kind, {
|
||||
본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 },
|
||||
});
|
||||
const got = await readTemplate(opt.layer, kind, made.이름, null);
|
||||
await saveTemplate(opt.layer, kind, made.이름, null, made.판, {
|
||||
...(got.문서 as object),
|
||||
이름: name,
|
||||
});
|
||||
showToast(L("M02_Created"), "success");
|
||||
await opt.refresh();
|
||||
const info = mine(kind).find((i) => i.이름 === made.이름);
|
||||
if (info) await select(info);
|
||||
});
|
||||
};
|
||||
|
||||
const remove = async (kind: PrecomboKind): Promise<void> => {
|
||||
await opt.refresh(); // 저장 직후여도 최신 판으로
|
||||
const info = current(kind);
|
||||
if (!info) return;
|
||||
const name = precomboLabel(info);
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return;
|
||||
try {
|
||||
if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판);
|
||||
else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.onOpen(null);
|
||||
await opt.refresh();
|
||||
} catch (error) {
|
||||
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
|
||||
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const block = (part: (typeof PARTS)[number]): HTMLElement => {
|
||||
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
|
||||
lists.set(part.kind, list);
|
||||
const row: HTMLElement[] = [];
|
||||
if (opt.canEdit) {
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
|
||||
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
|
||||
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
|
||||
buttons.set(part.kind, { copy: btnCopy, del });
|
||||
btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo);
|
||||
btnCopy.addEventListener("click", () => copy(part.kind));
|
||||
del.addEventListener("click", () => void remove(part.kind));
|
||||
row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] }));
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-side__sub",
|
||||
attrs: { "data-precombo": part.kind },
|
||||
children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list],
|
||||
});
|
||||
};
|
||||
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
children: [...opt.bar, ...PARTS.map(block)],
|
||||
});
|
||||
|
||||
function paint(all: TemplateInfo[]): void {
|
||||
items = all.filter((i) => isPrecombo(i.종류));
|
||||
for (const part of PARTS) {
|
||||
const found = mine(part.kind);
|
||||
lists.get(part.kind)?.replaceChildren(
|
||||
...(found.length
|
||||
? found.map((info) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`,
|
||||
text: precomboLabel(info),
|
||||
attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
);
|
||||
const b = buttons.get(part.kind);
|
||||
if (b) b.copy.disabled = b.del.disabled = !current(part.kind);
|
||||
}
|
||||
}
|
||||
|
||||
return { body, paint };
|
||||
}
|
||||
@@ -56,6 +56,20 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 일위대가 조합 서고(50-3) — 하위 단가승격 · 사전조합 */
|
||||
.m02-side__sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-side__subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -28,7 +28,9 @@ from config import config_system
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area")
|
||||
# promote = 단가승격(SP-) · precombo = 사전조합(SC-) — 일위대가 조합 서고(`M02_Labor_Ref`)
|
||||
LIBRARY_KINDS = ("promote", "precombo")
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS)
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -171,6 +173,8 @@ _SKELETON = {
|
||||
"structure": ("도면.entities",),
|
||||
"labor": ("담은로직",),
|
||||
"area": ("시트",),
|
||||
"promote": ("담은로직",),
|
||||
"precombo": ("담은로직",),
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +234,17 @@ def delete_template(
|
||||
return True
|
||||
|
||||
|
||||
def layer_files(kind: str) -> list[tuple[str, Path]]:
|
||||
"""모든 층(마스터 · 회사 · 개인 · 프로젝트 빼고)의 `kind/*.json` — (어디, 파일)."""
|
||||
root = storage_root()
|
||||
found = [("마스터", p) for p in sorted((system_dir() / kind).glob("[!.]*.json"))]
|
||||
found += [
|
||||
(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob(f"*/templates/{kind}/[!.]*.json"))
|
||||
]
|
||||
personal = sorted(root.glob(f"*/*/templates/{kind}/[!.]*.json"))
|
||||
return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal]
|
||||
|
||||
|
||||
# ── manifest ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -283,7 +298,8 @@ def copy_templates(
|
||||
) -> list[str]:
|
||||
"""`source_dir` 층의 양식을 `target_dir` 로 복사 → 복사한 `종류/이름` 목록.
|
||||
|
||||
`kind`·`name` 으로 좁힘 · `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
|
||||
`kind`·`name` 으로 좁힘(안 좁히면 서고 종류 `LIBRARY_KINDS` 는 뺌 — 프로젝트는 굳힌 사본) ·
|
||||
`only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
|
||||
manifest 에 출처(층 · 이름 · 판 · `source_ref`)를 적음. 출처 manifest 의 원래 출처는
|
||||
적지 않음 — 「어디서 가져왔나」 한 단계만.
|
||||
"""
|
||||
@@ -292,6 +308,8 @@ def copy_templates(
|
||||
rows = list_templates(source_dir)
|
||||
if kind is not None:
|
||||
rows = [row for row in rows if row["종류"] == check_kind(kind)]
|
||||
else:
|
||||
rows = [row for row in rows if row["종류"] not in LIBRARY_KINDS]
|
||||
if name is not None:
|
||||
rows = [row for row in rows if row["이름"] == check_name(name)]
|
||||
manifest = read_manifest(target_dir)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"판": 1,
|
||||
"판": 5,
|
||||
"schema_version": 1,
|
||||
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
|
||||
"구조물군": {
|
||||
@@ -25,7 +25,7 @@
|
||||
{
|
||||
"type_id": "pipe",
|
||||
"group": "A",
|
||||
"name": "배수관",
|
||||
"name": "배관",
|
||||
"placement": "point",
|
||||
"managed_by": "pipe_points",
|
||||
"style": {
|
||||
@@ -50,7 +50,8 @@
|
||||
"파형강관"
|
||||
],
|
||||
"default": "파형강관",
|
||||
"required": false
|
||||
"required": false,
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"key": "pipe_diameter_mm",
|
||||
@@ -297,7 +298,7 @@
|
||||
"label": "본체 폭",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.0,
|
||||
"default": 2,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -317,7 +318,7 @@
|
||||
"label": "본체 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.0,
|
||||
"default": 2,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -963,7 +964,7 @@
|
||||
"label": "높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.0,
|
||||
"default": 2,
|
||||
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
|
||||
"required": false,
|
||||
"phase": "b05",
|
||||
@@ -1292,7 +1293,7 @@
|
||||
"required": false,
|
||||
"phase": "b05",
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
|
||||
"warn_above": 2.0,
|
||||
"warn_above": 2,
|
||||
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
|
||||
},
|
||||
{
|
||||
@@ -2197,7 +2198,7 @@
|
||||
"label": "높이(≤2.0)",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.0
|
||||
"default": 2
|
||||
},
|
||||
{
|
||||
"key": "weep_hole_diameter_mm",
|
||||
@@ -2661,7 +2662,7 @@
|
||||
"label": "유효길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 15.0
|
||||
"default": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2685,14 +2686,14 @@
|
||||
"label": "너비",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 7.0
|
||||
"default": 7
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "유효길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 20.0
|
||||
"default": 20
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2715,7 +2716,7 @@
|
||||
"label": "너비",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10.0
|
||||
"default": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3265,7 +3266,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"표시": {},
|
||||
"표시": {
|
||||
"pipe": {
|
||||
"pipe_kind": false
|
||||
}
|
||||
},
|
||||
"그룹": {
|
||||
"pipe": [
|
||||
{
|
||||
@@ -3273,19 +3278,23 @@
|
||||
"이름": "관",
|
||||
"옵션": [
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"inlet_type"
|
||||
]
|
||||
"pipe_diameter_mm"
|
||||
],
|
||||
"형상": {}
|
||||
},
|
||||
{
|
||||
"id": "gnbf9m9",
|
||||
"이름": "관 보호공(유입)",
|
||||
"옵션": []
|
||||
"옵션": [
|
||||
"inlet_type"
|
||||
],
|
||||
"형상": {}
|
||||
},
|
||||
{
|
||||
"id": "g9lq9uk",
|
||||
"이름": "관 보호공(유출)",
|
||||
"옵션": []
|
||||
"옵션": [],
|
||||
"형상": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@ import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력";
|
||||
const q = new URLSearchParams(location.search);
|
||||
const mode = q.get("mode") === "자재" ? "자재" : "입력";
|
||||
// ?ghost=1 — 목록 확정 + 목록에 없는 연결 알약 X(B1) · 목록 알약 L(B2) / ?range=1 — 도면 보낸 범위 A1:B2
|
||||
const ghost = q.get("ghost") === "1";
|
||||
|
||||
const book: Workbook = {
|
||||
종류: "통합문서",
|
||||
@@ -29,6 +32,11 @@ const book: Workbook = {
|
||||
],
|
||||
활성: "s1",
|
||||
};
|
||||
if (ghost)
|
||||
book.시트[0].연결 = [
|
||||
{ 키: "X", 칸: "B1", 이름: "지운로직", 단위: "m" },
|
||||
{ 키: "L", 칸: "B2", 이름: "길이", 단위: "m" },
|
||||
];
|
||||
|
||||
const MATERIALS: LinkMaterial[] = [
|
||||
{ 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" },
|
||||
@@ -59,6 +67,14 @@ w.ss = createSpreadsheet(document.getElementById("host")!, book, {
|
||||
{ 키: "H", 이름: "높이", 단위: "m" },
|
||||
{ 키: "A", 이름: "면적", 단위: "m²" },
|
||||
],
|
||||
...(ghost ? { 확정: true } : {}),
|
||||
...(q.get("range") === "1"
|
||||
? {
|
||||
표시범위: [
|
||||
{ 시트: "s1", 범위: "A1:B2", 이름: "T1 도면", 설명: "도면 표 T1 로 보낸 범위" },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록),
|
||||
자재찾기: async (글) => {
|
||||
asked.push(글);
|
||||
|
||||
@@ -61,3 +61,46 @@ def test_tables_split_and_br() -> None:
|
||||
tables = [b["table"] for b in one["blocks"] if "table" in b]
|
||||
assert [len(t[0]) for t in tables] == [7, 2]
|
||||
assert tables[0][2][0] == "인력운반\n타설"
|
||||
|
||||
|
||||
def test_markdown_stripped() -> None:
|
||||
"""역슬래시 · 인용 `>` 머리 · 글 안 주석을 벗김(50-2) — 모든 정본 출처 원문에 0."""
|
||||
bad = 0
|
||||
for path in store.FOLDER.glob("로직_*품셈_*.json"):
|
||||
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
|
||||
for one in make.source(row.get("출처", ""))["found"]:
|
||||
for b in one["blocks"]:
|
||||
t = b.get("p") or b.get("text") or ""
|
||||
bad += "\\" in t or "<!--" in t or t.startswith(">")
|
||||
assert bad == 0
|
||||
one = make.source("산림품셈 3-4-2 1")["found"][0]
|
||||
assert any(b.get("p") == "Q = 3,600 × q × K × f × E / Cm" for b in one["blocks"])
|
||||
|
||||
|
||||
def test_item_marked() -> None:
|
||||
"""출처 항 · 주 번호 → 그 조각만 hit(절은 통째) · 못 찾으면 item 없음."""
|
||||
|
||||
def hits(src: str) -> list[str]:
|
||||
one = make.source(src)["found"][0]
|
||||
return [(b.get("p") or b.get("text") or "table")[:2] for b in one["blocks"] if b.get("hit")]
|
||||
|
||||
one = make.source("산림품셈 3-4-2 1")["found"][0]
|
||||
assert one["item"] == "1" and one["blocks"][0].get("hit") is None
|
||||
assert not any(b.get("hit") for b in one["blocks"] if b.get("p", "").startswith("2."))
|
||||
assert hits("산림품셈 3-4-2 1")[0] == "1."
|
||||
assert hits("산림품셈 9-7-1 주⑥") == ["⑥ "]
|
||||
assert make.source("산림품셈 13-10-2 2")["found"][0]["item"] == "2"
|
||||
assert "item" not in make.source("산림품셈 3-4-2")["found"][0]
|
||||
|
||||
|
||||
def test_item_all_marked() -> None:
|
||||
"""항 · 주 번호가 달린 정본 출처는 전부 표시가 붙음(전수)."""
|
||||
total = missing = 0
|
||||
for path in store.FOLDER.glob("로직_*품셈_*.json"):
|
||||
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
|
||||
src = row.get("출처", "")
|
||||
if not any(i for _n, i in make._NUMBER.findall(src)):
|
||||
continue
|
||||
total += 1
|
||||
missing += not any(f.get("item") for f in make.source(src)["found"])
|
||||
assert total >= 30 and missing == 0, (total, missing)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""50-1 면적산출 알약 · 보낸 범위 표시 — 원문 규칙 점검(화면 수치는 ui_checks/50-1_*.js 를 ORCA 로)."""
|
||||
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_orphan_mark_only_when_list_confirmed():
|
||||
# ⚠ 는 호스트가 목록을 확정했을 때만 — 알약 받기 실패(확정 없음)는 표시 안 함
|
||||
links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts")
|
||||
assert 'opts.모드 === "입력" && opts.확정 === true' in links
|
||||
structure = _read("M02_MasterTemplete", "M02_MasterTemplete_Structure.ts")
|
||||
assert "확정: true" in structure
|
||||
assert structure.count("확정: true") == 1 # 실패 길(none)은 확정 없음
|
||||
|
||||
|
||||
def test_cell_pick_highlights_pill_and_range_is_unfilled():
|
||||
links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts")
|
||||
assert "is-here" in links and "scrollIntoView" in links
|
||||
css = _read("A00_Common", "spreadsheet", "spreadsheet_links.css")
|
||||
block = css.split(".ss-link-range {", 1)[1].split("}", 1)[0]
|
||||
assert "dashed" in block and "background" not in block # 채움 없음 = 알약 색과 안 겹침
|
||||
|
||||
|
||||
def test_sent_ranges_from_same_column_drawing():
|
||||
src = _read("M02_MasterTemplete", "M02_MasterTemplete_AreaToDrawing.ts")
|
||||
assert 'readTemplate(at.layer, "structure", at.name, at.projectId)' in src
|
||||
assert '"{id} 도면"' in src
|
||||
@@ -79,7 +79,9 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
|
||||
assert "opt.card ? null : addLogic" in detail and "...(opt.card ? [] : [dgBox])," in detail
|
||||
# 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음
|
||||
assert "opt.card || lib ? null : addLogic" in detail
|
||||
assert "...(opt.card || lib ? [] : [dgBox])," in detail
|
||||
assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert "const tools = !!(move || remove || option);" in rows and "compactTable" not in rows
|
||||
|
||||
@@ -90,6 +90,14 @@ def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_
|
||||
assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"])
|
||||
|
||||
|
||||
def test_돈_로직_줄에_재료_노무_경비_줄_실림_합은_기준():
|
||||
got = labor.calc(labor.clean(_doc(담은로직=[{"로직": TEN}, {"로직": PLAIN}])), {})
|
||||
ten, plain = got["줄"]
|
||||
assert ten["줄"] and all({"이름", "수량", "단위", "금액"} <= set(x) for x in ten["줄"])
|
||||
assert sum(x["금액"] for x in ten["줄"]) == pytest.approx(ten["기준"]["계"])
|
||||
assert "줄" not in plain
|
||||
|
||||
|
||||
def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
|
||||
doc = labor.clean(
|
||||
_doc(
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""일위대가 조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 단가승격 · 사전조합 · 참조 줄.
|
||||
|
||||
펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 ·
|
||||
프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage).
|
||||
"""
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_labor_calc_mirror import _same, _ts
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
TEN = "GC000421" # 원/10㎡ — 배 10
|
||||
STONE = "GF000219" # 원/㎡
|
||||
PLAIN = "GC000422" # 돈 아닌 로직
|
||||
SYS = {"층": "system", "주인": None}
|
||||
COMPANY = {"층": "company", "주인": "7"}
|
||||
MINE = {"층": "personal", "주인": "7/42"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, monkeypatch):
|
||||
from config import config_system
|
||||
|
||||
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")
|
||||
return {
|
||||
"system": system,
|
||||
"company": layers.company_dir(7),
|
||||
"personal": layers.personal_dir(7, 42),
|
||||
}
|
||||
|
||||
|
||||
def _promote(folder, code=TEN, **extra):
|
||||
with store.at(folder):
|
||||
return refs.create("promote", {"담은로직": [{"로직": code, "입력값": {}}], **extra})["이름"]
|
||||
|
||||
|
||||
def _combo(folder, lines, name="조합"):
|
||||
with store.at(folder):
|
||||
return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _read(folder, kind, key):
|
||||
return json.loads((folder / kind / f"{key}.json").read_bytes())
|
||||
|
||||
|
||||
def _base(code):
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
return labor.run_line(m01.loaded()[1], code, {})["기준"]
|
||||
|
||||
|
||||
# ── 자료 틀 ────────────────────────────────────────────────────────────
|
||||
def test_승격은_로직_한_줄_단위_배_이름은_로직에서_키는_층마다_다음_번호(world):
|
||||
assert _promote(world["system"]) == "SP-000001"
|
||||
assert _promote(world["system"], STONE, 이름="돌") == "SP-000002"
|
||||
assert _promote(world["personal"]) == "SP-000001" # 층마다 셈
|
||||
doc = _read(world["system"], "promote", "SP-000001")
|
||||
assert (doc["종류"], doc["단위"], doc["배"]) == ("단가승격", "㎡", 10)
|
||||
assert doc["이름"] and doc["담은로직"] == [{"로직": TEN, "입력값": {}}]
|
||||
assert _read(world["system"], "promote", "SP-000002")["이름"] == "돌"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"담은로직": []},
|
||||
{"담은로직": [{"로직": TEN}, {"로직": STONE}]},
|
||||
{"담은로직": [{"로직": "GF999999"}]},
|
||||
{"담은로직": [{"참조": {**SYS, "키": "SP-000001"}}]},
|
||||
],
|
||||
)
|
||||
def test_틀린_승격은_400(world, body):
|
||||
_promote(world["system"])
|
||||
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
|
||||
refs.create("promote", body)
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
def test_사전조합_틀린_줄_400_순환_422(world):
|
||||
sp = _promote(world["system"])
|
||||
one = {"참조": {**SYS, "키": sp}}
|
||||
for lines in (
|
||||
[{"로직": TEN}], # 로직은 못 담음(승격 · 사전조합만)
|
||||
[one, one], # 같은 참조 두 번
|
||||
[{"참조": {**SYS, "키": "SP-000009"}}], # 없는 참조
|
||||
[{"참조": {**SYS, "키": "SU-000001"}}], # 서고 키 아님
|
||||
):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_combo(world["system"], lines)
|
||||
assert e.value.status == 400
|
||||
a = _combo(world["system"], [one], "가")
|
||||
b = _combo(world["system"], [{"참조": {**SYS, "키": a}}], "나")
|
||||
with store.at(world["system"]):
|
||||
have = store.read("precombo", a)
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.write(
|
||||
"precombo",
|
||||
a,
|
||||
have["판"],
|
||||
{**have["문서"], "담은로직": [{"참조": {**SYS, "키": b}}]},
|
||||
)
|
||||
assert e.value.status == 422 and f"{a} → {b} → {a}" in e.value.detail
|
||||
|
||||
|
||||
def test_층_규칙은_위_층만(world):
|
||||
_promote(world["system"])
|
||||
_promote(world["company"])
|
||||
_promote(world["personal"])
|
||||
ok = [{"참조": {**s, "키": "SP-000001"}} for s in (SYS, COMPANY, MINE)]
|
||||
assert _combo(world["personal"], ok) == "SC-000001" # 개인 → 개인 · 회사 · 마스터
|
||||
for folder, bad in (
|
||||
(world["company"], MINE), # 회사 → 개인 X
|
||||
(world["system"], COMPANY), # 마스터 → 회사 X
|
||||
(world["personal"], {"층": "personal", "주인": "7/43"}), # 남의 개인 X
|
||||
(world["personal"], {"층": "company", "주인": "8"}), # 남의 회사 X
|
||||
):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_combo(folder, [{"참조": {**bad, "키": "SP-000001"}}])
|
||||
assert e.value.status == 400 and "위 층만" in e.value.detail
|
||||
|
||||
|
||||
def test_일위대가_참조_줄_정리_같은_참조_두_번_400(world):
|
||||
sp = _promote(world["system"])
|
||||
line = {"참조": {**SYS, "키": sp}, "별칭": " 승격 ", "수량": {"손": 3}}
|
||||
with store.at(world["system"]):
|
||||
got = labor.clean({"열": "rv", "담은로직": [line]})["담은로직"]
|
||||
assert got == [{"참조": {**SYS, "키": sp}, "별칭": "승격", "메모": None, "수량": {"손": 3}}]
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
labor.clean({"열": "rv", "담은로직": [line, line]})
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
# ── 펼침(계산) ─────────────────────────────────────────────────────────
|
||||
def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합은_수량_곱(world):
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}], "둘")
|
||||
ref = {**SYS, "키": sc}
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]})
|
||||
got = labor.calc(doc, {})
|
||||
one = got["줄"][0]
|
||||
want = Decimal(str(_base(TEN)["계"])) / 10 * 2
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref))
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 5))
|
||||
assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp
|
||||
pills = labor.pills(doc)
|
||||
assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}]
|
||||
|
||||
|
||||
def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world):
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "하나")
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": k}} for k in (sp, sc)]})
|
||||
promote, combo = labor.calc(doc, {})["줄"]
|
||||
assert promote["원로직"] == TEN and promote["줄"]
|
||||
assert sum(x["금액"] for x in promote["줄"]) == pytest.approx(float(_base(TEN)["계"]))
|
||||
assert "줄" not in combo and [x["참조"]["키"] for x in combo["안"]] == [sp]
|
||||
|
||||
|
||||
def test_펼침은_순환_깊이_없는_참조를_까닭으로_멈춤(world):
|
||||
folder = world["system"] / "precombo"
|
||||
for key, child in (("SC-000001", "SC-000002"), ("SC-000002", "SC-000001")):
|
||||
body = {"이름": key, "담은로직": [{"참조": {**SYS, "키": child}}]}
|
||||
(folder / f"{key}.json").write_text(json.dumps(body), encoding="utf-8")
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
expand = refs.Expand(m01.loaded()[1])
|
||||
got = expand({**SYS, "키": "SC-000001"})
|
||||
assert got["기준"] is None and "순환" in got["까닭"]
|
||||
assert "없는 참조" in expand({**SYS, "키": "SC-000009"})["까닭"]
|
||||
deep = refs.Expand(
|
||||
m01.loaded()[1], lambda r: {"담은로직": [{"참조": {**SYS, "키": "SC-000001"}}]}
|
||||
)
|
||||
assert deep.__call__({**SYS, "키": "SC-000001"}, ())["기준"] is None
|
||||
|
||||
|
||||
def test_돈_아닌_승격은_까닭(world):
|
||||
sp = _promote(world["system"], PLAIN)
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
got = refs.Expand(m01.loaded()[1])({**SYS, "키": sp})
|
||||
assert got["기준"] is None and got["까닭"]
|
||||
|
||||
|
||||
def test_참조_줄_settle_파이썬과_TS_가_같은_답(world):
|
||||
sp = _promote(world["system"])
|
||||
ref = {**SYS, "키": sp}
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean(
|
||||
{"열": "rv", "담은로직": [{"로직": STONE, "수량": None}, {"참조": ref, "수량": None}]}
|
||||
)
|
||||
answer = labor.calc(doc, {})
|
||||
values = {refs.ref_id(ref): 4}
|
||||
py = labor.settle(answer, doc, values)
|
||||
assert py["줄"][1]["수량출처"] == "칸"
|
||||
_same(py, _ts(answer, doc, values))
|
||||
|
||||
|
||||
# ── 지우기 · 복사 · 굳힘 · 로직 지우기 ─────────────────────────────────
|
||||
def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world):
|
||||
sp = _promote(world["system"])
|
||||
with store.at(world["personal"]):
|
||||
line = {"참조": {**SYS, "키": sp}}
|
||||
store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []})
|
||||
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
|
||||
store.delete("promote", sp)
|
||||
assert e.value.status == 409 and "개인 7/42" in e.value.detail
|
||||
(world["personal"] / "labor" / "x1.json").unlink()
|
||||
with store.at(world["system"]):
|
||||
store.delete("promote", sp)
|
||||
assert not (world["system"] / "promote" / f"{sp}.json").exists()
|
||||
|
||||
|
||||
def test_복사는_못_읽는_참조를_같이_복사_본뜬(world):
|
||||
sp = _promote(world["personal"])
|
||||
sys_sp = _promote(world["system"])
|
||||
sc = _combo(
|
||||
world["personal"],
|
||||
[{"참조": {**MINE, "키": sp}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}],
|
||||
)
|
||||
with store.at(world["personal"]):
|
||||
doc = store.read("precombo", sc)["문서"]
|
||||
with store.at(world["company"]):
|
||||
made = refs.create(
|
||||
"precombo", refs.carry({**doc, "본뜬": {**MINE, "키": sc}}, ("personal", "7/42"))
|
||||
)
|
||||
got = _read(world["company"], "precombo", made["이름"])
|
||||
assert got["본뜬"] == {**MINE, "키": sc}
|
||||
assert [one["참조"] for one in got["담은로직"]] == [
|
||||
{**COMPANY, "키": "SP-000001"}, # 회사가 못 읽는 개인 승격 → 회사 층 새 키
|
||||
{**SYS, "키": sys_sp}, # 마스터는 그대로
|
||||
]
|
||||
assert _read(world["company"], "promote", "SP-000001")["본뜬"] == {**MINE, "키": sp}
|
||||
|
||||
|
||||
def test_길_새로_복사_권한(world):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Labor_Router as labor_router
|
||||
|
||||
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,
|
||||
}
|
||||
client = TestClient(app)
|
||||
url = "/api/m02/layers/{}/library/{}"
|
||||
body = {"문서": {"담은로직": [{"로직": TEN}]}}
|
||||
got = client.post(url.format("personal", "promote"), json=body).json()
|
||||
assert got["이름"] == "SP-000001"
|
||||
assert client.post(url.format("system", "promote"), json=body).status_code == 403
|
||||
copy = {"본뜬": {**MINE, "키": "SP-000001"}}
|
||||
assert client.post(url.format("personal", "promote"), json=copy).json()["이름"] == "SP-000002"
|
||||
other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SP-000001"}}
|
||||
assert client.post(url.format("personal", "promote"), json=other).status_code == 403
|
||||
|
||||
|
||||
def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_값(world):
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Source as source
|
||||
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}])
|
||||
ref = {**SYS, "키": sc}
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]})
|
||||
live = labor.calc(doc, {})["줄"][0]["기준"]["계"]
|
||||
rows = source.labor_doc_rows(doc)
|
||||
assert set(rows["참조사본"]) == {refs.ref_id(ref), refs.ref_id({**SYS, "키": sp})}
|
||||
assert refs.frozen_logics(rows["참조사본"]) == [TEN]
|
||||
bundle = prices.to_json(prices.collect([TEN]))
|
||||
for key in (sc, sp): # 원본이 사라져도 프로젝트는 사본만 봄
|
||||
kind = refs.kind_of(key)
|
||||
(world["system"] / kind / f"{key}.json").unlink()
|
||||
got = prices.calc(rows, bundle, {}, {}, None)
|
||||
line = got["줄"][0]
|
||||
assert line["ok"] and line["참조"] == ref
|
||||
assert line["계"] == pytest.approx(live, rel=1e-9)
|
||||
assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9)
|
||||
|
||||
|
||||
def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너뜀(world):
|
||||
from M01_MasterData import M01_MasterData_Store_Cache as cache
|
||||
|
||||
sp = _promote(world["system"], STONE)
|
||||
with store.at(world["system"]):
|
||||
have = store.read("labor", "rv")
|
||||
line = {"참조": {**SYS, "키": sp}}
|
||||
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]})
|
||||
|
||||
class Gone: # STONE 을 지운 마스터
|
||||
def logic(self, code):
|
||||
if code == STONE:
|
||||
raise cache.cm.mf.FormulaError("없음")
|
||||
|
||||
got = cache.labor_errors(Gone())
|
||||
assert got == [f"단가승격 마스터 「{sp}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"]
|
||||
|
||||
|
||||
def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path):
|
||||
_promote(world["system"])
|
||||
got = layers.seed_project(tmp_path / "p1")
|
||||
assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"])
|
||||
@@ -46,7 +46,7 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음():
|
||||
assert "buildAllItems" not in page and "onAll" not in page and "ALL_TITLE" not in page
|
||||
assert "buildAllItems" not in _text("M02_MasterTemplete_ItemList.ts")
|
||||
api = _text("M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert '| "labor";' in api and "일위대가번: string;" in api
|
||||
assert '| "labor" |' in api and "일위대가번: string;" in api
|
||||
locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8")
|
||||
for gone in ("M02_ItemsAll", "M02_KindLabor", "M02_KindStructure", "M02_KindArea"):
|
||||
assert gone not in locale, gone
|
||||
@@ -57,7 +57,9 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음():
|
||||
|
||||
def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
|
||||
# 구조물 조합 줄 아래 하나 + 일위대가 조합 서고(50-3) 하나
|
||||
assert page.count('layerBar("') == 2
|
||||
assert 'layerBar("일위대가조합", null, () => undefined)' in page
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
|
||||
assert 'query.get("root") ?? query.get("layer")' in layer
|
||||
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
|
||||
assert '["구조물조합", "옵션", "도면"]' in layer # 일위대가 = 구조물 조합 안(44-7)
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3
|
||||
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* 50-1 면적 시트 「도면으로 보낸 범위」 읽기(`sentRanges`) — orca eval 한 번(서버 쓰기 없음 · 가짜 응답).
|
||||
* 시작 화면: 로그인한 M02(#/m02-master-template?open=구조물조합&root=master) 아무 화면.
|
||||
* 하는 일: 도면 읽기 길만 가짜 연결표(T1 · T3)로 바꿔 부름 → 그 답 · 실패 때(500) 빈 목록 → fetch 되돌림.
|
||||
* 기대(out): rows = 2 · tag = ["T1 도면","T3 도면"] · tip0 = 「도면 표 T1 로 보낸 범위 A1:B2 · 글자 2.5」 ·
|
||||
* sheet0 = 가짜 시트 id · failed = [] .
|
||||
*/
|
||||
(async () => {
|
||||
const mod =
|
||||
await import("/@fs/C:/Aislo_wt/sub_laptop_3/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts");
|
||||
const real = window.fetch;
|
||||
const at = { layer: "system", projectId: null, kind: "area", name: "item1", label: "item1" };
|
||||
const out = {};
|
||||
try {
|
||||
window.fetch = async (url, init) => {
|
||||
if (!String(url).includes("/templates/structure/item1")) return real(url, init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
종류: "structure",
|
||||
이름: "item1",
|
||||
판: "1",
|
||||
문서: {
|
||||
연결표: [
|
||||
{ 연결: "T1", 시트: "s9", 범위: "A1:B2", 글자크기: 2.5, 값: {} },
|
||||
{ 연결: "T3", 시트: "s9", 범위: "C3:D5", 글자크기: 3, 값: {} },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
const rows = await mod.sentRanges(at);
|
||||
out.rows = rows.length;
|
||||
out.tag = rows.map((r) => r.이름);
|
||||
out.tip0 = rows[0]?.설명;
|
||||
out.sheet0 = rows[0]?.시트;
|
||||
window.fetch = async () => new Response("{}", { status: 500 });
|
||||
out.failed = await mod.sentRanges(at);
|
||||
} finally {
|
||||
window.fetch = real;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
/* 50-1 면적산출 알약 · 칸 · 보낸 범위 표시 — orca eval 한 번에 도는 시나리오(저장 · 서버 없음).
|
||||
* 시작 화면: /@fs/C:/Aislo_wt/sub_laptop_3/resources/tester/spreadsheet/harness_links.html?ghost=1&range=1 (알약 L · H · A · 연결 X=B1 · L=B2 · 범위 A1:B2).
|
||||
* 하는 일: ① 알약 줄 ② 보낸 범위 상자 ③ 칸 B1 · B2 를 눌러 그 칸 알약 강조 ④ X 의 [×] 로 연결 풀기
|
||||
* 기대(out): orphan = X 만 ⚠ · 목록 알약 L 은 아님 · rangeTag "T1 도면" · rangeFill 투명 · rangeDashed true ·
|
||||
* hereB1 = ["X"] · hereB2 = ["L"] · popOnce true(칸이 바뀐 첫 그림만) · xGone true.
|
||||
*/
|
||||
(async () => {
|
||||
const W = (t) => new Promise((r) => setTimeout(r, t));
|
||||
const q = (s) => document.querySelector(s);
|
||||
const all = (s) => [...document.querySelectorAll(s)];
|
||||
const click = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const at = {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
};
|
||||
const t = document.elementFromPoint(at.clientX, at.clientY) ?? el;
|
||||
for (const type of ["mousedown", "mouseup", "click"]) t.dispatchEvent(new MouseEvent(type, at));
|
||||
};
|
||||
await W(800);
|
||||
const out = {};
|
||||
const pill = (k) => q(`.ss-link[data-key="${k}"]`);
|
||||
out.orphan = all(".ss-link.is-orphan").map((b) => b.dataset.key);
|
||||
out.xText = pill("X")?.textContent;
|
||||
out.lOrphan = pill("L")?.classList.contains("is-orphan");
|
||||
const range = q(".ss-link-range");
|
||||
const cs = range && getComputedStyle(range);
|
||||
out.rangeTag = range?.querySelector(".ss-link-range__tag")?.textContent;
|
||||
out.rangeFill = cs?.backgroundColor;
|
||||
out.rangeDashed = cs?.borderTopStyle === "dashed";
|
||||
out.rangeSize = range && [Math.round(range.offsetWidth), Math.round(range.offsetHeight)];
|
||||
const byKey = (k) => all(".ss-link-cell").find((t) => t.dataset.key === k); // 칸 표시는 고를 때마다 새로 얹힘
|
||||
out.cellOrphan = byKey("X")?.classList.contains("is-orphan");
|
||||
const here = () => all(".ss-link.is-here").map((b) => b.dataset.key);
|
||||
click(byKey("X")); // B1
|
||||
await W(300);
|
||||
out.hereB1 = here();
|
||||
out.popOnce = all(".ss-link.is-pop").length === 1;
|
||||
click(byKey("L")); // B2
|
||||
await W(300);
|
||||
out.hereB2 = here();
|
||||
const doc0 = window.ss.getDoc();
|
||||
const n0 = doc0.시트[0].연결.length;
|
||||
click(pill("X").querySelector(".ss-link__x"));
|
||||
await W(300);
|
||||
const n1 = window.ss.getDoc().시트[0].연결.length;
|
||||
out.xGone = n1 === n0 - 1 && !pill("X");
|
||||
out.xCell = !all(".ss-link-cell").some((t) => t.dataset.key === "X");
|
||||
return out;
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
/* 50-2 원문 패널 — 마크다운 기호 벗김 · 출처 항 · 주 강조 · `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
|
||||
*
|
||||
* 시작 화면: M01 마스터 데이터(#/m01-master-data · 시스템 관리자 로그인) · 아무 화면이나.
|
||||
* 로직 GF000003(산림품셈 3-4-2 1) · GF000094(산림품셈 9-7-1 주⑥)을 엶 · 고치지 않음 · 패널 취향을 처음 값으로 되돌림.
|
||||
* 기대 수치:
|
||||
* item1 = {head:"3-4-2. …", file:"1항", hit:12, first:"1. 단가산출 방법은 …", nextNotHit:true, bad:0(역슬래시 · 「<!--」 · 「>」 머리 글 수), inView:true}
|
||||
* note6 = {file:"주⑥", hit:1, first:"⑥ 굴착기의 작업능력 …", nextNotHit:true, bad:0, inView:true}
|
||||
*/
|
||||
(async () => {
|
||||
const W = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
||||
const until = async (f, ms = 15000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
const v = f();
|
||||
if (v) return v;
|
||||
await W(200);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const PREF = "aislo:pref:m01-logic-side";
|
||||
const first = localStorage.getItem(PREF);
|
||||
const out = {};
|
||||
const openLogic = async (key) => {
|
||||
sessionStorage.setItem("m01.logic.pending", key);
|
||||
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
|
||||
if (!tab) return false;
|
||||
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
|
||||
tab.click();
|
||||
return until(() => {
|
||||
const t = $(".m02-dfp__title")?.innerText ?? "";
|
||||
return !$(".m01lab-side")?.hidden && $(".m01lab-side .m02-dfp") && t;
|
||||
});
|
||||
};
|
||||
const shown = (n) => !!n && n.getBoundingClientRect().width > 0;
|
||||
const panel = (id) => $(`.m01lab-side .ui-side-panels__panel[data-panel="${id}"]`);
|
||||
const strip = (title) =>
|
||||
$$(".m01lab-side .ui-side-panels__handles > *").find(
|
||||
(s) => s.innerText.replace(/\s/g, "") === title,
|
||||
);
|
||||
const inView = (n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0;
|
||||
};
|
||||
const read = async (key, wantFile) => {
|
||||
if (!(await openLogic(key))) return { error: "로직 못 엶" };
|
||||
await W(600);
|
||||
if (!shown(panel("source"))) strip("원문")?.click();
|
||||
await until(() => $$(".m01lab-src__file").some((f) => f.innerText.includes(wantFile)), 6000);
|
||||
await W(600);
|
||||
const ps = $$(".m01lab-src__body > *");
|
||||
const hits = $$(".m01lab-src__hit");
|
||||
const texts = $$(".m01lab-src__p, .m01lab-src__head").map((n) => n.innerText);
|
||||
return {
|
||||
head: $(".m01lab-src__head")?.innerText,
|
||||
file: $(".m01lab-src__file")?.innerText.split(" · ").pop(),
|
||||
hit: hits.length,
|
||||
first: hits[0]?.innerText.slice(0, 20),
|
||||
nextNotHit: !hits.includes(ps[ps.indexOf(hits[hits.length - 1]) + 1]),
|
||||
bad: texts.filter((t) => /\|<!--|^>/.test(t)).length,
|
||||
inView: hits.length > 0 && inView(hits[0]),
|
||||
};
|
||||
};
|
||||
if (!(await openLogic("GF000003"))) return JSON.stringify({ error: "로직 못 엶" });
|
||||
out.item1 = await read("GF000003", "1항");
|
||||
out.note6 = await read("GF000094", "주⑥");
|
||||
// 되돌림 — 처음 취향
|
||||
if (first === null) localStorage.removeItem(PREF);
|
||||
else localStorage.setItem(PREF, first);
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,174 @@
|
||||
/* 50-3 ④ — 일위대가 상세 [로직 추가] 창 탭 단가산출 · 단가승격 · 사전조합 + 층 단추 · 참조 줄 표 · 줄 상세
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
|
||||
* 나무에 item1(구조물 조합 · 일위대가 첫 줄 GC000198)이 있음
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 공유 마스터에 서고 문서를 안 씀 — 이 탭 fetch 만 바꿔 끼움(끝나면 되돌림):
|
||||
* 층 목록 = 가짜 SP-990001 · SC-990001 더함 · calc = 참조 줄 빼고 진짜 셈 + 참조 줄 답(승격 = GC000198 줄 · 사전조합 안 줄 하나)
|
||||
* 하는 일: item1 → 일위대가 [수정] → [로직 추가] 탭 셋 → 단가승격 탭 · 마스터 → SP 고름 → 사전조합 탭 → SC 고름 →
|
||||
* 승격 줄 누름 · 조합 줄 누름 → 두 줄 삭제(저장 안 함) → item1 다시
|
||||
* 기대 수치:
|
||||
* - tabs = ["단가산출","단가승격","사전조합"] · layers = ["마스터"](마스터 층은 위 층 없음) · listed = ["SP-990001"] · ["SC-990001"]
|
||||
* - added.kinds 끝 둘 = ["단가승격","일위조합"] · added.keys 끝 둘 = ["SP-990001","SC-990001"] · 옵션 단추 없음(optBtn = 0)
|
||||
* - promote.from = "원 로직 GC000198" · promote.rows = GC000198 줄 수(4) · promote.sum = first 줄 기준 계
|
||||
* - combo.inner = 1 · combo.innerKind = "단가승격"
|
||||
* - removed = true(원래 줄 수) · askedLeave = false(저장 안 한 고침 없음) · back = true
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0;
|
||||
const SP = { 층: "system", 주인: null, 키: "SP-990001" };
|
||||
const SC = { 층: "system", 주인: null, 키: "SC-990001" };
|
||||
const rid = (r) => `참조|${r.층}|${r.주인 ?? ""}|${r.키}`;
|
||||
const real = window.fetch;
|
||||
let first = null;
|
||||
window.fetch = async (url, init) => {
|
||||
const u = String(url);
|
||||
if (/\/layers\/system\/templates$/.test(u)) {
|
||||
const got = await (await real(url, init)).json();
|
||||
got.양식.push(
|
||||
{ 종류: "promote", 이름: SP.키, 제목: "시험 승격", 단위: "㎡" },
|
||||
{ 종류: "precombo", 이름: SC.키, 제목: "시험 조합", 단위: "식" },
|
||||
);
|
||||
return new Response(JSON.stringify(got), { status: 200 });
|
||||
}
|
||||
if (/\/labor\/[^/]+\/calc/.test(u) && init?.body) {
|
||||
const body = JSON.parse(init.body);
|
||||
const refs = (body.doc?.담은로직 ?? []).filter((r) => r.참조);
|
||||
if (!refs.length) return real(url, init);
|
||||
body.doc.담은로직 = body.doc.담은로직.filter((r) => !r.참조);
|
||||
const got = await (await real(url, { ...init, body: JSON.stringify(body) })).json();
|
||||
first = got.줄[0];
|
||||
for (const r of refs) {
|
||||
const base = { ...first.기준 };
|
||||
const line = {
|
||||
참조: r.참조,
|
||||
이름: r.참조.키 === SP.키 ? "시험 승격" : "시험 조합",
|
||||
알약: rid(r.참조),
|
||||
분모: "㎡",
|
||||
배: 1,
|
||||
기준: base,
|
||||
반영: null,
|
||||
수량: null,
|
||||
수량출처: "없음",
|
||||
까닭: null,
|
||||
};
|
||||
if (r.참조.키 === SP.키) Object.assign(line, { 원로직: first.로직, 줄: first.줄 });
|
||||
else
|
||||
line.안 = [{ 참조: SP, 수량: 1, 이름: "시험 승격", 단위: "㎡", 기준: base, 까닭: null }];
|
||||
got.줄.push(line);
|
||||
}
|
||||
return new Response(JSON.stringify(got), { status: 200 });
|
||||
}
|
||||
return real(url, init);
|
||||
};
|
||||
const out = {};
|
||||
try {
|
||||
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
|
||||
if (!node) return JSON.stringify({ error: "item1 없음" });
|
||||
node.click();
|
||||
const card = await until(() =>
|
||||
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
|
||||
c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"),
|
||||
),
|
||||
);
|
||||
if (!card) return JSON.stringify({ error: "일위대가 카드 없음" });
|
||||
[...card.querySelectorAll(".m02-struct__card-tools button")]
|
||||
.find((b) => b.innerText.trim() === "수정")
|
||||
?.click();
|
||||
const table = () =>
|
||||
[...document.querySelectorAll("table.m02-labrow")].find(
|
||||
(t) => !t.closest(".m02-struct__card") && t.querySelector("tr[data-labor-logic]"),
|
||||
);
|
||||
await until(() => table() && !/…/.test(table().querySelector("tbody").innerText), 12000);
|
||||
const host = table().closest("[data-labor]");
|
||||
const before = table().querySelectorAll("tbody tr[data-labor-logic]").length;
|
||||
const add = async (tabLabel, key) => {
|
||||
[...host.querySelectorAll(".m01-logic__section-head button")]
|
||||
.find((b) => b.innerText.trim() === "로직 추가")
|
||||
.click();
|
||||
const dlg = await until(
|
||||
() =>
|
||||
document.querySelector(".m01-logic__pick [data-pick-tabs] button") &&
|
||||
document.querySelector(".m01-logic__pick"),
|
||||
);
|
||||
const tabs = [...dlg.querySelectorAll("[data-pick-tabs] button")];
|
||||
out.tabs = tabs.map((b) => b.innerText.trim());
|
||||
tabs.find((b) => b.innerText.trim() === tabLabel).click();
|
||||
await until(() => dlg.querySelector(`[data-pick="${key}"]`));
|
||||
out.layers = [...dlg.querySelectorAll("[data-pick-layers] button")].map((b) =>
|
||||
b.innerText.trim(),
|
||||
);
|
||||
(out.listed ??= []).push([...dlg.querySelectorAll("[data-pick]")].map((b) => b.dataset.pick));
|
||||
dlg.querySelector(`[data-pick="${key}"]`).click();
|
||||
await until(
|
||||
() =>
|
||||
table()?.querySelector(`tr[data-labor-logic="${rid({ ...SP, 키: key })}"]`) &&
|
||||
!/…/.test(table().querySelector("tbody").innerText),
|
||||
12000,
|
||||
);
|
||||
};
|
||||
await add("단가승격", SP.키);
|
||||
await add("사전조합", SC.키);
|
||||
const rows = [...table().querySelectorAll("tbody tr[data-labor-logic]")];
|
||||
out.added = {
|
||||
kinds: rows.slice(-2).map((r) => r.querySelector(".m02-labrow__kind").innerText.trim()),
|
||||
keys: rows.slice(-2).map((r) => r.querySelector(".m02-labrow__key").innerText.trim()),
|
||||
optBtn: rows
|
||||
.slice(-2)
|
||||
.flatMap((r) =>
|
||||
[...r.querySelectorAll("button")].filter((b) => b.innerText.trim() === "옵션"),
|
||||
).length,
|
||||
};
|
||||
const open = async (ref) => {
|
||||
table()
|
||||
.querySelector(`tr[data-labor-logic="${rid(ref)}"] .m02-labrow__name`)
|
||||
.click();
|
||||
await w(300);
|
||||
return host.querySelector("[data-labor-linedet]");
|
||||
};
|
||||
let det = await open(SP);
|
||||
const grid = det.querySelector("table.m01-logic__grid");
|
||||
out.promote = {
|
||||
from: det.querySelector("p")?.innerText.trim(),
|
||||
rows: grid?.querySelectorAll("tbody tr").length ?? 0,
|
||||
sum: [...(grid?.querySelectorAll("tbody tr") ?? [])].reduce(
|
||||
(a, r) => a + n(r.lastElementChild.innerText),
|
||||
0,
|
||||
),
|
||||
firstBase: first?.기준?.계 ?? null,
|
||||
};
|
||||
det = await open(SC);
|
||||
const inner = det.querySelector("[data-labor-inner]");
|
||||
out.combo = {
|
||||
inner: inner?.querySelectorAll("tbody tr").length ?? 0,
|
||||
innerKind: inner?.querySelector("tbody td")?.innerText.trim(),
|
||||
};
|
||||
await open(SC); /* 닫음 */
|
||||
for (const ref of [SC, SP]) {
|
||||
const tr = table().querySelector(`tr[data-labor-logic="${rid(ref)}"]`);
|
||||
[...tr.querySelectorAll("button")].find((b) => b.innerText.trim() === "삭제").click();
|
||||
await w(600);
|
||||
}
|
||||
out.removed = table().querySelectorAll("tbody tr[data-labor-logic]").length === before;
|
||||
} finally {
|
||||
window.fetch = real;
|
||||
}
|
||||
document.querySelector('[data-node="i|item1"]')?.click();
|
||||
await w(800);
|
||||
const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((b) =>
|
||||
/확인|버림|떠나기/.test(b.innerText),
|
||||
);
|
||||
out.askedLeave = !!ask; /* 저장 안 한 고침이 남았으면 물음 — 지운 뒤라 없어야 함 */
|
||||
ask?.click();
|
||||
await w(1200);
|
||||
out.back = !!document.querySelector(".m02-struct__card table.m02-labrow");
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,97 @@
|
||||
/* 50-3 ③ — M02 좌측 「일위대가 조합」 서고 컨테이너 · 상세 서고 모드
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 쓰기 없음 — 새로 창은 열고 닫기만 · 상세 모드는 떼어 둔 칸에 가짜 문서로 붙여 봄(서버 calc 만 부름)
|
||||
* 기대 수치:
|
||||
* - side.order = ["구조물 조합", "일위대가 조합", …] · side.open = true(진입 open=일위대가조합 이 펼침)
|
||||
* - side.layerButtons ≥ 1 · side.subs = ["promote", "precombo"] · side.newButtons = 2(관리자)
|
||||
* - picker.opened = true(단가승격 [새로] = 로직 고르기 창) · precomboDialog = { opened: true, closed: true }
|
||||
* - detail.promote = { name: true, mats: false, diagram: false, add: false, rows: 1, source: true }
|
||||
* - detail.precombo = { name: true, mats: false, diagram: false, add: false, rows: 0 }
|
||||
*/
|
||||
(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const sections = [...document.querySelectorAll(".m02-side__lists > section")];
|
||||
const title = (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() ?? "";
|
||||
const lib = sections.find((s) => title(s) === "일위대가 조합");
|
||||
const side = {
|
||||
order: sections.map(title),
|
||||
open: !!lib && !lib.classList.contains("is-collapsed"),
|
||||
layerButtons: lib?.querySelectorAll(".m02-layers__btn").length ?? 0,
|
||||
subs: [...(lib?.querySelectorAll("[data-precombo]") ?? [])].map((x) => x.dataset.precombo),
|
||||
newButtons: [...(lib?.querySelectorAll(".m02-side__row") ?? [])].length,
|
||||
rows: lib?.querySelectorAll("[data-precombo-key]").length ?? 0,
|
||||
};
|
||||
const btn = (kind, i) =>
|
||||
lib?.querySelector(`[data-precombo="${kind}"] .m02-side__row`)?.querySelectorAll("button")[i];
|
||||
// 단가승격 [새로] = 로직 고르기 창 → 닫기
|
||||
btn("promote", 0)?.click();
|
||||
await wait(300);
|
||||
const pick = document.querySelector(".m01-logic__pick");
|
||||
const picker = { opened: !!pick };
|
||||
pick?.closest(".m01-logic__backdrop")?.remove();
|
||||
// 사전조합 [새로] = 이름 창 → 닫기(Escape)
|
||||
btn("precombo", 0)?.click();
|
||||
await wait(300);
|
||||
const modal = document.querySelector(".m02-modal");
|
||||
const precomboDialog = { opened: !!modal };
|
||||
modal?.querySelector(".ui-modal__top button")?.click(); // [닫기]
|
||||
await wait(200);
|
||||
|
||||
// 상세 서고 모드 — 떼어 둔 칸(화면 밖)에 가짜 문서
|
||||
// 모듈 주소 = 페이지가 받은 UI_Side 주소에서 파일 이름만 바꿈(vite 뿌리 밖 `/@fs/…`)
|
||||
const sideUrl = performance
|
||||
.getEntriesByType("resource")
|
||||
.map((e) => e.name)
|
||||
.find((n) => n.includes("M02_MasterTemplete_UI_Side.ts"));
|
||||
const { mountLaborDetail } = await import(
|
||||
sideUrl.replace(/M02_MasterTemplete_UI_Side\.ts.*$/, "M02_MasterTemplete_Labor_Detail.ts")
|
||||
);
|
||||
const probe = async (mode, doc) => {
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;left:-9999px;top:0;width:900px";
|
||||
document.body.append(host);
|
||||
const h = mountLaborDetail(host, doc, {
|
||||
layer: "system",
|
||||
column: doc.키,
|
||||
readOnly: false,
|
||||
onChanged: () => {},
|
||||
goArea: null,
|
||||
mode,
|
||||
});
|
||||
await wait(400);
|
||||
const out = {
|
||||
name: !!host.querySelector('[data-labor-field="이름"] input'),
|
||||
nameValue: host.querySelector('[data-labor-field="이름"] input')?.value ?? null,
|
||||
mats: [...host.querySelectorAll("h3")].some((x) => x.textContent === "자재목록"),
|
||||
diagram: !!host.querySelector(".m02-labor__dg"),
|
||||
add: [...host.querySelectorAll("button")].some((x) => x.textContent === "로직 추가"),
|
||||
rows: host.querySelectorAll("[data-labor-logic]").length,
|
||||
source:
|
||||
!!host.querySelector("[data-labor-source]") || !!host.querySelector(".ui-side-panels"),
|
||||
kind: h.getDoc().종류,
|
||||
keepsColumn: "열" in h.getDoc() || "자재" in h.getDoc(),
|
||||
};
|
||||
h.destroy();
|
||||
host.remove();
|
||||
return out;
|
||||
};
|
||||
const detail = {
|
||||
promote: await probe("promote", {
|
||||
종류: "단가승격",
|
||||
키: "SP-999999",
|
||||
이름: "시험 승격",
|
||||
단위: null,
|
||||
담은로직: [{ 로직: "L-0001", 입력값: {} }],
|
||||
}),
|
||||
precombo: await probe("precombo", {
|
||||
종류: "사전조합",
|
||||
키: "SC-999999",
|
||||
이름: "시험 사전조합",
|
||||
단위: null,
|
||||
담은로직: [],
|
||||
}),
|
||||
};
|
||||
precomboDialog.closed = !document.querySelector(".m02-modal");
|
||||
return JSON.stringify({ side, picker, precomboDialog, detail });
|
||||
})();
|
||||
@@ -0,0 +1,131 @@
|
||||
/* 50-4 — 일위대가 단가산출 로직 표 줄 누름 = 표 아래 그 줄 상세(M01 시험계산 표) · 다시 누르면 닫힘 · 「구분」 열 — 카드 · 상세 두 곳
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
|
||||
* 나무에 item1(구조물 조합 · 일위대가 로직 둘 이상)이 있음
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 하는 일: item1 카드 표 줄 누름 · 다른 줄 · 같은 줄 다시 → 일위대가 카드 [수정]으로 상세 → 같은 차례 → item1 다시(값 안 바뀜)
|
||||
* 기대 수치(카드 · 상세 같음):
|
||||
* - head[1] = "구분" · kinds = 줄마다 "단가산출"
|
||||
* - first.open = 1(상세 상자 하나) · first.rows ≥ 1 · first.sum = first.total = first.base(줄 합 = 상세 계 = 표 기준 합계)
|
||||
* - first.applied = first.appliedRow(상세 반영 계 = 표 반영 합계 · 수량 없으면 둘 다 「—」 = 0)
|
||||
* - second.title 가 둘째 줄 키 · second.open = 1(한 줄만)
|
||||
* - again.open = 0 · again.selected = 0(같은 줄 다시 = 닫힘)
|
||||
* - back = true(카드 화면으로 돌아감)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0;
|
||||
const rowsOf = (t) => [...t.querySelectorAll("tbody tr[data-labor-logic]")];
|
||||
const settled = (get) =>
|
||||
until(() => {
|
||||
const t = get();
|
||||
return t && !/…/.test(t.querySelector("tbody").innerText) ? t : null;
|
||||
}, 10000);
|
||||
/* 상세 상자 읽기 — 표 줄 금액 합 · 기준 계 · 반영 계 */
|
||||
const det = (box) => {
|
||||
const dl = [...box.querySelectorAll("dl.m01-logic__sums")];
|
||||
const total = (d) => {
|
||||
const dt = d && [...d.querySelectorAll("dt")].find((x) => /계|합계|Total/.test(x.innerText));
|
||||
return dt ? n(dt.nextElementSibling.innerText) : null;
|
||||
};
|
||||
const rows = [...box.querySelectorAll("table.m01-logic__grid tbody tr")];
|
||||
return {
|
||||
title: box.querySelector("h4")?.innerText ?? null,
|
||||
rows: rows.length,
|
||||
sum: rows.reduce((a, r) => a + n(r.lastElementChild.innerText), 0),
|
||||
total: total(dl[0]),
|
||||
applied: dl[1] ? total(dl[1]) : "없음",
|
||||
};
|
||||
};
|
||||
const measure = async (get, boxOf) => {
|
||||
const t = await settled(get);
|
||||
const rows = rowsOf(t);
|
||||
const head = [...t.querySelectorAll("thead th")].map((th) => th.innerText.trim());
|
||||
const kinds = rows.map((r) => r.querySelector(".m02-labrow__kind")?.innerText.trim());
|
||||
const click = async (key) => {
|
||||
get().querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__name`).click();
|
||||
await w(300);
|
||||
};
|
||||
const opened = () =>
|
||||
[...boxOf().querySelectorAll("[data-labor-linedet]")].filter((b) => b.childElementCount);
|
||||
const k0 = rows[0].getAttribute("data-labor-logic");
|
||||
const k1 = rows[1]?.getAttribute("data-labor-logic") ?? k0;
|
||||
const sumCell = (key) => {
|
||||
const tds = get().querySelectorAll(`tr[data-labor-logic="${key}"] .m02-labrow__money`);
|
||||
const last = tds[tds.length - 1];
|
||||
return {
|
||||
base: n(last.querySelector(".m02-labrow__base").innerText),
|
||||
applied: n(last.querySelector(".m02-labrow__applied").innerText),
|
||||
};
|
||||
};
|
||||
await click(k0);
|
||||
const d0 = det(opened()[0]);
|
||||
const row0 = sumCell(k0);
|
||||
const first = {
|
||||
key: k0,
|
||||
open: opened().length,
|
||||
...d0,
|
||||
base: row0.base,
|
||||
appliedRow: row0.applied,
|
||||
};
|
||||
await click(k1);
|
||||
const second = {
|
||||
key: k1,
|
||||
open: opened().length,
|
||||
title: opened()[0]?.querySelector("h4")?.innerText,
|
||||
};
|
||||
await click(k1);
|
||||
const again = {
|
||||
open: opened().length,
|
||||
selected: rowsOf(get()).filter((r) => r.classList.contains("is-selected")).length,
|
||||
};
|
||||
return { head, kinds, first, second, again };
|
||||
};
|
||||
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
|
||||
if (!node) return JSON.stringify({ error: "item1 없음 — 폴더를 펼쳐 둘 것" });
|
||||
node.click();
|
||||
const card = await until(() =>
|
||||
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
|
||||
c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"),
|
||||
),
|
||||
);
|
||||
if (!card) return JSON.stringify({ error: "일위대가 카드 표 없음" });
|
||||
await w(1500);
|
||||
const out = {
|
||||
card: await measure(
|
||||
() => card.querySelector("table.m02-labrow"),
|
||||
() => card,
|
||||
),
|
||||
};
|
||||
const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find(
|
||||
(b) => b.innerText.trim() === "수정",
|
||||
);
|
||||
if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" });
|
||||
edit.click();
|
||||
const dt = await until(() =>
|
||||
[...document.querySelectorAll("table.m02-labrow")].find(
|
||||
(t) => !t.closest(".m02-struct__card") && t.querySelector("tbody tr[data-labor-logic]"),
|
||||
),
|
||||
);
|
||||
if (!dt) return JSON.stringify({ ...out, error: "상세 표 없음" });
|
||||
await w(2000);
|
||||
const table = () =>
|
||||
[...document.querySelectorAll("table.m02-labrow")].find((t) => !t.closest(".m02-struct__card"));
|
||||
out.detail = await measure(table, () => table().closest("[data-labor]"));
|
||||
/* 카드 화면으로 돌아감(고름만 했으니 버림 물음 없음 — 뜨면 확인) */
|
||||
document.querySelector('[data-node="i|item1"]')?.click();
|
||||
await w(800);
|
||||
[...document.querySelectorAll(".ui-modal button, dialog button")]
|
||||
.find((b) => /확인|버림|떠나기/.test(b.innerText))
|
||||
?.click();
|
||||
await w(1200);
|
||||
out.back = !!document.querySelector(".m02-struct__card table.m02-labrow");
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user