Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
/* 공용 단가산출 계산서(714-2) — 장비 · 하위 로직 한 줄 = 누르면 펼침 */
|
||||
[data-sheet-equip] {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
[data-sheet-equip]:hover,
|
||||
[data-sheet-equip]:focus-visible {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
[data-sheet-part] .ui-calc-row__label {
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* =============================================================================
|
||||
* calc_sheet.ts
|
||||
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5 · 6) — 704-2 M02 계산서 보기를 올린 것.
|
||||
* 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거
|
||||
* 꼴 둘 —
|
||||
* 비목(기본 · 704-2) = 비목 묶음마다 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
|
||||
* 묶음(`bundle`) = 인력 · 재료 · 경비(장비) · 하위 — 줄의 `묶음`(714-1 서버 덧칸) 으로 다시 묶음 ·
|
||||
* 장비 · 하위 로직 = 한 줄(노 · 재 · 경 칸 · 합) + 누르면 펼침(그 줄의 노 · 재 · 경 식) ·
|
||||
* 묶음 머리 = 이름 · 기준단위 · 환산 근거 · 그 묶음이 쓰는 입력 칸(`쓰는입력`) · 맨 아래 비목 소계 셋 + 계
|
||||
* 값 = 서버 셈 그대로(묶음 소계만 서버 줄 금액의 합) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
|
||||
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 단추 · 표 · 페이지 CSS 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import "./calc_sheet.css";
|
||||
|
||||
export interface SheetLine {
|
||||
이름: string;
|
||||
/** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */
|
||||
글: string;
|
||||
금액: number | null;
|
||||
/** 714-1 서버 덧칸 — 그 줄 값이 쓰는 로직 입력 · 묶음 · 장비 · 찾는 표 기준 · 환산 근거 */
|
||||
쓰는입력?: string[];
|
||||
묶음?: string | null;
|
||||
장비?: boolean | null;
|
||||
기준단위?: string | null;
|
||||
환산근거?: string;
|
||||
}
|
||||
export interface SheetGroup {
|
||||
비목: string;
|
||||
소계: number | null;
|
||||
끝수?: string;
|
||||
줄: SheetLine[];
|
||||
}
|
||||
export interface SheetInput {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
고르기?: (string | number)[];
|
||||
}
|
||||
export interface SheetOptions {
|
||||
묶음: SheetGroup[];
|
||||
계: number | null;
|
||||
/** 맨 아래 계 줄 이름(「계 (원/㎡)」) */
|
||||
sumLabel: string;
|
||||
/** 인력 · 재료 · 경비(장비) 묶음으로 다시 묶음(없으면 서버 비목 묶음 그대로 · 704-2) */
|
||||
bundle?: boolean;
|
||||
/** 변수 칸 — 입력 꼴 · 지금 값 · 바꾼 뒤 할 일(없으면 변수 칸 없음) · 칸 = 묶음 줄들의 `쓰는입력` */
|
||||
inputs?: SheetInput[];
|
||||
values?: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
|
||||
export const sheetFormula = (text: string): string =>
|
||||
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
|
||||
|
||||
/** 장비 · 하위 로직 한 줄 — 비목마다 몫 줄(서버 줄 그대로) · 합 */
|
||||
export interface SheetMerged {
|
||||
이름: string;
|
||||
몫: { 비목: string; 줄: SheetLine }[];
|
||||
금액: number | null;
|
||||
}
|
||||
export interface SheetBundle {
|
||||
묶음: string;
|
||||
줄: (SheetLine | SheetMerged)[];
|
||||
소계: number;
|
||||
쓰는입력: string[];
|
||||
기준단위: string[];
|
||||
환산근거: string[];
|
||||
}
|
||||
|
||||
const ORDER = ["인력", "재료", "경비", "하위"];
|
||||
const OF_COST: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
|
||||
const add = (a: number | null, b: number | null): number | null =>
|
||||
a === null && b === null ? null : (a ?? 0) + (b ?? 0);
|
||||
const merges = (l: SheetLine): boolean => !!l.장비 || l.묶음 === "하위";
|
||||
|
||||
/** 비목 묶음 → 인력 · 재료 · 경비 · 하위 묶음 — 장비 · 하위 줄은 비목마다 갈린 몫을 한 줄로
|
||||
* (같은 이름 k 번째끼리 짝 — 서버 줄 차례 = 호표 차례 · 비목마다 같은 차례) */
|
||||
export function bundleLines(groups: SheetGroup[]): SheetBundle[] {
|
||||
const bundles = new Map<string, SheetBundle>();
|
||||
const merged = new Map<string, SheetMerged>();
|
||||
const take = (name: string): SheetBundle => {
|
||||
let b = bundles.get(name);
|
||||
if (!b) {
|
||||
b = { 묶음: name, 줄: [], 소계: 0, 쓰는입력: [], 기준단위: [], 환산근거: [] };
|
||||
bundles.set(name, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
for (const g of groups) {
|
||||
const seen = new Map<string, number>();
|
||||
for (const line of g.줄) {
|
||||
const b = take(line.묶음 || OF_COST[g.비목] || g.비목);
|
||||
b.소계 += line.금액 ?? 0;
|
||||
for (const n of line.쓰는입력 ?? []) if (!b.쓰는입력.includes(n)) b.쓰는입력.push(n);
|
||||
if (line.기준단위 && !b.기준단위.includes(line.기준단위)) b.기준단위.push(line.기준단위);
|
||||
if (line.환산근거 && !b.환산근거.includes(line.환산근거)) b.환산근거.push(line.환산근거);
|
||||
if (!merges(line)) {
|
||||
b.줄.push(line);
|
||||
continue;
|
||||
}
|
||||
const k = seen.get(line.이름) ?? 0;
|
||||
seen.set(line.이름, k + 1);
|
||||
const id = `${b.묶음}\n${line.이름}\n${k}`;
|
||||
let m = merged.get(id);
|
||||
if (!m) {
|
||||
m = { 이름: line.이름, 몫: [], 금액: null };
|
||||
merged.set(id, m);
|
||||
b.줄.push(m);
|
||||
}
|
||||
m.몫.push({ 비목: g.비목, 줄: line });
|
||||
m.금액 = add(m.금액, line.금액);
|
||||
}
|
||||
}
|
||||
const rank = (x: string): number => (ORDER.includes(x) ? ORDER.indexOf(x) : ORDER.length);
|
||||
return [...bundles.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음));
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
const TEXT = {
|
||||
인력: ["인력", "Labor"],
|
||||
재료: ["재료", "Material"],
|
||||
경비: ["경비(장비)", "Expense (equipment)"],
|
||||
하위: ["하위 로직", "Sub logic"],
|
||||
Basis: ["기준", "Basis"],
|
||||
Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"],
|
||||
Fold: ["누르면 노 · 재 · 경 식 펼침", "Click to expand labor · material · expense"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b);
|
||||
|
||||
const won = (v: number | null, digits = 0): string =>
|
||||
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||||
|
||||
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
|
||||
|
||||
const lineRow = (line: SheetLine, label = line.이름): HTMLElement =>
|
||||
createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1));
|
||||
|
||||
/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */
|
||||
function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
let control: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
control = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: onInput,
|
||||
}).root;
|
||||
} else {
|
||||
const box = createInputField({ value, compact: true });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("change", () => {
|
||||
if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim());
|
||||
});
|
||||
control = box.root;
|
||||
}
|
||||
const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control);
|
||||
row.dataset.sheetVar = spec.이름;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */
|
||||
function mergedRows(m: SheetMerged): HTMLElement[] {
|
||||
const share = (cost: string): number | null =>
|
||||
m.몫.filter((x) => x.비목 === cost).reduce<number | null>((s, x) => add(s, x.줄.금액), null);
|
||||
const short = t("Short")
|
||||
.replace("{a}", won(share("노무비"), 1))
|
||||
.replace("{b}", won(share("재료비"), 1))
|
||||
.replace("{c}", won(share("경비"), 1));
|
||||
const head = createCalcFormulaRow(`▸ ${m.이름}`, createCalcFormula(short), out(m.금액, false, 1));
|
||||
const parts = m.몫.map((x) => lineRow(x.줄, `${x.비목}`));
|
||||
for (const p of parts) {
|
||||
p.hidden = true;
|
||||
p.dataset.sheetPart = m.이름;
|
||||
}
|
||||
head.dataset.sheetEquip = m.이름;
|
||||
head.title = t("Fold");
|
||||
head.tabIndex = 0;
|
||||
head.setAttribute("role", "button");
|
||||
head.setAttribute("aria-expanded", "false");
|
||||
const label = head.firstElementChild as HTMLElement;
|
||||
const toggle = (): void => {
|
||||
const open = head.getAttribute("aria-expanded") !== "true";
|
||||
head.setAttribute("aria-expanded", String(open));
|
||||
label.textContent = `${open ? "▾" : "▸"} ${m.이름}`;
|
||||
parts.forEach((p) => (p.hidden = !open));
|
||||
};
|
||||
head.addEventListener("click", toggle);
|
||||
head.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Enter" && ev.key !== " ") return;
|
||||
ev.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
return [head, ...parts];
|
||||
}
|
||||
|
||||
/** 묶음 머리 — 이름 · 기준 · 환산 근거 · 쓰는 입력 칸 */
|
||||
function headRows(
|
||||
name: string,
|
||||
uses: string[],
|
||||
o: SheetOptions,
|
||||
specs: Map<string, SheetInput>,
|
||||
basis: string[] = [],
|
||||
notes: string[] = [],
|
||||
): HTMLElement[] {
|
||||
const vars = o.onInput ? uses.filter((n) => specs.has(n)) : [];
|
||||
if (!vars.length && !o.bundle) return [];
|
||||
const title = [name, basis.length ? `${t("Basis")} ${basis.join(" · ")}` : ""].filter(Boolean);
|
||||
const head = createText(title.join(" — "), "muted", 14);
|
||||
head.dataset.sheetHead = name;
|
||||
const rows: HTMLElement[] = [head];
|
||||
if (notes.length) rows.push(createText(notes.join(" · "), "hint"));
|
||||
const onInput = o.onInput;
|
||||
if (onInput)
|
||||
for (const n of vars)
|
||||
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
const subtotal = (g: SheetGroup): HTMLElement =>
|
||||
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true));
|
||||
|
||||
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
|
||||
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
|
||||
const rows: HTMLElement[] = [];
|
||||
const specs = new Map((o.inputs ?? []).map((s) => [s.이름, s]));
|
||||
if (o.bundle) {
|
||||
for (const b of bundleLines(o.묶음)) {
|
||||
const name = bundleName(b.묶음);
|
||||
rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거));
|
||||
for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)]));
|
||||
rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1)));
|
||||
}
|
||||
rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐
|
||||
} else {
|
||||
for (const g of o.묶음) {
|
||||
const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))];
|
||||
rows.push(...headRows(g.비목, uses, o, specs));
|
||||
rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g));
|
||||
}
|
||||
}
|
||||
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
|
||||
return rows;
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
|
||||
m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"),
|
||||
m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"),
|
||||
m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"),
|
||||
m02(L("B01_Dashboard_CardSangeun"), "단가산출", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값)
|
||||
precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} ·
|
||||
`단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌)
|
||||
sangeun 단가산출(SK · 내 자료집 · 719-2) = precombo 와 한 꼴(참조사본 = 풀어 굳힌 SK)
|
||||
[업데이트](719-2) = 원본판이 다른 사본만 같은 폴더에 다시 굳힘(`changed` · `refresh_copy` ·
|
||||
값 그대로 · 원본판 없는 옛 사본 = 그대로).
|
||||
사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) ·
|
||||
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
|
||||
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
|
||||
@@ -54,14 +57,15 @@ from M02_MasterTemplete.M02_Item_Source import (
|
||||
labor_doc_rows,
|
||||
price_bundle,
|
||||
read_json,
|
||||
version_of,
|
||||
)
|
||||
|
||||
LIBRARY = "library"
|
||||
KINDS = ("combo", "precombo")
|
||||
KINDS = ("combo", "precombo", "sangeun")
|
||||
#: 폴더 이름 — 종류 · 층(project = 불러온 공사 일위대가 · 707-7) · 주인 ·
|
||||
#: 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음)
|
||||
NAME = re.compile(
|
||||
r"^(combo|precombo)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
|
||||
r"^(combo|precombo|sangeun)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
|
||||
)
|
||||
|
||||
|
||||
@@ -86,11 +90,11 @@ def folder_name(
|
||||
return name
|
||||
|
||||
|
||||
def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
def _precombo_files(layer: str, owner: str | None, key: str, kind: str = "precombo") -> dict:
|
||||
ref = {"층": layer, "주인": owner, "키": key}
|
||||
doc = refs.read_ref(ref)
|
||||
if doc is None or doc.get("종류") != refs.TITLE["precombo"]:
|
||||
raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음")
|
||||
if doc is None or doc.get("종류") != refs.TITLE[kind]:
|
||||
raise LookupError(f"{refs.TITLE[kind]} {key} 이 {layer} 층에 없음")
|
||||
line = {"참조": ref, "수량": {"손": 1}}
|
||||
labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]})
|
||||
return {
|
||||
@@ -100,6 +104,12 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def source_version(files: dict[str, Any]) -> str:
|
||||
"""서고 사본 원본판(719-1) = 일위대가.json 지문 — 굳힌 SC · SK 문서 + 풀어 굳힌 SK 줄(바탕
|
||||
M01 줄 · 바탕판) · 단가는 뺌(단가 바뀜은 [업데이트] 단가 굳힘 몫) · 값 덮기 전에 셈."""
|
||||
return version_of(files.get(LABOR_FILE))
|
||||
|
||||
|
||||
def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]:
|
||||
"""서고 원본 → (사본 파일들, 원본 표지)."""
|
||||
if kind not in KINDS:
|
||||
@@ -109,10 +119,43 @@ def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, di
|
||||
item = {"layer": layer, "owner": owner, "key": key}
|
||||
item["name"] = item_name(item)
|
||||
return build_snapshot(item), origin_of(item)
|
||||
files = _precombo_files(layer, owner, key)
|
||||
files = _precombo_files(layer, owner, key, kind)
|
||||
return files, files.pop("_원본")
|
||||
|
||||
|
||||
def _source_of(manifest: dict[str, Any]) -> tuple[str, str, str | None, str]:
|
||||
"""manifest → (종류, 층, 주인, 키)."""
|
||||
kind, origin = str(manifest.get("종류")), manifest.get("원본") or {}
|
||||
if kind == "combo":
|
||||
return kind, origin.get("layer"), origin.get("owner"), origin.get("key")
|
||||
ref = origin.get("참조") or {}
|
||||
return kind, ref.get("층"), ref.get("주인"), ref.get("키")
|
||||
|
||||
|
||||
def changed(project_root: str | Path) -> list[tuple[Path, dict[str, Any], dict, dict]]:
|
||||
"""원본판이 지금 원본과 다른 사본 `(폴더, manifest, 새 파일들, 새 원본 표지)` — 원본판 없는 옛
|
||||
사본 · 원본 없음 = 뺌(그대로)."""
|
||||
out = []
|
||||
for folder, manifest in copies(project_root):
|
||||
if not manifest.get("원본판"):
|
||||
continue
|
||||
try:
|
||||
files, origin = _build(*_source_of(manifest))
|
||||
except (LookupError, ValueError, KeyError):
|
||||
continue
|
||||
if source_version(files) != manifest["원본판"]:
|
||||
out.append((folder, manifest, files, origin))
|
||||
return out
|
||||
|
||||
|
||||
def refresh_copy(project_root: str | Path, one: tuple) -> None:
|
||||
"""`changed` 한 줄 → 같은 폴더에 다시 굳힘(값 · 가져온이 그대로 · 원본판 새로)."""
|
||||
folder, manifest, files, origin = one
|
||||
keep = {k: v for k, v in manifest.items() if k not in ("값", "원값")}
|
||||
fresh = {**keep, "원본": origin, "원본판": source_version(files), "가져온날": now_text()}
|
||||
_write(project_root, folder.name, files, fresh, manifest.get("값") or {})
|
||||
|
||||
|
||||
# ── 운반 값 사본(263-2) ────────────────────────────────────────────────
|
||||
def _logic(code: str) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -247,7 +290,7 @@ def import_copy(
|
||||
if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict):
|
||||
return {"폴더": name, "만듦": False}
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who}
|
||||
_write(project_root, name, files, manifest, values)
|
||||
_write(project_root, name, files, {**manifest, "원본판": source_version(files)}, values)
|
||||
return {"폴더": name, "만듦": True}
|
||||
|
||||
|
||||
@@ -293,7 +336,8 @@ def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | N
|
||||
got = _build(kind, layer, owner, key)
|
||||
except (LookupError, ValueError):
|
||||
return None
|
||||
got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None})
|
||||
made = {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}
|
||||
got = (got[0], {**made, "원본판": source_version(got[0])})
|
||||
files, manifest = got
|
||||
values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist)
|
||||
name = base + (f"~{tag(values)}" if values else "")
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
|
||||
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
|
||||
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
|
||||
품셈 블록(`P:` · 굳힌 SK 포함 · 719-2) = 개인 층 단가산출(SK · `sangeun`) — 바탕 = 그 M01 로직 ·
|
||||
원본 SK 문서(바탕 · 덮개 · 더한줄 · 뺀줄) · 넣을 값 · 고른 자재(cid) · B09 품 · 단가 덮개 =
|
||||
SK 덮개(같은 cid · 굳힌 SK = `정체` 로 원본 cid · 더한줄 줄 = 그 더한줄 수량 · 단가) ·
|
||||
못 옮긴 칸 = 안담음 수 ·
|
||||
내 자료집 SK 불러오기 = B09 서고 사본 `sangeun`(`Bill_Library`).
|
||||
불러온 공사 일위대가(707-7 · 참조 층 `project`) = 하위 호표 굳힌 문서를 문서 `_하위` 에 실어 보내고
|
||||
쓸 때 지금 층에 같이 씀(새 키 · `본뜬.복사원본` = 그 참조 · 같은 하위는 한 번만 ·
|
||||
이미 있으면 그 키).
|
||||
@@ -16,11 +21,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, COVER_KEYS, with_copies
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
@@ -35,6 +42,8 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||||
|
||||
KIND = "precombo"
|
||||
SK_KIND = "sangeun"
|
||||
SK_KEEP = ("바탕", "입력값", "열린입력", "켜쌓기율", "고름", "자재표시", "덮개", "더한줄", "뺀줄")
|
||||
SUBS = "_하위"
|
||||
PRICE_KEYS = ("labor", "material", "expense")
|
||||
|
||||
@@ -113,8 +122,10 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None =
|
||||
base = cover_key
|
||||
while base.startswith("K:"): # 복사본 → 원본 cover
|
||||
base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "")
|
||||
lines, mats = source(root, blocks.get(base, block), base)
|
||||
mine = cover.get(cover_key) or {}
|
||||
if block["kind"] == "품셈":
|
||||
return _sangeun(root, blocks.get(base, block), mine, name)
|
||||
lines, mats = source(root, blocks.get(base, block), base)
|
||||
kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)}
|
||||
kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines)
|
||||
skip = {"품": 0, "단가": 0}
|
||||
@@ -155,6 +166,70 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None =
|
||||
return {"문서": doc, "담음": kept, "안담음": skip}
|
||||
|
||||
|
||||
def _sangeun(root: Path, block: dict, mine: dict, name: str | None) -> dict[str, Any]:
|
||||
"""품셈 블록 → 개인 층 SK 문서 `{문서, 담음, 안담음}` — 블록 줄 차례 = 엔진 줄 차례 =
|
||||
cid 차례."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
origin = block.get("origin") or {}
|
||||
rid = origin.get("단가산출")
|
||||
if rid: # 굳힌 SK = 원본 문서 위에 · cid = 정체(굳힌 줄 차례 → 원본 cid)
|
||||
frozen = _frozen(root, rid)
|
||||
if frozen is None or not isinstance(frozen.get("원본"), dict):
|
||||
raise LookupError(f"단가산출 원천을 못 찾음 「{rid}」")
|
||||
doc = {k: copy.deepcopy(frozen["원본"][k]) for k in SK_KEEP if k in frozen["원본"]}
|
||||
ids = list(frozen.get("정체") or [])
|
||||
unit = str(frozen["원본"].get("단위") or "")
|
||||
else: # M01 로직 한 줄 = 바탕 · 고름 차례 → cid
|
||||
try:
|
||||
row = m01.loaded()[1].logic(str(origin.get("로직")))
|
||||
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
|
||||
raise LookupError(f"로직을 못 찾음 「{origin.get('로직')}」") from e
|
||||
ids, unit = sd.line_ids(row), sg._unit(row)[0]
|
||||
chosen = (origin.get("고름") or {}).items()
|
||||
picks = {ids[int(n)]: mt for n, mt in chosen if int(n) < len(ids)}
|
||||
doc = {"바탕": {"로직": origin.get("로직")}, "입력값": dict(origin.get("입력값") or {})}
|
||||
doc |= {"고름": picks} if picks else {}
|
||||
if origin.get("켜쌓기율") is not None:
|
||||
doc["켜쌓기율"] = origin["켜쌓기율"]
|
||||
covers, added = doc.setdefault("덮개", {}), doc.setdefault("더한줄", [])
|
||||
kept, skip = {"품": 0, "단가": 0}, {"품": 0, "단가": 0}
|
||||
for n, line in enumerate(block["lines"]):
|
||||
cell = {k: v for k, v in (mine.get(line.get("cid")) or {}).items() if k in COVER_KEYS}
|
||||
cid = ids[n] if n < len(ids) else ""
|
||||
if not cell:
|
||||
continue
|
||||
if cid.startswith("add:#"): # 더한줄 = 그 줄 수량 · 단가(한 비목 줄만)
|
||||
one = added[int(cid[5:])]
|
||||
if "qty" in cell:
|
||||
one["수량"], kept["품"] = cell["qty"], kept["품"] + 1
|
||||
prices = [cell[k] for k in PRICE_KEYS if k in cell]
|
||||
ok = len(prices) == 1 and "참조" not in one and "고정" not in one
|
||||
if ok:
|
||||
one["단가"], kept["단가"] = prices[0], kept["단가"] + 1
|
||||
skip["단가"] += len(prices) - (1 if ok else 0)
|
||||
continue
|
||||
if not cid or ("qty" in cell and doc.get("열린입력")): # 열어 둔 SK = 품 배율만
|
||||
skip["품"] += "qty" in cell
|
||||
cell.pop("qty", None)
|
||||
if not cell:
|
||||
continue
|
||||
if not cid:
|
||||
skip["단가"] += len(cell)
|
||||
continue
|
||||
was = {k: v for k, v in (covers.get(cid) or {}).items() if k != "원값"}
|
||||
if "qty" in cell:
|
||||
was.pop("배율", None)
|
||||
covers[cid] = {**was, **cell}
|
||||
kept["품"] += "qty" in cell
|
||||
kept["단가"] += sum(k in cell for k in PRICE_KEYS)
|
||||
if not covers:
|
||||
doc.pop("덮개")
|
||||
head = {"종류": sd.TITLE, "이름": (name or block.get("name") or "").strip(), "단위": unit}
|
||||
return {"문서": {**head, **doc}, "담음": kept, "안담음": skip}
|
||||
|
||||
|
||||
def _own(lines: Any) -> list[dict[str, Any]]:
|
||||
"""불러온 공사 하위 호표 참조(층 `project` — 층에 없음 · 굳힌 문서뿐)."""
|
||||
return [
|
||||
@@ -211,9 +286,10 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
|
||||
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
|
||||
subs = doc.get(SUBS) or {}
|
||||
doc = {k: v for k, v in doc.items() if k != SUBS}
|
||||
kind = SK_KIND if doc.get("종류") == refs.TITLE[SK_KIND] else KIND
|
||||
same = [
|
||||
path.stem
|
||||
for path in sorted((store.root() / KIND).glob("SC-*.json"))
|
||||
for path in sorted((store.root() / kind).glob(f"{refs.PREFIX[kind]}-*.json"))
|
||||
if (read_json(path) or {}).get("이름") == doc["이름"]
|
||||
]
|
||||
if overwrite and overwrite not in same:
|
||||
@@ -223,6 +299,6 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
|
||||
if subs:
|
||||
doc = {**doc, "담은로직": _carry(doc["담은로직"], subs, {})}
|
||||
if overwrite:
|
||||
old = store.read(KIND, overwrite)
|
||||
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
|
||||
return refs.create(KIND, doc)
|
||||
old = store.read(kind, overwrite)
|
||||
return store.write(kind, overwrite, old["판"], {**doc, "키": overwrite})
|
||||
return refs.create(kind, doc)
|
||||
|
||||
@@ -115,6 +115,14 @@ class Builder:
|
||||
|
||||
# ── 품셈(로직 하나) ─────────────────────────────────────────────────
|
||||
def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None:
|
||||
got = self._poomsem(whole, one, title)
|
||||
if got is None:
|
||||
return None
|
||||
got.pop("_engine"), got.pop("_items")
|
||||
return self.put(got)
|
||||
|
||||
def _poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> dict | None:
|
||||
"""품셈 블록 꼴(넣기 전) — 엔진 줄 · 줄마다 `_n`(엔진 차례 · 단가산출 블록이 씀)."""
|
||||
code = str(one.get("로직") or "")
|
||||
given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름")
|
||||
try:
|
||||
@@ -142,18 +150,73 @@ class Builder:
|
||||
f"{k} {st._word(whole, k, v) if isinstance(v, str) else v}"
|
||||
for k, v in sorted(given.items())
|
||||
)
|
||||
return self.put(
|
||||
{
|
||||
"kind": "품셈",
|
||||
"name": title or logic.get("이름") or code,
|
||||
"spec": shown,
|
||||
"spec_raw": spec,
|
||||
"unit": unit,
|
||||
"cite": logic.get("출처") or "",
|
||||
"origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks},
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"kind": "품셈",
|
||||
"name": title or logic.get("이름") or code,
|
||||
"spec": shown,
|
||||
"spec_raw": spec,
|
||||
"unit": unit,
|
||||
"cite": logic.get("출처") or "",
|
||||
"origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks},
|
||||
"lines": lines,
|
||||
"_engine": engine,
|
||||
"_items": items,
|
||||
}
|
||||
|
||||
# ── 단가산출(SK · 719-2) ────────────────────────────────────────────
|
||||
def sangeun(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str | None:
|
||||
"""굳힌 SK(사본 `단가.json` 로직 줄 · 719-1) = 품셈 블록 꼴 대가 — 셈 = 굳힌 줄 그대로 ·
|
||||
단가산출 속 일위대가 · SK 줄(참조) = 그 대가 블록을 부르는 줄(펼침 · B09 차례 그대로) ·
|
||||
고정 줄 = `fixed` · 단가 고친 줄(덮개 · 더한줄 단가 · 값묶음) = `fixed`(줄 단가 = M02 셈) ·
|
||||
cover = `P:` + SK 이름표(SK 를 고쳐 [업데이트] 해도 B09 덮개 그대로) ·
|
||||
줄 `sk` · `poom`(M02 에서 고친 줄의 품셈 값 = 원본 덮개 `원값`)."""
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as item_sk
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
rid = refs.ref_id(ref)
|
||||
key = str((doc.get("바탕") or {}).get("로직") or "")
|
||||
if item_sk.ROW not in doc or not key:
|
||||
return None # 못 푼 SK(없는 바탕 · 순환) — 부르는 줄이 까닭
|
||||
|
||||
def read(r: dict) -> dict | None:
|
||||
return copies.get(refs.ref_id(r))
|
||||
|
||||
try:
|
||||
got = calc.resolve(doc, whole, read, (rid,))
|
||||
row = {**got["줄"], "키": key, "호표": [dict(x) for x in got["줄"]["호표"]]}
|
||||
if calc._bundles(row, whole, Expand(whole, read), (rid,)):
|
||||
return None
|
||||
except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]}
|
||||
block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]})
|
||||
if block is None:
|
||||
return None
|
||||
ids = list(doc.get("정체") or [])
|
||||
was = {c: v.get("원값") for c, v in ((doc.get("원본") or {}).get("덮개") or {}).items()}
|
||||
lines = []
|
||||
for n, (line, x) in enumerate(zip(block["lines"], block.pop("_engine"))):
|
||||
item = block["_items"][n] if n < len(block["_items"]) else {}
|
||||
qty = _f(x.get("수량"))
|
||||
head = {"name": line.get("name") or "", "spec": "", "unit": line.get("unit") or ""}
|
||||
if isinstance(item.get("참조"), dict) and depth < refs.DEPTH:
|
||||
line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head}
|
||||
elif isinstance(item.get("고정"), dict):
|
||||
line = {**fixed_line(item["고정"], qty), **head}
|
||||
elif qty and ("단가" in item or item.get("값묶음")):
|
||||
own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART}
|
||||
line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}}
|
||||
poom = was.get(ids[n]) if n < len(ids) else None
|
||||
lines.append({**line, "sk": True, **({"poom": poom} if poom else {})})
|
||||
block.pop("_items")
|
||||
name = doc.get("이름") or block["name"]
|
||||
origin = {**block["origin"], "단가산출": rid}
|
||||
cover = f"P:{_digest({'단가산출': rid})}"
|
||||
return self.put({**block, "name": name,
|
||||
"cite": f"단가산출 {ref.get('키')}", "origin": origin,
|
||||
"lines": lines}, cover) # fmt: skip
|
||||
|
||||
def _engine_line(
|
||||
self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None
|
||||
@@ -209,6 +272,17 @@ class Builder:
|
||||
"qty": each,
|
||||
"why": f"없는 참조 「{ref.get('키')}」",
|
||||
}
|
||||
if refs.kind_of(ref.get("키")) == "sangeun": # 단가산출(719-2) = 굳힌 줄 대가
|
||||
bid = self.sangeun(whole, ref, doc, copies, depth + 1)
|
||||
if bid is None:
|
||||
why = "단가산출을 못 셈(바탕 없음 · 순환)"
|
||||
return {"kind": "blank", "name": name, "qty": each, "why": why}
|
||||
from M02_MasterTemplete.M02_Sangeun_Calc import times_of
|
||||
|
||||
block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위
|
||||
per = float(times_of(whole.logic(doc["바탕"]["로직"])))
|
||||
return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"],
|
||||
"qty": None if each is None else each / per} # fmt: skip
|
||||
if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가
|
||||
first = (doc.get("담은로직") or [{}])[0]
|
||||
bid = self.poomsem(whole, first, doc.get("이름") or name)
|
||||
@@ -225,7 +299,7 @@ class Builder:
|
||||
return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"}
|
||||
key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
|
||||
return {"kind": "sangeun", "key": key, "name": name or code,
|
||||
"unit": sg.unit_of(code), "qty": q} # fmt: skip
|
||||
"unit": sg.unit_of(whole, code), "qty": q} # fmt: skip
|
||||
bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name)
|
||||
if bid is None:
|
||||
return {
|
||||
@@ -243,9 +317,11 @@ class Builder:
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
|
||||
kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈
|
||||
pills = {
|
||||
code: pill
|
||||
for pill in combo_store.combo_pills({"담은로직": inner})["물량"]
|
||||
for pill in (kept if isinstance(kept, list) else None)
|
||||
or combo_store.combo_pills({"담은로직": inner})["물량"]
|
||||
for code in pill["로직"]
|
||||
}
|
||||
lines = []
|
||||
@@ -318,7 +394,7 @@ class Builder:
|
||||
|
||||
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
|
||||
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
|
||||
if manifest.get("종류") != "precombo":
|
||||
if manifest.get("종류") not in ("precombo", "sangeun"):
|
||||
return self.combo(folder, f"L:{folder.name}")
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
ref = (manifest.get("원본") or {}).get("참조")
|
||||
@@ -327,6 +403,8 @@ class Builder:
|
||||
if not isinstance(doc, dict):
|
||||
return None
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2)
|
||||
return self.sangeun(whole, ref, doc, copies, 1)
|
||||
return self.precombo(whole, ref, doc, copies, 1)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째
|
||||
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) ·
|
||||
사본 일위대가 자재 · 굳힌 사전조합 자재 코드 · 고정 줄 이음 키도(183-1 · 705-1 · 로직을 안 돌아 `get` 에 안 걸림).
|
||||
굳힌 SK(719-2) = 굳힌 키 로직 줄로 돌림(사본 `단가.json` · M01 안 읽음).
|
||||
고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`).
|
||||
구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음.
|
||||
"""
|
||||
@@ -54,7 +55,9 @@ def used_keys(folder: Path) -> list[str]:
|
||||
# 굳힌 참조사본 안 로직까지
|
||||
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
|
||||
inner = [d for d in (labor.get("참조사본") or {}).values() if isinstance(d, dict)]
|
||||
for one in [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]:
|
||||
sks = [_sk_line(d) for d in inner if d.get("종류") == "단가산출" and "굳힌줄" in d]
|
||||
lines = [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]
|
||||
for one in [*lines, *sks]:
|
||||
# 못 돈 로직 — 부른 데까지만(까닭은 안 봄)
|
||||
if isinstance(one, dict) and one.get("로직"):
|
||||
labor_doc.run_line(
|
||||
@@ -63,12 +66,30 @@ def used_keys(folder: Path) -> list[str]:
|
||||
mats = [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]
|
||||
codes = [str(mat.get("코드") or "") if isinstance(mat, dict) else "" for mat in mats]
|
||||
codes += [k for d in [doc, *inner] for k in fixed.link_keys(d)] # 705-1 고정 줄 이음
|
||||
codes += [ # 굳힌 SK 고정 더한줄 이음(719-2)
|
||||
k
|
||||
for d in inner
|
||||
for x in (d.get("굳힌줄") or {}).get("호표") or []
|
||||
if isinstance(x.get("고정"), dict)
|
||||
for k in fixed.link_keys({"담은로직": [{"고정": x["고정"]}]})
|
||||
]
|
||||
for code in codes:
|
||||
if code[:2] in RESOURCE_TABLES and code not in seen:
|
||||
seen.append(code)
|
||||
return seen
|
||||
|
||||
|
||||
def _sk_line(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""굳힌 SK(719-1) → 로직 줄 꼴 — 굳힌 키(사본 `단가.json` 로직) · 고름 cid → 차례."""
|
||||
from M02_MasterTemplete.M02_Sangeun_Doc import line_ids
|
||||
|
||||
chosen = doc.get("고름") or {}
|
||||
ids = line_ids(doc["굳힌줄"])
|
||||
picks = {str(n): chosen[cid] for n, cid in enumerate(ids) if cid in chosen}
|
||||
one = {"로직": (doc.get("바탕") or {}).get("로직"), "입력값": doc.get("입력값")}
|
||||
return {**one, "켜쌓기율": doc.get("켜쌓기율"), "고름": picks or None}
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
|
||||
겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌)."""
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날)
|
||||
적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) ·
|
||||
덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림).
|
||||
M02 단가산출(SK · 719-2) — 구조물 · 서고 사본의 굳힌 SK 를 원본 층에서 다시 풀어 굳힘(같은 굳힌 키 ·
|
||||
같은 cover → B09 덮개 · 입력값 · 고른 자재 그대로) · 서고 사본도 원본판이 다르면 다시 굳힘 ·
|
||||
미리 보기 `단가산출` = 바뀌는 SK 줄 [{사본, 이름, 키, 판(새 · 바뀜 · 빠짐)}].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,10 +15,13 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COPY
|
||||
from M02_MasterTemplete import M02_Item_Copy as item_copy
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as item_sk
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, linked_snapshots, structures_dir
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, build_source, read_json
|
||||
|
||||
|
||||
def changed_copies(root: str | Path) -> list[dict[str, Any]]:
|
||||
@@ -28,6 +34,41 @@ def changed_copies(root: str | Path) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _sks(labor: Any) -> dict[str, dict]:
|
||||
copies = (labor or {}).get("참조사본") or {}
|
||||
return {rid: doc for rid, doc in copies.items() if item_sk.is_sangeun(doc)}
|
||||
|
||||
|
||||
def sk_lines(old: Any, new: Any, where: str) -> list[dict[str, str]]:
|
||||
"""사본 하나 전후 일위대가.json → 바뀌는 SK 줄(굳힌 줄 · 원본 SK 문서가 다름)."""
|
||||
was, now = _sks(old), _sks(new)
|
||||
out = []
|
||||
for rid in sorted(set(was) | set(now)):
|
||||
a, b = was.get(rid) or {}, now.get(rid) or {}
|
||||
if all(a.get(k) == b.get(k) for k in (item_sk.ROW, "원본")):
|
||||
continue
|
||||
mark = "새" if not a else "빠짐" if not b else "바뀜"
|
||||
out.append({"사본": where, "이름": (b or a).get("이름") or "", "키": rid.rsplit("|")[-1],
|
||||
"판": mark}) # fmt: skip
|
||||
return out
|
||||
|
||||
|
||||
def sk_changes(root: str | Path, copies: list[dict], libs: list[tuple]) -> list[dict[str, str]]:
|
||||
"""다시 가져올 구조물 사본(`changed_copies`) · 서고 사본(`library.changed`)의 바뀌는 SK 줄."""
|
||||
out = []
|
||||
for one in copies:
|
||||
folder = structures_dir(root) / one["폴더"]
|
||||
try:
|
||||
fresh = build_source((read_json(folder / MANIFEST_NAME) or {})["원본"])
|
||||
except (LookupError, ValueError, KeyError):
|
||||
continue
|
||||
out += sk_lines(read_json(folder / LABOR_FILE), fresh.get(LABOR_FILE), one["이름"])
|
||||
for folder, manifest, files, origin in libs:
|
||||
name = str(origin.get("name") or folder.name)
|
||||
out += sk_lines(read_json(folder / LABOR_FILE), files.get(LABOR_FILE), name)
|
||||
return out
|
||||
|
||||
|
||||
def cover_cells(cover: dict[str, Any] | None) -> int:
|
||||
"""고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌."""
|
||||
return sum(
|
||||
@@ -43,6 +84,8 @@ def preview(
|
||||
root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None
|
||||
) -> dict[str, Any]:
|
||||
diff = prices.book_diff(book, prices.refresh_book(keys, book))
|
||||
copies, libs = changed_copies(root), library.changed(root)
|
||||
lib_names = [{"폴더": f.name, "이름": str(o.get("name") or f.name)} for f, _m, _f, o in libs]
|
||||
before = (book or {}).get("labor_edition")
|
||||
made = str((book or {}).get("made") or "")[:10]
|
||||
return {
|
||||
@@ -51,7 +94,8 @@ def preview(
|
||||
"바뀐줄": diff["changed"],
|
||||
"계수": diff["factor"],
|
||||
"환율": diff["fx"],
|
||||
"사본": changed_copies(root),
|
||||
"사본": copies + lib_names,
|
||||
"단가산출": sk_changes(root, copies, libs),
|
||||
"고친칸": cover_cells(cover),
|
||||
"노임판": {
|
||||
"전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"),
|
||||
@@ -61,8 +105,16 @@ def preview(
|
||||
|
||||
|
||||
def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]:
|
||||
"""원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`."""
|
||||
"""원본이 바뀐 연결 사본 재적용 · 서고 사본 다시 굳힘 —
|
||||
`{다시: [이름], 막힘: [이름 — 까닭]}`."""
|
||||
done, stuck = [], []
|
||||
for one in library.changed(root):
|
||||
name = str(one[3].get("name") or one[0].name)
|
||||
try:
|
||||
library.refresh_copy(root, one)
|
||||
done.append(name)
|
||||
except (LookupError, ValueError, KeyError, OSError) as error:
|
||||
stuck.append(f"{name} — {error}")
|
||||
for one in changed_copies(root):
|
||||
try:
|
||||
item_copy.reapply(root, one["폴더"], who)
|
||||
|
||||
@@ -129,6 +129,17 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]:
|
||||
doc = copies.get(refs.ref_id(ref))
|
||||
if not isinstance(doc, dict) or depth >= refs.DEPTH:
|
||||
continue
|
||||
if refs.kind_of(ref.get("키")) == "sangeun": # 굳힌 SK(719-2) = 대가 · 하위 = 산근
|
||||
base = str((doc.get("바탕") or {}).get("로직") or "")
|
||||
if base and "굳힌줄" in doc:
|
||||
logic_line(base, doc.get("입력값"), doc.get("켜쌓기율"), None, True)
|
||||
inner_refs = [
|
||||
{"참조": x["참조"]}
|
||||
for x in doc["굳힌줄"].get("호표") or []
|
||||
if isinstance(x.get("참조"), dict)
|
||||
]
|
||||
walk(inner_refs, True, depth + 1) # 단가산출 속 일위대가 · SK
|
||||
continue
|
||||
if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화
|
||||
first = (doc.get("담은로직") or [None])[0]
|
||||
if isinstance(first, dict) and first.get("로직"):
|
||||
@@ -194,15 +205,23 @@ class _Prices:
|
||||
)
|
||||
|
||||
|
||||
def _unit(code: str) -> tuple[str, Decimal]:
|
||||
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10)."""
|
||||
pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0]
|
||||
return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1))
|
||||
def _unit(row: dict) -> tuple[str, Decimal]:
|
||||
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10) · 알약 규칙(`combo_pills`) 그대로 ·
|
||||
줄 = 사본 단가 묶음 것(M01 다시 안 읽음 · 719-2)."""
|
||||
per = combo_store._PER.findall(str(row.get("결과단위") or ""))
|
||||
if not per:
|
||||
return "", Decimal(1)
|
||||
times = per[0][0].replace(",", "")
|
||||
unit = " · ".join(dict.fromkeys(u for _, u in per))
|
||||
return unit, Decimal(times) if len(per) == 1 and times else Decimal(1)
|
||||
|
||||
|
||||
def unit_of(code: str) -> str:
|
||||
"""산근 1 단위 이름(「원/10㎡」 = ㎡)."""
|
||||
return _unit(code)[0]
|
||||
def unit_of(whole: Any, code: str) -> str:
|
||||
"""산근 1 단위 이름(「원/10㎡」 = ㎡) — `whole` = 사본 단가 묶음."""
|
||||
try:
|
||||
return _unit(whole.logic(code))[0]
|
||||
except mf.FormulaError:
|
||||
return ""
|
||||
|
||||
|
||||
def _env(whole: Any, row: dict, given: dict) -> dict:
|
||||
@@ -242,7 +261,7 @@ def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None:
|
||||
target = None
|
||||
if not target:
|
||||
return None
|
||||
if any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||||
if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1)
|
||||
keys = _captured(whole, node, env)
|
||||
return {"machine": keys[0] if keys else None}
|
||||
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
|
||||
@@ -343,7 +362,7 @@ class Solver:
|
||||
name = str(row.get("이름") or code)
|
||||
if isinstance(row.get("흐름"), dict) or "결과" in row:
|
||||
return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음")
|
||||
unit, times = _unit(code)
|
||||
unit, times = _unit(row)
|
||||
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
|
||||
lines: list[dict[str, Any]] = []
|
||||
try:
|
||||
|
||||
@@ -276,7 +276,7 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic
|
||||
except mf.FormulaError:
|
||||
target = None
|
||||
name = item.get("이름", "")
|
||||
if target and any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||||
if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1)
|
||||
keys = sg._captured(whole, node, env)
|
||||
got = price_of(keys[0]) if keys else None
|
||||
if not got:
|
||||
|
||||
@@ -73,11 +73,15 @@ export function refreshTip(payload: Row | null): string {
|
||||
/** [업데이트] 미리 보기 글 줄(703-3) */
|
||||
export function previewLines(got: Row): string[] {
|
||||
const copies: Row[] = got.사본 || [];
|
||||
const sks: Row[] = got.단가산출 || [];
|
||||
const edition = got.노임판 || {};
|
||||
return [
|
||||
`바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` +
|
||||
`${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
|
||||
`다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
|
||||
`다시 가져올 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
|
||||
...(sks.length
|
||||
? [`M02 단가산출 바뀜 ${sks.length} — ${sks.map((s) => `${s.이름}(${s.판})`).join(" · ")}`]
|
||||
: []),
|
||||
`고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`,
|
||||
`노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`,
|
||||
];
|
||||
|
||||
@@ -319,6 +319,13 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
payload = next;
|
||||
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
|
||||
user = draft ?? next.user ?? {};
|
||||
// 719-2 [업데이트]가 서고 사본을 다시 굳히면 블록 id 가 바뀜 → 사본 폴더(lib)로 다시 이음(짝 `Bill_Library.fix_keys`)
|
||||
const libs: Row = next.inputs?.daega?.libs || {};
|
||||
const stale = ((user.bill?.rows || []) as Row[]).filter(
|
||||
(r) => r?.ref?.tab === "dg" && libs[r.ref.lib] && r.ref.key !== libs[r.ref.lib],
|
||||
);
|
||||
for (const r of stale) r.ref.key = libs[r.ref.lib];
|
||||
if (stale.length && draft && projectId) writeState("b09-estimation", user, projectId);
|
||||
recompute();
|
||||
show(current);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Store.ts
|
||||
* B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사).
|
||||
* B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 단가산출 · 품셈 · 지난 공사).
|
||||
* 단가산출(717-4) = 위 층 M02 SK 목록(`Sangeun_Pick` · 마스터 › 회사 › 개인) · 공사 연결은 아직 없음(목록만)
|
||||
*
|
||||
* 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 ·
|
||||
* 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결)
|
||||
@@ -31,17 +32,19 @@ import {
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet";
|
||||
import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer";
|
||||
import { listSangeun } from "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
||||
|
||||
type Source = "here" | "mine" | "company" | "poomsem" | "past";
|
||||
type Source = "here" | "mine" | "company" | "sangeun" | "poomsem" | "past";
|
||||
const SOURCES: Array<[Source, string]> = [
|
||||
["here", "이 공사"],
|
||||
["mine", "내 것"],
|
||||
["company", "회사"],
|
||||
["sangeun", "단가산출"],
|
||||
["poomsem", "품셈"],
|
||||
["past", "지난 공사"],
|
||||
];
|
||||
@@ -80,11 +83,12 @@ function hereItems(ctx: TabContext): Item[] {
|
||||
return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)];
|
||||
}
|
||||
|
||||
/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */
|
||||
/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) + 단가산출(SK · 719-2) */
|
||||
async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
|
||||
const owner = ownOwner(layer, await fetchSessionUser().catch(() => null));
|
||||
const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer);
|
||||
const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : [];
|
||||
const sks = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "sangeun") : [];
|
||||
return [
|
||||
...combos.map((r) => {
|
||||
const name = itemName(r);
|
||||
@@ -99,15 +103,19 @@ async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
|
||||
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
|
||||
};
|
||||
}),
|
||||
...pres.map((r) => {
|
||||
...[
|
||||
...pres.map((r) => ["precombo", r] as const),
|
||||
...sks.map((r) => ["sangeun", r] as const),
|
||||
].map(([kind, r]) => {
|
||||
const name = r.제목 || r.이름;
|
||||
const pick = { kind: "precombo" as const, layer, owner, key: r.이름 };
|
||||
const tag = kind === "precombo" ? "일위대가" : "단가산출";
|
||||
const pick = { kind, layer, owner, key: r.이름 };
|
||||
return {
|
||||
tag: "일위대가",
|
||||
tag,
|
||||
name,
|
||||
sub: [r.구분, r.상세구분].filter(Boolean).join(" "),
|
||||
unit: String(r.단위 || ""),
|
||||
hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`),
|
||||
hit: has(`${tag} ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`),
|
||||
go: (ctx: TabContext, apply: ApplyRef) =>
|
||||
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
|
||||
};
|
||||
@@ -115,6 +123,28 @@ async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
|
||||
];
|
||||
}
|
||||
|
||||
/** 단가산출(717-4) — 위 층 SK(마스터 › 회사 › 개인) · 공사 연결 길 없음 = 알림만 */
|
||||
async function sangeunItems(): Promise<Item[]> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const own = (["company", "personal"] as const)
|
||||
.map((층) => ({ 층, 주인: ownOwner(층, user) }))
|
||||
.filter((x) => x.주인);
|
||||
const layerText: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
return (await listSangeun([{ 층: "system", 주인: null }, ...own])).map((r) => {
|
||||
const name = r.제목 || r.이름;
|
||||
const sub = [r.구분, r.상세구분].filter(Boolean).join(" ");
|
||||
const money = r.기준금액 === null ? "" : `${Math.round(r.기준금액).toLocaleString("ko-KR")}원`;
|
||||
return {
|
||||
tag: `단가산출 · ${layerText[r.ref.층] ?? r.ref.층}`,
|
||||
name,
|
||||
sub: [sub, money].filter(Boolean).join(" · "),
|
||||
unit: r.단위 ?? "",
|
||||
hit: has(`단가산출 ${name} ${sub}`),
|
||||
go: () => showToast("단가산출은 아직 공사에 바로 연결하지 않음 — 목록 보기만", "info"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 ·
|
||||
* 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */
|
||||
function askStandard(apply: ApplyRef, r: StandardRow): void {
|
||||
@@ -200,6 +230,7 @@ async function pastItems(): Promise<Item[]> {
|
||||
const LOAD: Record<Exclude<Source, "here">, () => Promise<Item[]>> = {
|
||||
mine: () => layerItems("personal"),
|
||||
company: () => layerItems("company"),
|
||||
sangeun: sangeunItems,
|
||||
poomsem: poomsemItems,
|
||||
past: pastItems,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
|
||||
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
|
||||
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
|
||||
* M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`)
|
||||
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
|
||||
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
|
||||
* 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 ·
|
||||
@@ -59,11 +60,20 @@ let open: string | null = null;
|
||||
const plain = (v: unknown): string =>
|
||||
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
|
||||
|
||||
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */
|
||||
/** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */
|
||||
const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값");
|
||||
|
||||
/** 단가산출에서 고친 줄의 품셈 값 곁 글 */
|
||||
const poomText = (line: Row): string =>
|
||||
line.poom ? `품셈 품 ${plain(line.poom.qty)} · 단가 ${plain(line.poom.단가)}` : "";
|
||||
|
||||
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 · 단가산출 줄 = 품셈 값 곁 */
|
||||
function baseText(line: Row): string {
|
||||
const covered: string[] = line.covered || [];
|
||||
if (!covered.length) return "같음";
|
||||
return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
|
||||
const poom = poomText(line);
|
||||
if (!covered.length) return poom ? `단가산출 고침 — ${poom}` : "같음";
|
||||
const base = covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
|
||||
return line.sk ? `단가산출 ${base}${poom ? ` (${poom})` : ""}` : base;
|
||||
}
|
||||
|
||||
function linesOf(block: Row, blocks: Map<string, Row>, level: number, trail: string[]): FoldLine[] {
|
||||
@@ -184,7 +194,9 @@ function foldCard(
|
||||
const at = lines[Number(cell.parentElement?.dataset.i)];
|
||||
const field = FIELD[key];
|
||||
if (!at || !field || !covered(at, field)) return null;
|
||||
return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`;
|
||||
const poom = poomText(at.line);
|
||||
const back = backName(at.line);
|
||||
return `고친 칸 — ${back} ${plain(at.line.base?.[field])}${poom ? ` · ${poom}` : ""} · 우클릭 [${back}으로]`;
|
||||
},
|
||||
"tr[data-i] td",
|
||||
);
|
||||
@@ -192,7 +204,7 @@ function foldCard(
|
||||
const at = lines[i];
|
||||
const undo: MapContextMenuItem[] =
|
||||
at && (at.line.covered || []).length
|
||||
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
|
||||
? [[`${backName(at.line)}으로`, () => clearCovers(ctx, [at])], "-"]
|
||||
: [];
|
||||
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
|
||||
});
|
||||
@@ -200,7 +212,7 @@ function foldCard(
|
||||
...(edited.length
|
||||
? [
|
||||
createButton({
|
||||
label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`,
|
||||
label: `고친 칸 ${edited.length}줄 모두 ${backName((block.lines || []).find((l: Row) => l.sk))}으로`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => clearCovers(ctx, edited),
|
||||
@@ -216,7 +228,7 @@ function foldCard(
|
||||
body: [
|
||||
table.root,
|
||||
createText(
|
||||
"품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [품셈 값으로] · [저장] 때 서버가 다시 셈",
|
||||
`품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [${backName((block.lines || []).find((l: Row) => l.sk))}으로] · [저장] 때 서버가 다시 셈`,
|
||||
"muted",
|
||||
),
|
||||
],
|
||||
|
||||
@@ -87,11 +87,15 @@ async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; g
|
||||
return { ok: response.ok, status: response.status, got };
|
||||
}
|
||||
|
||||
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
|
||||
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 품셈 블록 = 단가산출(SK ·
|
||||
* 품 · 단가 고친 칸 = SK 덮개 · 719-2) · 같은 이름 물음 */
|
||||
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
|
||||
const sk = block.kind === "품셈";
|
||||
let name = await askText({
|
||||
title: "내 자료집에 저장",
|
||||
message: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지",
|
||||
message: sk
|
||||
? "내 자료집 단가산출로 — 옵션 값 · 고른 자재 · 품 · 단가 고친 칸까지"
|
||||
: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지",
|
||||
value: block.name || "",
|
||||
okLabel: "저장",
|
||||
});
|
||||
@@ -104,9 +108,9 @@ async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
|
||||
const skip: Row = got.안담음 || {};
|
||||
const left = (skip.품 || 0) + (skip.단가 || 0);
|
||||
const tail = left
|
||||
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
|
||||
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가})${sk ? "" : " — 품은 고정 줄(PLAN 705) 뒤 담김"}`
|
||||
: "";
|
||||
showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000);
|
||||
showToast(`내 자료집${sk ? " 단가산출" : ""}에 저장 「${got.이름}」${tail}`, "success", 6000);
|
||||
return;
|
||||
}
|
||||
if (status !== 409 || !same) {
|
||||
|
||||
@@ -66,7 +66,7 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row
|
||||
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) ·
|
||||
* lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */
|
||||
export type LibPick = {
|
||||
kind: "combo" | "precombo";
|
||||
kind: "combo" | "precombo" | "sangeun";
|
||||
layer: string;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
@@ -85,7 +85,7 @@ export interface LibLine {
|
||||
/** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */
|
||||
export function parseLib(lib: string): LibPick | null {
|
||||
const [kind, layer, owner, key] = lib.split("~")[0].split("@");
|
||||
if ((kind !== "combo" && kind !== "precombo") || !key) return null;
|
||||
if ((kind !== "combo" && kind !== "precombo" && kind !== "sangeun") || !key) return null;
|
||||
return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
|
||||
export type Row = Record<string, unknown>;
|
||||
@@ -89,8 +90,12 @@ export type SaveResult =
|
||||
| { status: 422; errors: string[] }
|
||||
| { status: number; detail: string };
|
||||
|
||||
async function call<T>(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
async function call<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
base = "/m01",
|
||||
): Promise<{ status: number; body: T }> {
|
||||
const response = await fetch(`${API_BASE_URL}${base}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
@@ -98,9 +103,13 @@ async function call<T>(path: string, init: RequestInit = {}): Promise<{ status:
|
||||
return { status: response.status, body: (await response.json()) as T };
|
||||
}
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
|
||||
async function get<T>(
|
||||
path: string,
|
||||
params: Record<string, string | number>,
|
||||
base = "/m01",
|
||||
): Promise<T> {
|
||||
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
|
||||
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`);
|
||||
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`, {}, base);
|
||||
if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`));
|
||||
return body;
|
||||
}
|
||||
@@ -121,7 +130,11 @@ export const fetchRows = (
|
||||
detail = "",
|
||||
state = "",
|
||||
): Promise<RowsPage> =>
|
||||
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state });
|
||||
get<RowsPage>(
|
||||
"/rows",
|
||||
{ file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state },
|
||||
logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1)
|
||||
);
|
||||
|
||||
/** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */
|
||||
export const fetchSubsAll = (where: {
|
||||
@@ -174,7 +187,8 @@ export interface PickItem {
|
||||
export const fetchPick = (
|
||||
kind: PickKind,
|
||||
q: string,
|
||||
): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 });
|
||||
): Promise<{ total: number; items: PickItem[] }> =>
|
||||
get("/pick", { kind, q, limit: 50 }, logicApiPaths().read);
|
||||
|
||||
/** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */
|
||||
export const searchMaterials = async (
|
||||
|
||||
@@ -18,6 +18,7 @@ from M01_MasterData import M01_MasterData_Store_Flow as flow
|
||||
from M01_MasterData import M01_MasterData_Store_List as store_list
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
from M01_MasterData import M01_MasterData_Store_Sheet as sheet
|
||||
|
||||
import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠)
|
||||
|
||||
@@ -275,7 +276,7 @@ def post_text(body: CalcBody) -> dict:
|
||||
if got is not None:
|
||||
return got
|
||||
with store.cm.mf.mp.choosing(body.고름):
|
||||
return _call(store.text, body.key, body.inputs, body.row, body.file)
|
||||
return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
|
||||
@@ -17,6 +17,7 @@ from config import config_system
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
cm, mk, mcb = store.cm, store.mk, store.mcb
|
||||
import master_unit as mu # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤(712-2)
|
||||
|
||||
FILE = mcb.FILE
|
||||
OWNERS = mcb.OWNERS
|
||||
@@ -150,9 +151,6 @@ def combo(key: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_PER = re.compile(
|
||||
r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)"
|
||||
) # 「원/1,000본」 = 배 1000
|
||||
_TAIL = re.compile(r"\s*\([^()]*\)\s*$")
|
||||
|
||||
|
||||
@@ -177,7 +175,7 @@ def combo_pills(row: dict) -> dict:
|
||||
got = whole.logic(code)
|
||||
except cm.mf.FormulaError:
|
||||
continue
|
||||
per = _PER.findall(str(got.get("결과단위") or ""))
|
||||
per = mu.per(got.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2)
|
||||
if not per:
|
||||
continue
|
||||
# ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름
|
||||
@@ -187,9 +185,9 @@ def combo_pills(row: dict) -> dict:
|
||||
(name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []}
|
||||
)
|
||||
pill["로직"].append(code)
|
||||
times = per[0][0].replace(",", "")
|
||||
if len(per) == 1 and times and float(times) != 1:
|
||||
pill["배"] = float(times) if "." in times else int(times)
|
||||
times = per[0][0]
|
||||
if len(per) == 1 and times != 1:
|
||||
pill["배"] = int(times) if times == times.to_integral_value() else float(times)
|
||||
return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""M01 `/text` 계산서 줄 덧칸(PLAN 714-1) — `store.text` 답에 줄마다 쓰는 입력 · 묶음 · 기준단위 ·
|
||||
환산 근거(`master_sheet_line.annotate` — M02 `/logic-lib/sheet` 와 한 벌) · 서버 단독(③) · 저장 안 함.
|
||||
`M01_MasterData_Store.py` 는 700 줄 근처라 여기서 감쌈."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
mf = store.cm.mf
|
||||
import master_sheet_line as msl # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
|
||||
|
||||
|
||||
def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None) -> dict:
|
||||
got = store.text(key, inputs, row, file)
|
||||
if not isinstance(got, dict) or not got.get("ok"):
|
||||
return got
|
||||
given = {k: store._dec(v) for k, v in inputs.items()}
|
||||
try:
|
||||
if row is None:
|
||||
files, whole = store.loaded()
|
||||
else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본
|
||||
files = store.cm.load(folder=store.FOLDER)
|
||||
key = store._swap(files, str(row.get("키") or key), row, file)
|
||||
whole = mf.Master(files)
|
||||
return msl.annotate(files, key, mf.fill_empty(whole.logic(key), given), got)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
return got
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type LogicFlow,
|
||||
} from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
@@ -98,7 +99,6 @@ const calcRow = (row: LogicRow | null): LogicRow | null => {
|
||||
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
function loadDrafts(): Record<string, Draft> {
|
||||
@@ -256,6 +256,7 @@ export async function mountM01LogicLab(
|
||||
values: current.values,
|
||||
ownHost: current.ownHost,
|
||||
onChange: touch,
|
||||
onInput: (name, v) => syncLeft(current, { [name]: v }, true),
|
||||
calcHost: calc,
|
||||
flowHost: flowOf(current.row) ? current.flowHost : undefined,
|
||||
admin,
|
||||
@@ -331,11 +332,11 @@ export async function mountM01LogicLab(
|
||||
};
|
||||
|
||||
/** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>, loud = false): void => {
|
||||
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
|
||||
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
|
||||
if (!changed.length) return;
|
||||
quiet = true;
|
||||
quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길
|
||||
for (const [k, v] of changed) o.values[k] = v;
|
||||
quiet = false;
|
||||
drawCalc();
|
||||
@@ -501,9 +502,8 @@ export async function mountM01LogicLab(
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶
|
||||
const saved = (await saveFiles([...byFile.values()])) as {
|
||||
added?: { file: string; key: string }[];
|
||||
};
|
||||
const saved = await saveRevised((ok) => saveFiles([...byFile.values()], ok));
|
||||
if (!saved) return; // 개정 확인 취소 = 캐시 그대로
|
||||
const was = opened;
|
||||
drafts = {};
|
||||
await reload();
|
||||
|
||||
@@ -22,7 +22,9 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
|
||||
import {
|
||||
condText,
|
||||
openMaterialModal,
|
||||
@@ -37,7 +39,6 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
@@ -81,6 +82,8 @@ export interface DetailContext {
|
||||
flowHost?: HTMLElement;
|
||||
/** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */
|
||||
admin?: boolean;
|
||||
/** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
@@ -281,7 +284,7 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
open: (self) =>
|
||||
openMaterialModal({
|
||||
unitFrag: self.unitFrag,
|
||||
unitRef: self.unitRef,
|
||||
unitRef: line?.품목?.키 ?? self.unitRef,
|
||||
unitText: self.unitText,
|
||||
qtyFrag: self.piecesFrag,
|
||||
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
||||
@@ -290,6 +293,12 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
|
||||
kind: item.종류,
|
||||
usedSlot: line?.품목?.값칸,
|
||||
...(item.종류 === "재료"
|
||||
? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
return spread(entry, line);
|
||||
@@ -504,7 +513,8 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
/** 상세 화면 넷을 `host` 에 쌓음 */
|
||||
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
const formula = el("div", { className: "m01lab__formula" });
|
||||
buildFormula(formula, { text: ctx.text });
|
||||
const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput };
|
||||
buildFormula(formula, { text: ctx.text, ...fx });
|
||||
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
|
||||
const active = document.activeElement;
|
||||
const kept =
|
||||
@@ -515,7 +525,10 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"),
|
||||
block(
|
||||
section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]),
|
||||
"est",
|
||||
),
|
||||
...(ctx.flowHost
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Est.ts
|
||||
* 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로.
|
||||
* 인력 · 재료 · 경비(장비) 묶음(714-1 줄 덧칸 `묶음`) · 묶음 머리 변수 칸 = 줄들의 `쓰는입력` · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import type { LogicInput } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "./M01_MasterData_UI_Logic_Text";
|
||||
import type { PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export interface EstContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
unit: string;
|
||||
inputs: LogicInput[];
|
||||
values: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function buildEst(ctx: EstContext): HTMLElement {
|
||||
const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } });
|
||||
const text = ctx.text;
|
||||
if (!text || (text.ok && text.계산됨 === false)) {
|
||||
host.append(createText(tl("Formula_Slot"), "muted"));
|
||||
return host;
|
||||
}
|
||||
if (!text.ok) {
|
||||
host.append(createText(safeReason(text.reason), "error"));
|
||||
return host;
|
||||
}
|
||||
host.append(
|
||||
...calcSheetRows({
|
||||
묶음: text.groups,
|
||||
계: text.계,
|
||||
sumLabel: `${tl("Total")} (${ctx.unit || "원"})`,
|
||||
bundle: true,
|
||||
inputs: ctx.inputs,
|
||||
values: ctx.values,
|
||||
onInput: ctx.onInput,
|
||||
}),
|
||||
);
|
||||
return host;
|
||||
}
|
||||
@@ -37,6 +37,18 @@
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */
|
||||
.m01lab-fx__vars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01lab-fx__var .ui-field {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.m01lab-fx,
|
||||
.m01lab-fx__head,
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
|
||||
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
|
||||
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
|
||||
* 714-3 — 줄이 쓰는 입력(서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
|
||||
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
@@ -17,6 +20,77 @@ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean };
|
||||
|
||||
export interface FormulaContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
/** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */
|
||||
inputs?: LogicInput[];
|
||||
values?: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
/* ── 순수 셈: 줄이 쓰는 입력 ── */
|
||||
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
|
||||
export function usedInputs(
|
||||
line: Pick<TextLine, "조각" | "요소조각">,
|
||||
names: string[],
|
||||
): [string, string][] {
|
||||
const out = new Map<string, string>();
|
||||
for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) {
|
||||
const row = p.kind === "표" ? p.참조?.행 : null;
|
||||
if (row && typeof row === "object")
|
||||
for (const [k, v] of Object.entries(row))
|
||||
if (names.includes(k) && !out.has(k)) out.set(k, String(v));
|
||||
if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글))
|
||||
out.set(p.글, String(p.값));
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */
|
||||
function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement {
|
||||
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
|
||||
const chip = createButton({
|
||||
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: (ev) => {
|
||||
ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게
|
||||
let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게
|
||||
const done = (v: string | null): void => {
|
||||
if (over) return;
|
||||
over = true;
|
||||
if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim());
|
||||
else wrap.replaceChildren(chip);
|
||||
};
|
||||
let field: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
const pick = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value: shown,
|
||||
size: 24,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => done(v),
|
||||
});
|
||||
pick.select.addEventListener("blur", () => done(null));
|
||||
field = pick.root;
|
||||
} else {
|
||||
const box = createInputField({ value: shown, size: 24 });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("keydown", (k) => {
|
||||
if (k.isComposing) return;
|
||||
if (k.key === "Enter") done(box.input.value);
|
||||
if (k.key === "Escape") (k.preventDefault(), done(null));
|
||||
});
|
||||
box.input.addEventListener("blur", () => done(box.input.value));
|
||||
field = box.root;
|
||||
}
|
||||
field.addEventListener("click", (e) => e.stopPropagation());
|
||||
wrap.replaceChildren(field);
|
||||
field.querySelector<HTMLElement>("select, input")?.focus();
|
||||
},
|
||||
});
|
||||
chip.title = tl("Formula_Var");
|
||||
wrap.append(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
|
||||
@@ -47,6 +121,29 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
],
|
||||
});
|
||||
const rows: HTMLElement[] = [];
|
||||
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
|
||||
const vars = (line: TextLine): HTMLElement[] => {
|
||||
const names = specs.map((x) => x.이름);
|
||||
const seen = new Map(usedInputs(line, names));
|
||||
// 서버 「쓰는입력」(714-1 · 조건 · 재료 고르기 · 중간값까지) 이 있으면 그것 · 없으면 조각에서
|
||||
const used: [string, string][] = line.쓰는입력
|
||||
? line.쓰는입력.filter((n) => names.includes(n)).map((n) => [n, seen.get(n) ?? ""])
|
||||
: [...seen];
|
||||
if (!used.length || !ctx.onInput) return [];
|
||||
const onInput = ctx.onInput;
|
||||
return [
|
||||
el("div", {
|
||||
className: "m01lab-fx__vars",
|
||||
children: used.map(([name, was]) =>
|
||||
inputChip(
|
||||
specs.find((x) => x.이름 === name)!,
|
||||
ctx.values?.[name] || was,
|
||||
(v) => onInput(name, v),
|
||||
),
|
||||
),
|
||||
}),
|
||||
];
|
||||
};
|
||||
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
|
||||
const light = (pair: string, on: boolean): void =>
|
||||
rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on));
|
||||
@@ -64,6 +161,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
children: calculated
|
||||
? [
|
||||
el("div", { text: `${line.이름} : ${line.글}` }),
|
||||
...vars(line),
|
||||
...(line.까닭
|
||||
? [
|
||||
el("div", {
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
* 자료 모달 둘 —
|
||||
* ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를
|
||||
* 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식
|
||||
* 재료 줄 = 위 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) + 후보 목록(찾기 · `onPick` 이면 누르면 고름)
|
||||
* — 상세 표 줄 · 시험 계산 자재 칸이 같은 모달(711-2) · 기계 고르기는 `Logic_PickTable` 그대로
|
||||
* ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임
|
||||
* 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
|
||||
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
fetchMaterials,
|
||||
searchElements,
|
||||
type ElementBrief,
|
||||
type NamedFormula,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import { createStat, statGrid } from "@ui/ui_template_stat";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note";
|
||||
@@ -81,6 +86,12 @@ export interface ModalTarget {
|
||||
unitText?: string;
|
||||
/** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */
|
||||
qtyFrag?: Fragment[];
|
||||
/** 호표 줄 종류 — 「재료」면 후보 목록(고르기 조건 줄이 아니어도) */
|
||||
kind?: string;
|
||||
/** 시험 계산이 쓴 값 칸(계산 답 `품목.값칸`) — 위 물가 넷 가운데 굵게 */
|
||||
usedSlot?: string;
|
||||
/** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */
|
||||
onPick?: (item: { ref: string; label: string }) => void;
|
||||
}
|
||||
|
||||
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
|
||||
@@ -176,7 +187,71 @@ function goButton(target: MasterJump): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */
|
||||
const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격"];
|
||||
const MARKET_FILE = "재료_자재품목.json";
|
||||
const marketRows = new Map<string, Promise<Row | null>>();
|
||||
/** 자재품목 줄 하나(값 칸 넷) — 키로 찾음 · 한 번만 물음 */
|
||||
const marketRow = (ref: string): Promise<Row | null> => {
|
||||
if (!marketRows.has(ref))
|
||||
marketRows.set(
|
||||
ref,
|
||||
fetchRows(MARKET_FILE, 1, 5, ref)
|
||||
.then((got) => got.rows.find((r) => r["키"] === ref) ?? null)
|
||||
.catch(() => null),
|
||||
);
|
||||
return marketRows.get(ref)!;
|
||||
};
|
||||
|
||||
/** 자재품목 줄 단가 — 이름 · 키 · 규격 + 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게 · 모르면 낮은 값) */
|
||||
function marketView(
|
||||
ref: string,
|
||||
brief: ElementBrief | null,
|
||||
usedSlot?: string,
|
||||
onValue?: (value: unknown) => void,
|
||||
): HTMLElement {
|
||||
const head = el("strong");
|
||||
const spec = el("p", { className: "ui-text ui-text--muted" });
|
||||
const slots = el("div");
|
||||
const paint = (name: unknown, size: unknown, values: Record<string, unknown>): void => {
|
||||
head.textContent = `${String(name ?? ref)} · ${ref}`;
|
||||
spec.textContent = size ? `${tl("Modal_Spec")} ${String(size)}` : "";
|
||||
spec.hidden = !size;
|
||||
const nums = SLOTS.map((s) => values[s]).filter((v): v is number => typeof v === "number");
|
||||
const used = SLOTS.includes(usedSlot ?? "")
|
||||
? usedSlot
|
||||
: SLOTS.find((s) => nums.length && values[s] === Math.min(...nums));
|
||||
slots.replaceChildren(
|
||||
statGrid(
|
||||
SLOTS.map((s) => {
|
||||
const card = createStat({
|
||||
label: s,
|
||||
value: values[s] === null || values[s] === undefined ? "-" : formatPrice(values[s]),
|
||||
compact: true,
|
||||
});
|
||||
card.classList.toggle("is-used", s === used);
|
||||
return card;
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (used) onValue?.(values[used]);
|
||||
};
|
||||
paint(brief?.이름, brief?.규격, brief?.값들 ?? {});
|
||||
if (!brief?.값들) void marketRow(ref).then((row) => row && paint(row["이름"], row["규격"], row));
|
||||
return el("div", {
|
||||
className: "m01lab__data m01lab__slots",
|
||||
attrs: { "data-price": ref },
|
||||
children: [
|
||||
head,
|
||||
spec,
|
||||
slots,
|
||||
goButton({ kind: "element", group: "재료", ref, file: brief?.file ?? MARKET_FILE }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLElement {
|
||||
if (ref.startsWith("MT")) return marketView(ref, brief, usedSlot);
|
||||
const cols = Object.entries(brief.값칸 ?? {});
|
||||
const slots = Object.entries(brief.값들 ?? {}).filter(([, v]) => v !== null && v !== undefined);
|
||||
return el("div", {
|
||||
@@ -207,8 +282,8 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
|
||||
function openDialog(title: string, mount: (body: HTMLElement) => void): void {
|
||||
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */
|
||||
function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void {
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: tl("Modal_Close"),
|
||||
@@ -220,58 +295,122 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void {
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
|
||||
|
||||
/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */
|
||||
function candidateView(
|
||||
cond: PickCond,
|
||||
pickedRef: string,
|
||||
onPicked: (item: ElementBrief | null) => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } });
|
||||
box.append(muted("…"));
|
||||
void searchElements("재료", seedOf(cond))
|
||||
const free = (v: unknown): string => {
|
||||
const text = String(v ?? "");
|
||||
return text.includes("{") ? "" : text; // `{입력}` 자리는 계산 때 정해짐 — 거름에 안 씀
|
||||
};
|
||||
const squash = (v: unknown): string =>
|
||||
String(v ?? "")
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
/** 재료 줄 후보 목록 — 고르기 조건 안 후보(`/materials` · 단위 거름) · 조건이 없으면 이름 찾기 ·
|
||||
* 찾기 칸 = 받은 후보 안에서 거름(없으면 재료 전체 이름 찾기) · 잡힌 품목(`pickedRef`) 노란 줄 맨 위 ·
|
||||
* `onPick` 이면 줄을 누르면 고름 · 값은 위 가로 칸이 보여 목록엔 값 열 없음 */
|
||||
function candidateView(target: ModalTarget, pickedRef: string, close: () => void): HTMLElement {
|
||||
const cond = target.cond;
|
||||
const sub = free(cond?.구분);
|
||||
const list = el("div");
|
||||
const count = el("strong");
|
||||
const field = createInputField({ type: "search", placeholder: tl("Modal_Find") });
|
||||
const box = el("div", {
|
||||
className: "m01lab__data",
|
||||
attrs: { "data-candidates": "1" },
|
||||
children: [count, field.root, list],
|
||||
});
|
||||
list.append(muted("…"));
|
||||
let base: ElementBrief[] = [];
|
||||
const draw = (items: ElementBrief[], total: number): void => {
|
||||
const sorted = [
|
||||
...items.filter((it) => it.ref === pickedRef),
|
||||
...items.filter((it) => it.ref !== pickedRef),
|
||||
];
|
||||
const more = total > items.length ? ` (${items.length})` : "";
|
||||
count.textContent = `${tl(target.onPick ? "Modal_PickList" : "Modal_Candidates")} ${total}${more}`;
|
||||
const rows = sorted.map((it) => {
|
||||
const hit = it.ref === pickedRef;
|
||||
const tr = el("tr", {
|
||||
className: `${hit ? "m01lab__hit" : ""}${target.onPick ? " m01lab__pickrow" : ""}`,
|
||||
attrs: { ...(hit ? { "data-hit": "1" } : {}), "data-ref": it.ref },
|
||||
children: [
|
||||
el("td", { text: it.이름 ?? it.ref }),
|
||||
el("td", { text: it.규격 ?? "" }),
|
||||
el("td", { text: String(it.단위 ?? "") }),
|
||||
],
|
||||
});
|
||||
const onPick = target.onPick;
|
||||
if (onPick)
|
||||
tr.addEventListener("click", () => {
|
||||
close();
|
||||
onPick({ ref: it.ref, label: `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() || it.ref });
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
list.replaceChildren(
|
||||
rows.length
|
||||
? el("div", {
|
||||
className: "m01-logic__scroll m01lab__cands",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m01-logic__grid m01lab__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [tl("Col_Name"), tl("Modal_Spec"), tl("Col_Unit")].map((h) =>
|
||||
el("th", { text: h }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: muted(tl("Pill_NoPick")),
|
||||
);
|
||||
};
|
||||
// 조건 없는 재료 줄 = 그 요소 이름 첫 낱말로 찾음(「모르타르, 서울 1:1」 → 「모르타르」)
|
||||
const known = target.element ? target.prices[target.element] : null;
|
||||
const seed = cond ? seedOf(cond) : headWord(known?.이름 ?? target.title).replace(/[,,].*$/, "");
|
||||
const first = sub
|
||||
? fetchMaterials({
|
||||
sub,
|
||||
detail: free(cond?.상세구분),
|
||||
spec: free(cond?.규격),
|
||||
unit: target.unit ?? "",
|
||||
})
|
||||
: searchElements("재료", seed);
|
||||
let total = 0;
|
||||
let timer = 0;
|
||||
let seq = 0;
|
||||
/** 찾기 칸 글로 거름 — 받은 후보 안에서 · 하나도 없으면 재료 전체 이름 찾기 */
|
||||
const apply = (): void => {
|
||||
const mine = ++seq;
|
||||
const q = field.input.value.trim();
|
||||
const words = q.split(/\s+/).map(squash).filter(Boolean);
|
||||
if (!words.length) return draw(base, total);
|
||||
const hits = base.filter((it) =>
|
||||
words.every((w) => squash(`${it.이름 ?? ""} ${it.규격 ?? ""} ${it.ref}`).includes(w)),
|
||||
);
|
||||
if (hits.length) return draw(hits, hits.length);
|
||||
void searchElements("재료", q)
|
||||
.then((got) => mine === seq && draw(got.items, got.total))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
void first
|
||||
.then((got) => {
|
||||
onPicked(got.items.find((it) => it.ref === pickedRef) ?? null);
|
||||
const rows = got.items.map((it) =>
|
||||
el("tr", {
|
||||
className: it.ref === pickedRef ? "m01lab__hit" : "",
|
||||
attrs: it.ref === pickedRef ? { "data-hit": "1" } : {},
|
||||
children: [
|
||||
el("td", { text: it.이름 ?? it.ref }),
|
||||
el("td", { text: it.규격 ?? "" }),
|
||||
el("td", { text: String(it.단위 ?? "") }),
|
||||
el("td", { className: "m01-logic__money", text: formatPrice(it.값) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const more = got.total > got.items.length ? ` (${got.items.length})` : "";
|
||||
box.replaceChildren(
|
||||
el("strong", { text: `${tl("Modal_Candidates")} ${got.total}${more}` }),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m01-logic__grid m01lab__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
tl("Col_Name"),
|
||||
tl("Modal_Spec"),
|
||||
tl("Col_Unit"),
|
||||
tl("Modal_Value"),
|
||||
].map((h) => el("th", { text: h })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
base = got.items;
|
||||
total = got.total;
|
||||
field.input.value = sub ? free(cond?.["검색어"]) : ""; // 엔진이 고르는 검색어로 먼저 거름 · 지우면 조건 안 전부
|
||||
apply();
|
||||
})
|
||||
.catch(() => box.replaceChildren(muted(tl("Pill_NoPick"))));
|
||||
.catch(() => list.replaceChildren(muted(tl("Pill_NoPick"))));
|
||||
field.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(apply, 300);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -285,7 +424,7 @@ function pickedOf(cond: PickCond, values: Record<string, string>, fallback: stri
|
||||
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) ·
|
||||
* 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */
|
||||
export function openMaterialModal(target: ModalTarget): void {
|
||||
openDialog(target.title, (body) => {
|
||||
openDialog(target.title, (body, close) => {
|
||||
const cond = target.cond;
|
||||
const element = target.element ?? "";
|
||||
const base = element.split(".")[0];
|
||||
@@ -312,7 +451,7 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
setSum(known);
|
||||
const rate = el("div", { className: "m01lab__modal-body" });
|
||||
if (known && priceRef) {
|
||||
rate.append(priceView(priceRef, known));
|
||||
rate.append(priceView(priceRef, known, target.usedSlot));
|
||||
} else if (logicKey) {
|
||||
rate.append(
|
||||
el("p", {
|
||||
@@ -327,7 +466,12 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
}),
|
||||
);
|
||||
} else if (cond && priceRef) {
|
||||
rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴
|
||||
// 서버가 잡은 품목 · 고른 품목 — 자재품목 줄을 키로 읽어 물가 넷
|
||||
rate.append(
|
||||
marketView(priceRef, null, target.usedSlot, (value) =>
|
||||
setSum({ ref: priceRef, 값: value } as ElementBrief),
|
||||
),
|
||||
);
|
||||
} else if (cond) {
|
||||
rate.append(muted(tl("Pill_NoPick")));
|
||||
} else if (element.includes("{")) {
|
||||
@@ -358,23 +502,7 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
}
|
||||
body.replaceChildren(
|
||||
rate,
|
||||
...(cond
|
||||
? [
|
||||
candidateView(cond, priceRef, (item) => {
|
||||
if (known || !priceRef) return;
|
||||
const show = (brief: ElementBrief | null): void => {
|
||||
rate.replaceChildren(
|
||||
brief ? priceView(priceRef, brief) : muted(`${priceRef} — ${tl("Pill_NoValue")}`),
|
||||
);
|
||||
setSum(brief);
|
||||
};
|
||||
if (item) return show(item);
|
||||
void searchElements("재료", priceRef)
|
||||
.then((got) => show(got.items.find((it) => it.ref === priceRef) ?? null))
|
||||
.catch(() => show(null));
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []),
|
||||
muted(tl("Modal_Sum")),
|
||||
sum,
|
||||
el("details", {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Revise.ts
|
||||
* 로직 정본 개정 확인 창(PLAN 713-2 · 요구서 내역12 4장 10) — `/save` 409 `{개정, 까닭}` 를 받으면
|
||||
* 바뀌는 정본 줄(파일 · 로직 · 바뀜)을 보이고 「원문 개정 반영」 확인을 받음.
|
||||
* 확인 = 부르는 쪽이 같은 몸에 `개정확인: true` 로 다시 보냄 · 취소 = 아무것도 안 바뀜(캐시 그대로)
|
||||
* 옛 409(`stale` — 그 사이 파일 바뀜)는 이 창 대상 밖 · `reviseHits` 가 null
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
import { ApiError } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 서버 `Store_Revise.changed` 한 줄 */
|
||||
export interface ReviseHit {
|
||||
file: string;
|
||||
key: string | null;
|
||||
op: "edit" | "add" | "delete";
|
||||
이름?: string | null;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Title: ["원문 개정 반영", "Apply source revision"],
|
||||
Lead: [
|
||||
"로직 정본(품셈 원문) 줄을 바꿈 — 원문이 개정된 때만 저장 · 아래 {n} 줄",
|
||||
"This changes canonical logic rows (source text) — save only for a source revision · {n} rows below",
|
||||
],
|
||||
Ok: ["원문 개정 반영", "Apply revision"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
New: ["새 줄", "New row"],
|
||||
edit: ["고침", "Edit"],
|
||||
add: ["더함", "Add"],
|
||||
delete: ["지움", "Delete"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
const t = (key: keyof typeof TEXT, n = 0): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{n}", String(n));
|
||||
|
||||
const TONE: Record<ReviseHit["op"], BadgeTone> = { edit: "warning", add: "info", delete: "danger" };
|
||||
|
||||
/** 개정 409 면 바뀌는 줄 · 아니면 null */
|
||||
export function reviseHits(error: unknown): ReviseHit[] | null {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) return null;
|
||||
const hits = (error.detail as { 개정?: ReviseHit[] } | null)?.개정;
|
||||
return Array.isArray(hits) && hits.length ? hits : null;
|
||||
}
|
||||
|
||||
/** 확인 창 — 「원문 개정 반영」이면 true · 취소 · 닫기 · Esc · 바깥 누름이면 false */
|
||||
export function askRevise(hits: ReviseHit[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
openModal({
|
||||
title: t("Title"),
|
||||
closeLabel: t("Cancel"),
|
||||
width: "40rem",
|
||||
// [취소] · Esc · 바깥 누름 = 닫기 길(묻는 길) · 확인은 `shut` 이라 이 길을 안 거침
|
||||
beforeClose: () => {
|
||||
resolve(false);
|
||||
return true;
|
||||
},
|
||||
mount: (body, shut, actions) => {
|
||||
body.append(
|
||||
el("p", { text: t("Lead", hits.length) }),
|
||||
...hits.map((h) =>
|
||||
createListRow({
|
||||
label: `${h.key ?? t("New")} ${h.이름 ?? ""}`.trim(),
|
||||
sub: h.file,
|
||||
trailing: createBadge(t(h.op), TONE[h.op]),
|
||||
size: 36,
|
||||
}),
|
||||
),
|
||||
);
|
||||
actions.append(
|
||||
createButton({
|
||||
label: t("Ok"),
|
||||
variant: "danger",
|
||||
onClick: () => {
|
||||
shut();
|
||||
resolve(true);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 저장 한 번 — 개정 409 면 창을 띄워 확인 뒤 `개정확인: true` 로 다시 보냄 · 취소면 null(캐시 그대로) */
|
||||
export async function saveRevised(
|
||||
save: (confirmed: boolean) => Promise<unknown>,
|
||||
): Promise<{ added?: { file: string; key: string }[] } | null> {
|
||||
try {
|
||||
return (await save(false)) as { added?: { file: string; key: string }[] };
|
||||
} catch (error) {
|
||||
const hits = reviseHits(error);
|
||||
if (!hits) throw error;
|
||||
hideLoadingOverlay(); // 창이 가림막 아래로 숨지 않게
|
||||
const ok = await askRevise(hits);
|
||||
showLoadingOverlay();
|
||||
return ok ? ((await save(true)) as { added?: { file: string; key: string }[] }) : null;
|
||||
}
|
||||
}
|
||||
@@ -215,3 +215,21 @@
|
||||
.m01lab__now {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */
|
||||
.m01lab__slots .ui-stat:not(.is-used) .ui-stat__value {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.m01lab__cands {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */
|
||||
.m01lab-est {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -30,10 +30,6 @@ const TEXT = {
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Est: ["인력·재료·경비", "Labor·material·expense"],
|
||||
View_Label: ["보기", "View"],
|
||||
View_Soon: [
|
||||
"준비 중 — 인력 · 재료 · 경비 묶음 보기",
|
||||
"Coming soon — labor · material · expense view",
|
||||
],
|
||||
Now_Inputs: ["지금 입력", "Current inputs"],
|
||||
Now_None: ["없음", "none"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
@@ -61,6 +57,10 @@ const TEXT = {
|
||||
Modal_Close: ["닫기", "Close"],
|
||||
Formula_Left: ["변수 이름 식", "Formula with names"],
|
||||
Formula_Right: ["값이 들어간 식", "Formula with values"],
|
||||
Formula_Var: [
|
||||
"누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)",
|
||||
"Click to change the trial input and recalculate (source unchanged)",
|
||||
],
|
||||
Modal_Sum: ["한 줄 풀이", "In one line"],
|
||||
Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"],
|
||||
Modal_UnitPrice: ["단가", "Unit price"],
|
||||
@@ -83,6 +83,11 @@ const TEXT = {
|
||||
"후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)",
|
||||
"Candidates (yellow = used in the trial)",
|
||||
],
|
||||
Modal_PickList: [
|
||||
"후보 목록 — 줄을 누르면 그 품목으로 시험 계산 (노란 줄 = 지금 쓰는 품목)",
|
||||
"Candidates — click a row to use it in the trial (yellow = in use)",
|
||||
],
|
||||
Modal_Find: ["이름 · 규격으로 찾기", "Search by name or spec"],
|
||||
Modal_Logic: ["다른 로직을 부름", "Calls another logic"],
|
||||
Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"],
|
||||
Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"],
|
||||
|
||||
@@ -124,6 +124,8 @@ export type CalcAnswer =
|
||||
export interface TextPiece {
|
||||
kind: "요소" | "표" | "로직" | "값" | "기호" | "글";
|
||||
글: string;
|
||||
/** 이름 조각(`kind: "값"` · 글 = 이름)이 가진 그 값 — 714-3 입력 단추 */
|
||||
값?: string | number;
|
||||
참조?: {
|
||||
테이블?: string;
|
||||
키: string;
|
||||
@@ -146,6 +148,12 @@ export interface TextLine {
|
||||
짝?: number;
|
||||
금액: number;
|
||||
까닭?: string;
|
||||
/** 714-1 계산서 줄 덧칸 — 쓰는 로직 입력 · 묶음(인력 · 재료 · 경비 · 하위) · 장비 · 찾는 표 기준 · 환산 근거 */
|
||||
쓰는입력?: string[];
|
||||
묶음?: string | null;
|
||||
장비?: boolean | null;
|
||||
기준단위?: string | null;
|
||||
환산근거?: string;
|
||||
}
|
||||
/** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */
|
||||
export type TextAnswer =
|
||||
@@ -190,8 +198,22 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
/** 상세 부품이 부르는 길 앞머리(717-1) — 읽기(로직 · 요소 · 재료 · 표 · 줄) · 셈(`/calc` · `/text`)
|
||||
* · 기본 = M01(`/m01` · 시스템 관리자) · M02 = `{ read: "/m02/logic-lib", calc: "/m02/logic-lib" }`(로그인) */
|
||||
export interface LogicApiPaths {
|
||||
read: string;
|
||||
calc: string;
|
||||
}
|
||||
const M01_PATHS: LogicApiPaths = { read: "/m01", calc: "/m01" };
|
||||
let paths = M01_PATHS;
|
||||
/** 상세를 그리기 전에 한 번 — 안 넘기면 M01 길로 되돌림 · 저장 · 만들기 길은 늘 `/m01` */
|
||||
export const setLogicApiPaths = (next?: LogicApiPaths): void => {
|
||||
paths = next ?? M01_PATHS;
|
||||
};
|
||||
export const logicApiPaths = (): LogicApiPaths => paths;
|
||||
|
||||
async function request<T>(path: string, body?: unknown, base = "/m01"): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${base}${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -205,13 +227,16 @@ async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
||||
|
||||
export const fetchLogics = (): Promise<LogicSummary[]> =>
|
||||
request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics);
|
||||
request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics);
|
||||
|
||||
/** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */
|
||||
export const fetchLogicSubs = (): Promise<SubBrief[]> =>
|
||||
request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs);
|
||||
request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`, undefined, paths.read).then(
|
||||
(d) => d.subs,
|
||||
);
|
||||
|
||||
export const fetchLogic = (key: string): Promise<LogicOne> => request(`/logic?${query({ key })}`);
|
||||
export const fetchLogic = (key: string): Promise<LogicOne> =>
|
||||
request(`/logic?${query({ key })}`, undefined, paths.read);
|
||||
|
||||
export const fetchLogicFiles = (): Promise<LogicFile[]> =>
|
||||
request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then(
|
||||
@@ -222,10 +247,10 @@ export const searchElements = (
|
||||
group: string,
|
||||
q: string,
|
||||
): Promise<{ total: number; items: ElementBrief[] }> =>
|
||||
request(`/elements?${query({ group, q, limit: "100" })}`);
|
||||
request(`/elements?${query({ group, q, limit: "100" })}`, undefined, paths.read);
|
||||
|
||||
export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> =>
|
||||
request(`/pick?${query({ kind: "price", q, limit: "100" })}`);
|
||||
request(`/pick?${query({ kind: "price", q, limit: "100" })}`, undefined, paths.read);
|
||||
|
||||
export const fetchMaterials = (cond: {
|
||||
sub: string;
|
||||
@@ -235,11 +260,11 @@ export const fetchMaterials = (cond: {
|
||||
/** 호표 줄 단위 — 주면 그 단위 줄만 */
|
||||
unit?: string;
|
||||
}): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> =>
|
||||
request(`/materials?${query({ ...cond, limit: "100" })}`);
|
||||
request(`/materials?${query({ ...cond, limit: "100" })}`, undefined, paths.read);
|
||||
|
||||
/** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */
|
||||
export const fetchAuto = (key: string): Promise<Record<string, unknown>> =>
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`)
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`, undefined, paths.read)
|
||||
.then((d) => d.값 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
@@ -250,7 +275,7 @@ export const runCalc = (body: {
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<CalcAnswer> => request("/calc", body);
|
||||
}): Promise<CalcAnswer> => request("/calc", body, paths.calc);
|
||||
|
||||
export const runText = (body: {
|
||||
key: string;
|
||||
@@ -259,11 +284,12 @@ export const runText = (body: {
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<TextAnswer> => request("/text", body);
|
||||
}): Promise<TextAnswer> => request("/text", body, paths.calc);
|
||||
|
||||
export const saveFiles = (
|
||||
files: SaveFile[],
|
||||
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files });
|
||||
개정확인 = false,
|
||||
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files, 개정확인 });
|
||||
|
||||
/** 본떠 만들기 — 정본·자체 둘 다 본뜸 · 새 키(`GX…`)는 서버가 줌(계약 5장) */
|
||||
export const copyLogic = (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_CalcPick.ts
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나
|
||||
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* 재료 = 상세 표 줄과 같은 재료 모달(`openMaterialModal` · 711-2) · 기계 = 마스터 표 모달(`PickTable`)
|
||||
* 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
@@ -9,7 +10,7 @@ import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import type { CalcLine, ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { condText, openMaterialModal, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
|
||||
interface Chosen {
|
||||
ref: string;
|
||||
@@ -24,6 +25,19 @@ const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
|
||||
return mine;
|
||||
};
|
||||
|
||||
/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */
|
||||
export function pickMaterial(
|
||||
host: HTMLElement,
|
||||
values: Record<string, string>,
|
||||
i: number,
|
||||
it: Chosen,
|
||||
): void {
|
||||
picksOf(values).set(i, it);
|
||||
const link = host.querySelector<HTMLElement>(`[data-mat-idx="${i}"]`);
|
||||
if (link) link.textContent = it.label;
|
||||
host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
}
|
||||
|
||||
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
|
||||
export function pickBody(values: Record<string, string>): { 고름?: Record<string, string> } {
|
||||
const mine = picksOf(values);
|
||||
@@ -78,9 +92,6 @@ export function materialRows(
|
||||
const held = mine.get(i);
|
||||
const nowName =
|
||||
held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? "";
|
||||
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
|
||||
.map(headWord)
|
||||
.filter((w, k, all) => w && all.indexOf(w) === k);
|
||||
// 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음)
|
||||
const current = createButton({
|
||||
label: nowName || tx("Mat_NoPrice"),
|
||||
@@ -90,20 +101,26 @@ export function materialRows(
|
||||
current.dataset.matIdx = String(i);
|
||||
current.dataset.ref = brief?.ref ?? "";
|
||||
current.title = tx("Pick_Item");
|
||||
const title = `${label}${item.규격 ? ` ${item.규격}` : ""}`;
|
||||
const pick = (): void =>
|
||||
openPickTable({
|
||||
openMaterialModal({
|
||||
title,
|
||||
expr: item.수량,
|
||||
...(cond ? { cond } : { element: item.요소 }),
|
||||
unit: item.단위,
|
||||
kind: "재료",
|
||||
title: `${tx("Pick_MatTitle")} · ${label}`,
|
||||
seed: seeds[0] ?? "",
|
||||
alt: seeds.slice(1),
|
||||
isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref),
|
||||
onPick: (r) => {
|
||||
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
|
||||
current.textContent = nameOf(r);
|
||||
prices,
|
||||
middles: row.중간 ?? [],
|
||||
values,
|
||||
unitRef: mine.get(i)?.ref || current.dataset.ref || undefined,
|
||||
usedSlot: current.dataset.slot || undefined,
|
||||
onPick: (it) => {
|
||||
mine.set(i, it);
|
||||
current.textContent = it.label;
|
||||
again();
|
||||
},
|
||||
});
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current);
|
||||
return line(title, current);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,6 +134,7 @@ export function showItems(
|
||||
host.querySelectorAll<HTMLElement>("[data-mat-idx]").forEach((span) => {
|
||||
const i = Number(span.dataset.matIdx);
|
||||
const item = lines[i]?.품목;
|
||||
span.dataset.slot = item?.값칸 ?? ""; // 시험 계산이 쓴 값 칸 — 모달 위 가로 칸 굵게
|
||||
if (!item || mine.has(i)) return;
|
||||
span.dataset.ref = item.키;
|
||||
span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { logicApiPaths, type ElementBrief, type NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
|
||||
@@ -31,7 +31,7 @@ const tableCache = new Map<string, Promise<TableRow | null>>();
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}`);
|
||||
const res = await fetch(`${API_BASE_URL}${logicApiPaths().read}${path}`);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
|
||||
@@ -251,7 +251,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
|
||||
target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}}
|
||||
target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"])
|
||||
ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정"
|
||||
text = f"확인된 사전조합 {made}" if made else f"제{no}호표 먼저 확인"
|
||||
text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인"
|
||||
return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text}
|
||||
if kind == "노임":
|
||||
return {**out, "상태": "노임", "글": "노임 · 그릇 없음"}
|
||||
|
||||
@@ -32,9 +32,9 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
|
||||
|
||||
mb, mu = m01.cm.mf.mb, m01.cm.mf.mp.mm.mu # 묶음(712-1) · 단위(712-2) 한 벌
|
||||
FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
|
||||
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
|
||||
EQ_CODE = re.compile(r"^\d{4}-\d{4}$")
|
||||
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
|
||||
@@ -84,7 +84,7 @@ def whole() -> Any:
|
||||
|
||||
|
||||
def is_mach_logic(row: dict[str, Any]) -> bool:
|
||||
return "시간당" in str(row.get("이름") or "")
|
||||
return mb.is_machine(row) # 기계경비 판별 한 곳(712-1 · 결과단위 원/시간)
|
||||
|
||||
|
||||
def eq_of(code: Any) -> tuple[set[str], set[float]]:
|
||||
@@ -95,8 +95,8 @@ def eq_of(code: Any) -> tuple[set[str], set[float]]:
|
||||
|
||||
|
||||
def times_of(row: dict[str, Any]) -> float:
|
||||
per = _PER.findall(str(row.get("결과단위") or ""))
|
||||
return float(per[0].replace(",", "")) if per and per[0] else 1.0
|
||||
per = mu.per(row.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2)
|
||||
return float(per[0][0]) if per else 1.0
|
||||
|
||||
|
||||
def _args(text: str | None) -> dict[str, str]:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) —
|
||||
산근(단산) 블록 = 바탕 없는 단가산출.
|
||||
|
||||
블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가
|
||||
(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 ·
|
||||
서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) ·
|
||||
구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본,
|
||||
확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_time import utc_now_text
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
ORIGIN = "실무자료"
|
||||
KIND = "sangeun"
|
||||
|
||||
|
||||
def mark(key: str, no: int) -> str:
|
||||
return f"{key} 단산{no}"
|
||||
|
||||
|
||||
def made(folder: str | Path, key: str | None = None) -> dict[str, str]:
|
||||
"""이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만)."""
|
||||
out = {}
|
||||
for path in sorted((Path(folder) / KIND).glob("SK-*.json")):
|
||||
try:
|
||||
got = json.loads(path.read_bytes()).get("본뜬")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
if isinstance(got, dict) and got.get("층") == ORIGIN:
|
||||
if key is None or str(got.get("키") or "").startswith(f"{key} "):
|
||||
out[str(got.get("키"))] = path.stem
|
||||
return out
|
||||
|
||||
|
||||
def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]:
|
||||
name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}"
|
||||
unit = str(line.get("단위") or block.get("단위") or "").strip() or "식"
|
||||
spec = str(line.get("규격") or "").strip()
|
||||
price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()}
|
||||
fx = {
|
||||
"이름": name,
|
||||
"규격": spec,
|
||||
"단위": unit,
|
||||
"단가": price,
|
||||
"이음": dict.fromkeys(fixed.LINKS),
|
||||
"출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))},
|
||||
}
|
||||
return {
|
||||
"종류": "단가산출",
|
||||
"이름": name,
|
||||
"단위": unit,
|
||||
"구분": ORIGIN,
|
||||
"상세구분": str(hp.get("파일") or ""),
|
||||
"본뜬": {
|
||||
"층": ORIGIN,
|
||||
"키": mark(hp["키"], no),
|
||||
"파일": hp.get("파일"),
|
||||
"원본": list(refs.here()),
|
||||
"확인": {"누가": who, "때": stamp},
|
||||
},
|
||||
"바탕": None,
|
||||
"입력값": {},
|
||||
"열린입력": [],
|
||||
"고름": {},
|
||||
"자재표시": {},
|
||||
"덮개": {},
|
||||
"더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}],
|
||||
"뺀줄": [],
|
||||
}
|
||||
|
||||
|
||||
def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]:
|
||||
"""[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 →
|
||||
`{블록, 만듦 [{번, 이름, 키}], 그대로}`."""
|
||||
hp = hopyo_store.read(folder, key)
|
||||
blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None}
|
||||
calls: dict[int, dict] = {}
|
||||
for h in hp.get("호표") or []:
|
||||
for line in h.get("줄") or []:
|
||||
ref = line.get("참조") or {}
|
||||
no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0
|
||||
if no in blocks and no not in calls:
|
||||
calls[no] = {**line, "_호표": h.get("번")}
|
||||
have, stamp = made(folder, key), utc_now_text()
|
||||
out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0}
|
||||
for no in sorted(calls):
|
||||
if mark(key, no) in have:
|
||||
out["그대로"] += 1
|
||||
continue
|
||||
doc = _doc(calls[no], blocks[no], no, hp, stamp, who)
|
||||
got = refs.create(KIND, doc)
|
||||
out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]})
|
||||
return out
|
||||
@@ -44,7 +44,7 @@ def _texts(row: dict) -> list[str]:
|
||||
out.append(row["결과"]["식"])
|
||||
for item in row.get("호표", []):
|
||||
out.append(item["수량"])
|
||||
if not isinstance(item.get("요소"), dict):
|
||||
if isinstance(item.get("요소"), str): # 단가산출 참조 · 고정 더한줄 = 요소 없음(719-1)
|
||||
out.append(item["요소"])
|
||||
out += [extra["식"] for extra in row.get("덧줄", [])]
|
||||
return out
|
||||
@@ -200,6 +200,10 @@ class _Collector:
|
||||
return
|
||||
self.logics.add(key)
|
||||
self.keys.add(key)
|
||||
self.row(row)
|
||||
|
||||
def row(self, row: dict) -> None:
|
||||
"""로직 줄 하나가 닿는 것 — 마스터 로직 · 굳힌 단가산출 줄(719-1 · 마스터 밖 줄)."""
|
||||
if isinstance(row.get("흐름"), dict): # 흐름 로직 — 옛 칸 대신 흐름이 쓰는 것을 모음
|
||||
self.flow(row)
|
||||
return
|
||||
@@ -221,12 +225,17 @@ class _Collector:
|
||||
self.pick(row, item["요소"], item.get("단위"))
|
||||
|
||||
|
||||
def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]:
|
||||
"""담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`."""
|
||||
def collect(
|
||||
logic_keys: list[str], materials: list[str] | None = None, extra: list[dict] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}` ·
|
||||
`extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음)."""
|
||||
files, whole = master()
|
||||
collector = _Collector(whole)
|
||||
for key in logic_keys:
|
||||
collector.logic(key)
|
||||
for row in extra or []:
|
||||
collector.row(row)
|
||||
for code in materials or []:
|
||||
collector.add(code)
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(PLAN 719-1 · 요구서 내역12 원칙 3 · 4장 14).
|
||||
|
||||
사본을 만들 때(구조물 사본 `M02_Item_Source.build_source` · B09 서고 사본 `Bill_Library`) 굳힌
|
||||
참조사본 안 SK 문서마다 고친 줄(`M02_Sangeun_Calc.resolve` = 바탕 사슬 + 덮개 · 더한줄 · 뺀줄)을
|
||||
풀어 사본 `단가.json` 로직 줄 하나로 굳힘 — 사본 SK 문서는 그 줄만 바탕으로 가리킴(덮개 0 ·
|
||||
연결 끊김). 셈은 그대로 `M02_Sangeun_Calc` 한 벌(사본 색인 = 사본 `단가.json`).
|
||||
|
||||
굳힌 키 = SK 참조 이름표(`ref_id` = 층 · 주인 · 키)에서 정해지는 `GX9#####`(해시 · 겹치면
|
||||
이름표 차례로 다음 번호) — 같은 SK = 같은 굳힌 키([업데이트] 때 B09 덮개 cover · cid · 입력값 ·
|
||||
고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만.
|
||||
사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합),
|
||||
열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [],
|
||||
굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 정체(굳힌 줄 차례 → 원본
|
||||
cid · 더한줄 = `add:#k`), 바탕판, 짝잃음}.
|
||||
단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
#: 사본 `단가.json` 안 굳힌 SK 줄 파일(`master_formula.Master` 가 그룹 「로직」 으로 읽음)
|
||||
FILE = "로직_단가산출.json"
|
||||
ROW = "굳힌줄"
|
||||
_HEAD, _LOW, _SPAN = "GX", 900000, 99999 # GX900000 ~ GX999998(999999 = M01 저장 전 초안)
|
||||
|
||||
|
||||
def frozen_key(rid: str, taken: set[str]) -> str:
|
||||
"""SK 이름표 → 굳힌 로직 키 — 해시 자리 · 이미 쓴 키면 다음 번호(제자리 `taken` 에 더함)."""
|
||||
at = int(hashlib.sha256(rid.encode("utf-8")).hexdigest(), 16) % _SPAN
|
||||
while (key := f"{_HEAD}{_LOW + at:06d}") in taken:
|
||||
at = (at + 1) % _SPAN
|
||||
taken.add(key)
|
||||
return key
|
||||
|
||||
|
||||
def is_sangeun(doc: Any) -> bool:
|
||||
return isinstance(doc, dict) and doc.get("종류") == sd.TITLE
|
||||
|
||||
|
||||
def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""참조사본 `{ref_id: 문서}` → SK 를 풀어 굳힌 참조사본(새 묶음 · 받은 것 안 고침) ·
|
||||
못 푼 SK(없는 바탕 · 순환) = 그대로(사본 셈이 까닭을 냄)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc))
|
||||
if not sks:
|
||||
return copies
|
||||
whole = m01.loaded()[1]
|
||||
|
||||
def read(ref: dict) -> dict | None:
|
||||
return copies.get(refs.ref_id(ref))
|
||||
|
||||
out, taken = dict(copies), set()
|
||||
for rid in sks:
|
||||
doc = copies[rid]
|
||||
try:
|
||||
got = calc.resolve(doc, whole, read, (rid,))
|
||||
except sd.DocError:
|
||||
continue
|
||||
key = frozen_key(rid, taken)
|
||||
row = prices.to_json({**got["줄"], "키": key})
|
||||
ids = sd.line_ids(row)
|
||||
frozen = {k: doc.get(k) for k in ("종류", "키", "이름", "단위", "구분", "상세구분")}
|
||||
frozen |= {
|
||||
"바탕": {"로직": key},
|
||||
"입력값": prices.to_json(got["입력값"]),
|
||||
"열린입력": list(doc.get("열린입력") or []),
|
||||
"고름": {ids[int(n)]: mt for n, mt in got["고름"].items()},
|
||||
"자재표시": {ids[int(n)]: one for n, one in got["자재표시"].items()},
|
||||
"덮개": {},
|
||||
"더한줄": [],
|
||||
"뺀줄": [],
|
||||
ROW: row,
|
||||
"원본": doc,
|
||||
"정체": list(got["정체"]), # 굳힌 줄 차례 → 원본 cid(B09 덮개 · 내 자료집 · 719-2)
|
||||
"바탕판": got["바탕판"],
|
||||
"짝잃음": got["짝잃음"],
|
||||
}
|
||||
if got["켜쌓기율"] is not None:
|
||||
frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"])
|
||||
out[rid] = frozen
|
||||
return out
|
||||
|
||||
|
||||
def rows(copies: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례)."""
|
||||
return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc]
|
||||
|
||||
|
||||
def materials(copies: dict[str, Any]) -> list[str]:
|
||||
"""굳힌 SK 고른 자재 키 — 단가 묶음에 담음."""
|
||||
return [
|
||||
str(mt)
|
||||
for doc in copies.values()
|
||||
if is_sangeun(doc)
|
||||
for mt in (doc.get("고름") or {}).values()
|
||||
]
|
||||
@@ -5,7 +5,7 @@
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 ·
|
||||
규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘 · 단가산출 SK = 풀어 굳힌 로직 줄 719-1) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
|
||||
@@ -93,11 +93,12 @@ 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_Item_Sangeun as sk
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)}
|
||||
copies = refs.frozen(doc)
|
||||
copies = sk.freeze(refs.frozen(doc)) # 단가산출(SK) = 풀어 굳힌 로직 줄(719-1)
|
||||
return {**rows, "참조사본": copies} if copies else rows
|
||||
|
||||
|
||||
@@ -162,11 +163,14 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]:
|
||||
"""사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 ·
|
||||
개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2)."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as sk
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
copies = (labor or {}).get("참조사본") or {}
|
||||
sk_rows = sk.rows(copies) # 굳힌 단가산출 줄(719-1) — 그 줄이 닿는 마스터 줄도 모음
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
|
||||
logic_keys += refs.frozen_logics(copies) # 굳힌 단가승격 로직
|
||||
doc = (labor or {}).get("줄") or {}
|
||||
materials = [str(m.get("코드")) for m in doc.get("자재") or []]
|
||||
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
|
||||
@@ -174,8 +178,13 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]:
|
||||
]
|
||||
materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재
|
||||
materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계
|
||||
materials += sk.materials(copies)
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
bundle = (
|
||||
prices.to_json(prices.collect(logic_keys, materials, sk_rows)) if wanted or sk_rows else {}
|
||||
)
|
||||
if sk_rows:
|
||||
bundle[sk.FILE] = {"그룹": "로직", "원문": "자체", "줄": sk_rows}
|
||||
if mine := own.bundle(
|
||||
[c for c in materials if own.is_own(c)], root
|
||||
): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로
|
||||
|
||||
@@ -42,6 +42,7 @@ 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_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Labor_Spec as spec
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
@@ -127,6 +128,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"별칭": alias,
|
||||
"메모": str(memo) if memo else None,
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**patch.ref_clean(one, ref, where), # 그 줄 덮개(715-3)
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -155,6 +157,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
**({"고름": picks} if picks else {}),
|
||||
**({"승격": True} if one.get("승격") is True else {}),
|
||||
**patch.clean(one, where, whole, logic), # 그 줄 덮개(715-3)
|
||||
**(
|
||||
{"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {}
|
||||
),
|
||||
@@ -512,7 +515,7 @@ def calc(
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
if _is_ref(one):
|
||||
lines.append(ref_line(one["참조"], expand(one["참조"])))
|
||||
lines.append(ref_line(one["참조"], patch.ref(one, expand)))
|
||||
continue
|
||||
if fixed.is_fixed(one):
|
||||
lines.append(fixed.line(whole, one[fixed.FIXED]))
|
||||
@@ -525,6 +528,9 @@ def calc(
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지
|
||||
lines.append({**line, **patch.run(whole, one, read)})
|
||||
continue
|
||||
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
|
||||
lines.append({**line, **drop_apart(got, one.get("자재표시"))})
|
||||
mats = [
|
||||
|
||||
@@ -11,6 +11,7 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
@@ -81,10 +82,13 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지
|
||||
got = patch.run(self.whole, one, self.read)
|
||||
else:
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
@@ -109,9 +113,10 @@ class Expand:
|
||||
inner, rows, why = [], [], None
|
||||
for one in lines:
|
||||
ref = one.get("참조")
|
||||
if isinstance(ref, dict):
|
||||
head, sub = {"참조": ref}, self(ref, trail)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
|
||||
if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈
|
||||
head = {"참조": ref}
|
||||
sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) · 덮개 있음
|
||||
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
|
||||
elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`)
|
||||
head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"])
|
||||
@@ -156,7 +161,14 @@ class Expand:
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"머리": head_of(doc),
|
||||
"기준": None if why else labor._sum(rows),
|
||||
"까닭": why,
|
||||
"안": inner,
|
||||
}
|
||||
|
||||
|
||||
def head_of(doc: dict) -> str | None:
|
||||
"""상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None."""
|
||||
unit = str(doc.get("단위") or "").strip()
|
||||
return f"1 {unit} 당" if unit else None
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""일위대가 · 사전조합 줄 덮개(PLAN 715-3 · 요구서 내역12 4장 16 · 4-1 확정 5).
|
||||
|
||||
줄 칸 `패치` = 단가산출(SK) 문서 꼴 `{덮개, 뺀줄, 더한줄}`(한 벌 · `M02_Sangeun_Doc`) — 그 줄에만 ·
|
||||
로직 줄(`{로직, 입력값, 고름, 자재표시, 패치}`) · 단가산출 참조 줄(`{참조: SK, 패치}`)만 받음 ·
|
||||
줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 차례 그대로 — 셈 때 cid 로 바꿔 뺀줄 뒤에도 제자리.
|
||||
더한줄은 인력 · 재료 · 기계 · 로직 · 고정만(참조는 일위대가에 참조 줄로).
|
||||
셈 = 줄을 이름 없는 SK 문서로(`as_doc`) → `M02_Sangeun_Calc` 한 벌 · 로직 줄 기준 = 결과단위 값(배는
|
||||
알약 · `settle` 그대로) · 참조 줄 = 1 단위 값. [단가산출로 저장] = `as_doc` + 이름 · 단위 → 층 SK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
FIELD = "패치"
|
||||
KEEP = ("덮개", "뺀줄", "더한줄")
|
||||
TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함
|
||||
|
||||
|
||||
def _bad(text: str):
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
return store.StoreError(400, text)
|
||||
|
||||
|
||||
def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | None = None) -> dict:
|
||||
"""줄 칸 `패치` 정리 — 없거나 비면 `{}`(칸 안 둠) · 틀리면 400."""
|
||||
raw = one.get(FIELD)
|
||||
if not raw:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad(f"{where} · 패치는 묶음")
|
||||
if any("참조" in x for x in raw.get("더한줄") or [] if isinstance(x, dict)):
|
||||
raise _bad(f"{where} · 줄 더한줄엔 참조 못 넣음 — 일위대가에 참조 줄로")
|
||||
body = {k: raw.get(k) for k in KEEP}
|
||||
try:
|
||||
got = sd.clean(
|
||||
{"키": TEMP, "이름": "줄", "단위": "식", **body, "열린입력": opened or []}, row, whole
|
||||
)
|
||||
except sd.DocError as e:
|
||||
raise _bad(f"{where} · {e}") from e
|
||||
out = {k: got[k] for k in KEEP if got[k]}
|
||||
return {FIELD: out} if out else {}
|
||||
|
||||
|
||||
def ref_clean(one: dict, ref: dict, where: str) -> dict:
|
||||
"""단가산출 참조 줄 패치 — 그 SK 의 열린입력이면 품 배율만."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
if not one.get(FIELD):
|
||||
return {}
|
||||
if refs.kind_of(ref["키"]) != sd.KIND:
|
||||
raise _bad(f"{where} · 줄 덮개는 로직 줄 · 단가산출 참조 줄만")
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
doc = refs.read_ref(ref) or {}
|
||||
return clean(one, where, m01.loaded()[1], None, doc.get("열린입력"))
|
||||
|
||||
|
||||
def _keyed(value: Any, ids: list[str]) -> dict:
|
||||
"""차례 열쇠 → cid 열쇠(바탕 줄 차례)."""
|
||||
out = {}
|
||||
for n, v in (value or {}).items() if isinstance(value, dict) else ():
|
||||
at = int(n) if str(n).isdigit() else -1
|
||||
if 0 <= at < len(ids):
|
||||
out[ids[at]] = v
|
||||
return out
|
||||
|
||||
|
||||
def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]:
|
||||
"""일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로."""
|
||||
body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP}
|
||||
head = {"키": TEMP, "이름": name, "단위": unit, **body}
|
||||
if "참조" in one:
|
||||
return {**head, "바탕": {"참조": one["참조"]}}
|
||||
try:
|
||||
ids = sd.line_ids(whole.logic(one["로직"]))
|
||||
except Exception: # noqa: BLE001 — 없는 로직 = 셈이 까닭
|
||||
ids = []
|
||||
extra = {"켜쌓기율": one["켜쌓기율"]} if one.get("켜쌓기율") is not None else {}
|
||||
return {
|
||||
**head,
|
||||
**extra,
|
||||
"바탕": {"로직": one["로직"]},
|
||||
"입력값": dict(one.get("입력값") or {}),
|
||||
"고름": _keyed(one.get("고름"), ids),
|
||||
"자재표시": _keyed(one.get("자재표시"), ids),
|
||||
}
|
||||
|
||||
|
||||
def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]:
|
||||
"""패치 있는 로직 줄 기준(`run_line` 꼴 · 별도계상 뺀 뒤 · 기준 = 결과단위 값)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
try:
|
||||
doc = sd.clean(as_doc(one, whole), None, whole)
|
||||
return calc.run(doc, whole, None, read or refs.read_ref)
|
||||
except sd.DocError as e:
|
||||
return {"기준": None, "까닭": str(e)}
|
||||
|
||||
|
||||
def ref(one: dict, expand: Any) -> dict[str, Any]:
|
||||
"""참조 줄 펼침 — 패치 있는 SK 참조는 그 줄만 다시 셈(캐시 안 씀) · 그 밖 `expand` 그대로."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
if not one.get(FIELD):
|
||||
return expand(one["참조"])
|
||||
doc = as_doc(one, expand.whole)
|
||||
base = expand.read(one["참조"]) or {}
|
||||
doc = {**doc, "이름": base.get("이름") or doc["이름"], "단위": base.get("단위") or doc["단위"]}
|
||||
return calc.unit(doc, expand.whole, expand, ())
|
||||
|
||||
|
||||
def to_sangeun(one: dict, name: str, unit: str, whole: Any) -> dict[str, Any]:
|
||||
"""[단가산출로 저장] 문서 — 줄의 바탕 · 입력값 · 고름 · 덮개 → 이름 붙인 SK(키는 서버)."""
|
||||
if "로직" not in one and "참조" not in one:
|
||||
raise _bad("로직 줄 · 단가산출 참조 줄만 단가산출로 저장")
|
||||
doc = as_doc(one, whole, name, unit)
|
||||
doc.pop("키")
|
||||
return doc
|
||||
@@ -163,7 +163,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
|
||||
lines = _combo_lines(doc, key)
|
||||
if not unit and (lines or doc.get("자재")): # 일위대가 = 기준단위 1 당(요구 27 · 715-3) ·
|
||||
raise _bad("일위대가 단위가 빔 — 1 단위 값의 단위") # 빈 새 문서(이름만)는 받음
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=lines)
|
||||
out["자재"] = _combo_mats(doc, out["담은로직"])
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
@@ -226,9 +229,9 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
|
||||
lines = []
|
||||
for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]:
|
||||
head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]}
|
||||
head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3)
|
||||
if "로직" in one:
|
||||
head["입력값"] = one["입력값"]
|
||||
head.update(로직=one["로직"], 입력값=one["입력값"])
|
||||
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
|
||||
head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3)
|
||||
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
|
||||
@@ -299,6 +302,19 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) ->
|
||||
layer, owner = where(folder)
|
||||
base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {}
|
||||
out["기준금액"] = float(base["계"]) if "계" in base else None
|
||||
# 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수
|
||||
if kind == "sangeun":
|
||||
top, (layer, owner) = doc.get("바탕") or {}, where(folder)
|
||||
logic = top.get("로직")
|
||||
out["바탕"] = logic or (top.get("참조") or {}).get("키")
|
||||
if logic and expand is not None:
|
||||
try:
|
||||
out["바탕"] = f"{logic} {expand.whole.logic(logic).get('이름') or ''}".strip()
|
||||
except Exception: # noqa: BLE001 — 없는 로직 = 키만(셈 때 까닭)
|
||||
pass
|
||||
out["고친줄수"] = sum(len(doc.get(k) or ()) for k in ("덮개", "더한줄", "뺀줄"))
|
||||
# ponytail: SK 마다 모든 층 훑음(users) — 목록이 느려지면 층 훑기 한 번 셈으로
|
||||
out["쓰는곳수"] = len(users({"층": layer, "주인": owner, "키": key}))
|
||||
return out
|
||||
|
||||
|
||||
@@ -318,7 +334,8 @@ def users(ref: Ref) -> list[str]:
|
||||
"""`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 ·
|
||||
프로젝트 뺌)."""
|
||||
rid, out = ref_id(ref), []
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")):
|
||||
# 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」
|
||||
for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")):
|
||||
for at, path in layers.layer_files(kind):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
|
||||
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
|
||||
지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) +
|
||||
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
|
||||
서고 나무 자유 폴더(158-1 · 716-4 `{종류}-tree` · `M02_Precombo_Folders` · 읽기 = 층 읽기 ·
|
||||
고침 = 층 쓰기) +
|
||||
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
|
||||
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
|
||||
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) ·
|
||||
@@ -32,6 +33,7 @@ from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
|
||||
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
|
||||
from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
@@ -46,6 +48,9 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
|
||||
_call,
|
||||
_root,
|
||||
)
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
|
||||
PasteBody as TreePasteBody,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"])
|
||||
|
||||
@@ -130,25 +135,44 @@ def new_library(
|
||||
return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/precombo-tree")
|
||||
def precombo_tree(
|
||||
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리)."""
|
||||
return _call(_root(session, layer, owner), pre_folders.view)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/precombo-tree")
|
||||
def edit_precombo_tree(
|
||||
@router.get("/layers/{layer}/{kind}-tree")
|
||||
def library_tree(
|
||||
layer: Layer,
|
||||
kind: Literal["precombo", "sangeun"],
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""서고 나무 폴더(일위대가 · 단가산출) — `{판, 폴더: [경로 …], 자리: {키: 경로}}` ·
|
||||
자리 없는 문서 = 뿌리."""
|
||||
return _call(_root(session, layer, owner), pre_folders.view, kind)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/{kind}-tree")
|
||||
def edit_library_tree(
|
||||
layer: Layer,
|
||||
kind: Literal["precombo", "sangeun"],
|
||||
body: TreeBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 ·
|
||||
판 다르면 409."""
|
||||
"""새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(폴더 = 안에 든 문서째 · 쓰는 곳 있으면 409) ·
|
||||
여럿 = `대상` → 새 나무 · 판 다르면 409."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, pre_folders.edit, body.model_dump())
|
||||
return _call(folder, pre_folders.edit, body.model_dump(), kind)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/{kind}-tree/paste")
|
||||
def paste_library_tree(
|
||||
layer: Layer,
|
||||
kind: Literal["precombo", "sangeun"],
|
||||
body: TreePasteBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""복사한 층(읽기 권한) 문서 · 폴더 → 이 층(쓰기 권한) `경로` 아래 새 키 → 새 나무 + 알림."""
|
||||
target = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.from_layer, body.from_owner)
|
||||
return _call(target, pre_folders.paste, source, body.대상, body.경로, kind)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/materials")
|
||||
@@ -206,7 +230,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
when = str(old.get("끝낸때") or "")[:10]
|
||||
raise store.StoreError(
|
||||
409,
|
||||
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음",
|
||||
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})"
|
||||
" — 다시 만들지 않음",
|
||||
)
|
||||
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
|
||||
return {**same, "이어서": True}
|
||||
@@ -465,6 +490,7 @@ def _finish(folder: Path, key: str) -> dict[str, Any]:
|
||||
made = sorted(
|
||||
{sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)}
|
||||
)
|
||||
made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK)
|
||||
if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None:
|
||||
try:
|
||||
got = cache_path(folder, key)
|
||||
@@ -477,6 +503,16 @@ def _finish(folder: Path, key: str) -> dict[str, Any]:
|
||||
return {"지움": key, "만든": len(made)}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/sangeun")
|
||||
def hopyo_make_sangeun(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) →
|
||||
`{블록, 만듦, 그대로}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, hopyo_sangeun.build, folder, key, _who(session))
|
||||
|
||||
|
||||
class PasteBody(_Strict):
|
||||
"""엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) ·
|
||||
`줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함)."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""단가산출 로직 상세 읽기 · 셈 길(PLAN 717-1) — `/api/m02/logic-lib/…` · 로그인만(`main.py`).
|
||||
|
||||
M01 상세 부품(`M01_MasterData_UI_Logic_Api.ts` · `Api_Fetch.ts` · `Logic_Note.ts`)이 길 앞머리만
|
||||
`/m02/logic-lib` 로 바꿔 부름 — 길 · 몸 · 답 = M01 `/api/m01/…` 와 같음(M01 길 함수를 부름).
|
||||
쓰기 없음 — `/calc` · `/text` 의 `row`(저장 안 한 고친 줄)는 메모리 사본에서만 셈(`Store._swap`).
|
||||
`/logics` · `/subs` · `/logic` · 원문은 `M02_MasterTemplete_Router_Combos.py` 에 있음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as m01
|
||||
|
||||
router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"])
|
||||
|
||||
#: `/rows` 로 줄을 내줄 요소 파일 앞머리 — 인력 · 재료 · 기계(고르기 표 · 품목 찾기)
|
||||
ROW_FILES = ("인력", "재료", "기계")
|
||||
#: `/table` 로 내줄 표 파일 앞머리 — 소요량 · 계수(설명 카드 원문 표)
|
||||
TABLE_FILES = ("소요량_", "계수_")
|
||||
|
||||
|
||||
@router.get("/logic/auto")
|
||||
def get_auto(key: str) -> dict:
|
||||
return m01.get_logic_auto(key)
|
||||
|
||||
|
||||
@router.get("/logic/flow")
|
||||
def get_flow(key: str, file: str = "") -> dict:
|
||||
"""흐름 보기(읽기 · 변환해도 파일 안 바뀜)."""
|
||||
return m01.get_logic_flow(key, file)
|
||||
|
||||
|
||||
@router.get("/elements")
|
||||
def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
|
||||
return m01.get_elements(group, q, min(limit, 200))
|
||||
|
||||
|
||||
@router.get("/pick")
|
||||
def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
|
||||
return m01.get_pick(kind, q, min(limit, 200))
|
||||
|
||||
|
||||
@router.get("/materials")
|
||||
def get_materials(
|
||||
sub: str, detail: str = "", spec: str = "", region: str = "", unit: str = "", limit: int = 50
|
||||
) -> dict:
|
||||
return m01.get_materials(sub, detail, spec, region, unit, min(limit, 200))
|
||||
|
||||
|
||||
@router.get("/rows")
|
||||
def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", sub: str = "") -> dict:
|
||||
if not file.startswith(ROW_FILES):
|
||||
raise HTTPException(status_code=403, detail="요소 파일(인력 · 재료 · 기계)만 읽음")
|
||||
return m01.get_rows(file, page, size, q, False, sub, "", "")
|
||||
|
||||
|
||||
@router.get("/table")
|
||||
def get_table(file: str, key: str) -> dict:
|
||||
if not file.startswith(TABLE_FILES):
|
||||
raise HTTPException(status_code=403, detail="소요량 · 계수 표만 읽음")
|
||||
return m01.get_table(file, key)
|
||||
|
||||
|
||||
@router.post("/calc")
|
||||
def post_calc(body: m01.CalcBody) -> dict:
|
||||
"""M01 `/calc` 와 같음 — `row` = 저장 안 한 고친 줄(셈만 · 쓰기 없음)."""
|
||||
return m01.post_calc(body)
|
||||
|
||||
|
||||
@router.post("/text")
|
||||
def post_text(body: m01.CalcBody) -> dict:
|
||||
return m01.post_text(body)
|
||||
@@ -31,6 +31,9 @@ from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Logic_Settle as settle
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
mf = m01.cm.mf
|
||||
import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠
|
||||
|
||||
SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀
|
||||
KIND = "m02_logic_standard"
|
||||
REGION = "지역" # 지역 = 자동값 한 벌(정함 8)
|
||||
@@ -331,7 +334,7 @@ def sheet(key: str, change: dict) -> dict:
|
||||
raise m01.StoreError(404, str(e)) from e
|
||||
given, ax = _given(whole, logic, change)
|
||||
filled = m01.cm.mf.fill_empty(logic, given)
|
||||
got = m01.mtx.lines(files, key, filled)
|
||||
got = msl.annotate(files, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1)
|
||||
done = settle.settle(whole, key, filled)
|
||||
if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙)
|
||||
settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done)
|
||||
@@ -341,7 +344,7 @@ def sheet(key: str, change: dict) -> dict:
|
||||
"비목": g["비목"],
|
||||
"소계": g.get("소계"),
|
||||
"끝수": g.get("끝수") or "",
|
||||
"줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]],
|
||||
"줄": [{k: line.get(k) for k in ("이름", "글", "금액", *msl.KEYS)} for line in g["줄"]],
|
||||
}
|
||||
for g in got.get("groups") or []
|
||||
]
|
||||
@@ -356,5 +359,6 @@ def sheet(key: str, change: dict) -> dict:
|
||||
"입력": given,
|
||||
"묶음": groups,
|
||||
"계": got.get("계"),
|
||||
"기준단위": got.get("기준단위"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,8 +10,9 @@ export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) ·
|
||||
* 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo";
|
||||
* 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) ·
|
||||
* 「단가산출」 = sangeun(SK- 서고 · 715-2 · 717-2a) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo" | "sangeun";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -150,7 +151,7 @@ export type LibraryBody =
|
||||
{ 문서: Record<string, unknown> } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } };
|
||||
export const newLibrary = (
|
||||
layer: Layer,
|
||||
kind: "precombo",
|
||||
kind: LibraryKind,
|
||||
body: LibraryBody,
|
||||
owner?: string | null,
|
||||
): Promise<TemplateInfo> =>
|
||||
@@ -159,25 +160,49 @@ export const newLibrary = (
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/** 사전조합 나무 자유 폴더(158-1) — 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) */
|
||||
/** 서고 나무 자유 폴더(158-1 · 716-4) — 일위대가(precombo) · 단가산출(sangeun) 따로 ·
|
||||
* 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) · 지우기 = 안에 든 문서째(쓰는 곳 있으면 409) */
|
||||
export type LibraryKind = "precombo" | "sangeun";
|
||||
export interface PrecomboTree {
|
||||
판: string;
|
||||
폴더: string[][];
|
||||
자리: Record<string, string[]>;
|
||||
알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] };
|
||||
}
|
||||
export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise<PrecomboTree> =>
|
||||
call(owned(layer, `/layers/${layer}/precombo-tree`, owner));
|
||||
const libTree = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string =>
|
||||
owned(layer, `/layers/${layer}/${kind}-tree${tail}`, owner);
|
||||
export const fetchPrecomboTree = (
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
kind: LibraryKind = "precombo",
|
||||
): Promise<PrecomboTree> => call(libTree(kind, layer, owner));
|
||||
export const editPrecomboTree = (
|
||||
layer: Layer,
|
||||
edit: Pick<TreeEdit, "경로" | "이름" | "새경로" | "키"> & {
|
||||
op: "add-folder" | "rename" | "move" | "delete";
|
||||
대상?: TreeRef[];
|
||||
},
|
||||
판: string,
|
||||
owner: string | null,
|
||||
kind: LibraryKind = "precombo",
|
||||
): Promise<PrecomboTree> =>
|
||||
call(owned(layer, `/layers/${layer}/precombo-tree`, owner), {
|
||||
call(libTree(kind, layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
|
||||
/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */
|
||||
export const pasteLibraryTree = (
|
||||
kind: LibraryKind,
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
clip: TreeClip,
|
||||
dest: string[],
|
||||
): Promise<PrecomboTree> =>
|
||||
call(libTree(kind, layer, owner, "/paste"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...edit, 판 }),
|
||||
body: JSON.stringify({
|
||||
from_layer: clip.from.layer,
|
||||
from_owner: clip.from.owner,
|
||||
대상: clip.refs,
|
||||
경로: dest,
|
||||
}),
|
||||
});
|
||||
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import {
|
||||
buildHopyo,
|
||||
@@ -429,7 +430,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
const ask = whole
|
||||
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
|
||||
: `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` +
|
||||
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — 사전조합 생성`;
|
||||
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — ${L("M02_PrecomboTitle")} 생성`;
|
||||
if (!(await showConfirmDialog(ask, "생성"))) return;
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
|
||||
showLoadingOverlay();
|
||||
@@ -530,7 +531,11 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
draw();
|
||||
});
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" });
|
||||
const go = createButton({
|
||||
label: `${L("M02_PrecomboTitle")} 생성`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
});
|
||||
go.setAttribute("data-hopyo-make", "");
|
||||
go.addEventListener("click", () => void make(h));
|
||||
tools.push(all);
|
||||
@@ -573,7 +578,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
...(h.만든
|
||||
? [
|
||||
note(
|
||||
`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
|
||||
`만든 ${L("M02_PrecomboTitle")} ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
|
||||
(h.만든.품셈
|
||||
? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}`
|
||||
: ""),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
checkable,
|
||||
@@ -170,7 +171,10 @@ function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLEl
|
||||
createButton({ label, variant: "ghost", size: "sm", onClick: run });
|
||||
const out = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" },
|
||||
attrs: {
|
||||
title: `로직없음 · 사용안함 — ${L("M02_PrecomboTitle")}에 안 넣고 장부에 남김`,
|
||||
"data-out": "",
|
||||
},
|
||||
children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })),
|
||||
}) as HTMLSelectElement;
|
||||
out.value = ctx.d.뺌[line.차례] ?? "";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
readLedger,
|
||||
saveLedger,
|
||||
@@ -326,7 +327,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
className: "ui-toolbar m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`),
|
||||
note(`${LEDGER_HINT} · ${L("M02_PrecomboTitle")} 생성 때 남음 · 아래 층이 이김`),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -21,6 +22,7 @@ export function withHopyo(lib: LibraryPick): LibraryPick {
|
||||
const scope = { layer: lib.layer, owner: lib.owner };
|
||||
return {
|
||||
...lib,
|
||||
sangeun: true, // 717-4 — 일위대가 [로직 추가] 「단가산출」 탭
|
||||
kindFilter: lib.kindFilter ?? [
|
||||
{ value: "logic", text: tc("Kind_Logic") },
|
||||
{ value: "precombo", text: tc("Kind_Combo") },
|
||||
@@ -82,7 +84,7 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
|
||||
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
|
||||
const alias = row.이름.slice(0, ALIAS_MAX).trim() || null;
|
||||
if (row.확인) {
|
||||
showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success");
|
||||
showToast(`「${row.이름}」 → 확인한 ${L("M02_PrecomboTitle")} ${row.확인.키}`, "success");
|
||||
return lib.pick(row.확인, row.이름, alias);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createDataTable, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
pageScope,
|
||||
relinkApply,
|
||||
@@ -43,7 +44,7 @@ const won = (v: number | null | undefined): string =>
|
||||
export function relinkKeys(keys: string[]): RelinkSource {
|
||||
const scope = pageScope();
|
||||
return {
|
||||
title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`,
|
||||
title: `이 공사 단가로 다시 잇기 — ${L("M02_PrecomboTitle")} ${keys.length}`,
|
||||
priceLabel: "최신 판 단가",
|
||||
rows: () => relinkRows(scope, { 키: keys }),
|
||||
apply: async (changes) => {
|
||||
@@ -51,7 +52,7 @@ export function relinkKeys(keys: string[]): RelinkSource {
|
||||
const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0);
|
||||
return {
|
||||
back: got.되돌림,
|
||||
note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`,
|
||||
note: `바뀐 줄 ${got.바뀜} · ${L("M02_PrecomboTitle")} ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
@@ -239,7 +240,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 ${L("M02_PrecomboTitle")} · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "삭제"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, opt.key);
|
||||
@@ -427,7 +428,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length;
|
||||
const ask =
|
||||
`고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` +
|
||||
`고른 호표 ${nos.length}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` +
|
||||
(fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : "");
|
||||
if (!(await showConfirmDialog(ask, "저장"))) return;
|
||||
go.disabled = true;
|
||||
@@ -510,7 +511,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}
|
||||
if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));
|
||||
result.replaceChildren(
|
||||
table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]),
|
||||
table(
|
||||
["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"],
|
||||
[...rows, ...skipped],
|
||||
),
|
||||
);
|
||||
const all = order.length + skipped.length;
|
||||
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
|
||||
@@ -526,7 +530,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
/** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */
|
||||
async function importAll(): Promise<void> {
|
||||
const ask =
|
||||
`호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) 사전조합으로 들여옴 — ` +
|
||||
`호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` +
|
||||
"이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]";
|
||||
if (!(await showConfirmDialog(ask, "들여오기"))) return;
|
||||
fixAll.disabled = true;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
buildHopyoItem,
|
||||
hopyoSiblings,
|
||||
@@ -101,7 +102,7 @@ export async function toItem(o: ToItemOptions): Promise<void> {
|
||||
actions.append(go);
|
||||
body.append(
|
||||
createText(
|
||||
"체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦",
|
||||
`체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — ${L("M02_PrecomboTitle")}을 거치지 않고 구조물 조합 항목을 바로 만듦`,
|
||||
"muted",
|
||||
),
|
||||
name.root,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Wizard.ts
|
||||
* 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋:
|
||||
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림
|
||||
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림
|
||||
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
|
||||
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
|
||||
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층
|
||||
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록
|
||||
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 ·
|
||||
* 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음)
|
||||
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록
|
||||
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type HopyoFile,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
|
||||
const LAYERS: { layer: ManageLayer; text: string }[] = [
|
||||
@@ -35,9 +37,6 @@ const STEPS = [
|
||||
{ value: "2", label: "② 보기 · 확인 · 만들기" },
|
||||
{ value: "3", label: "③ 끝내기" },
|
||||
];
|
||||
/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */
|
||||
export const SANGEUN_READY = false;
|
||||
const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬";
|
||||
|
||||
type Got = HopyoFile & { 이어서?: boolean };
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
@@ -70,6 +69,7 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
});
|
||||
let layers: ManageLayer[] = [];
|
||||
let pick: ManageLayer = "personal";
|
||||
let wantSangeun = false; // 결과 「단가산출」 체크
|
||||
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
|
||||
|
||||
async function first(): Promise<void> {
|
||||
@@ -112,15 +112,17 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
});
|
||||
seg.root.dataset.hopyoLayer = "";
|
||||
const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true });
|
||||
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY });
|
||||
sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY;
|
||||
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", {
|
||||
checked: wantSangeun,
|
||||
onChange: (on) => (wantSangeun = on),
|
||||
});
|
||||
sangeun.root.dataset.hopyoSangeun = "";
|
||||
const busy = el("div", { className: "m02-hopyo" });
|
||||
body.replaceChildren(
|
||||
createText("결과 층", "muted", 14),
|
||||
el("div", { className: "ui-toolbar", children: [seg.root] }),
|
||||
createText("결과", "muted", 14),
|
||||
el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }),
|
||||
...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]),
|
||||
el("div", { className: "ui-toolbar", children: [upload, input] }),
|
||||
createPanelHead({ title: "작업 중", level: 4, divider: true }).root,
|
||||
busy,
|
||||
@@ -169,6 +171,14 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
)
|
||||
.catch((error: unknown) => showToast(why(error), "error")),
|
||||
});
|
||||
const sangeun = createButton({
|
||||
label: "단가산출 만들기",
|
||||
variant: "filled",
|
||||
onClick: () => void makeSangeun(),
|
||||
});
|
||||
sangeun.title =
|
||||
"엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)";
|
||||
sangeun.dataset.hopyoSangeunMake = "";
|
||||
const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() });
|
||||
finish.dataset.hopyoFinish = "";
|
||||
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
|
||||
@@ -177,22 +187,44 @@ export function buildHopyoWizard(): HTMLElement {
|
||||
body.replaceChildren(
|
||||
createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14),
|
||||
createText(
|
||||
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감",
|
||||
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감",
|
||||
"hint",
|
||||
),
|
||||
el("div", { className: "ui-toolbar", children: [view, finish, back] }),
|
||||
el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [view, ...(wantSangeun ? [sangeun] : []), finish, back],
|
||||
}),
|
||||
);
|
||||
|
||||
async function makeSangeun(): Promise<void> {
|
||||
sangeun.disabled = true;
|
||||
try {
|
||||
const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>(
|
||||
`/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const kind = got.블록 ? "success" : "warning";
|
||||
showToast(
|
||||
`단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`,
|
||||
kind,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(why(error), "error");
|
||||
} finally {
|
||||
sangeun.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function end(): Promise<void> {
|
||||
steps.set("3");
|
||||
const ok = await showConfirmDialog(
|
||||
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`,
|
||||
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`,
|
||||
"끝내기",
|
||||
);
|
||||
if (!ok) return steps.set("2");
|
||||
try {
|
||||
const got = (await deleteHopyo(scope, file.키)) as { 만든?: number };
|
||||
showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
|
||||
showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
|
||||
} catch (error) {
|
||||
showToast(why(error), "error");
|
||||
return steps.set("2");
|
||||
|
||||
@@ -342,7 +342,7 @@ export interface LibraryRow {
|
||||
/** 서고 목록 — 그 층(주인) 사전조합 `precombo` 문서 */
|
||||
export const fetchLibrary = async (
|
||||
ref: Omit<LaborRef, "키">,
|
||||
kind: "precombo",
|
||||
kind: "precombo" | "sangeun",
|
||||
): Promise<LibraryRow[]> => {
|
||||
const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : "";
|
||||
const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>(
|
||||
|
||||
@@ -16,13 +16,7 @@
|
||||
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
@@ -64,15 +58,15 @@ import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { logicTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
|
||||
import { laborGrid } from "./M02_MasterTemplete_Labor_Grid";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
import { addMaterialButton } from "./M02_Material_Pick";
|
||||
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
|
||||
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
|
||||
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
@@ -574,52 +568,6 @@ export function mountLaborDetail(
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
|
||||
/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 ·
|
||||
* 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */
|
||||
const meta = cur as { 구분?: string | null; 상세구분?: string | null };
|
||||
const libMeta = (): HTMLElement[] => {
|
||||
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
|
||||
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
|
||||
sub.root.dataset.laborField = "구분";
|
||||
detail.root.dataset.laborField = "상세구분";
|
||||
let subs: { name: string; details: string[] }[] = [];
|
||||
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
|
||||
const opts = (names: string[], now: string | null | undefined) =>
|
||||
[...new Set([now ?? "", ...names])]
|
||||
.filter((v, i) => v || i === 0)
|
||||
.map((v) => ({ value: v, text: v || "—" }));
|
||||
const fill = (): void => {
|
||||
sub.setOptions(
|
||||
opts(
|
||||
subs.map((s) => s.name),
|
||||
meta.구분,
|
||||
),
|
||||
meta.구분 ?? "",
|
||||
);
|
||||
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
|
||||
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
|
||||
};
|
||||
sub.select.addEventListener("change", () => {
|
||||
meta.구분 = sub.select.value;
|
||||
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
|
||||
fill();
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
detail.select.addEventListener("change", () => {
|
||||
meta.상세구분 = detail.select.value;
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
fill();
|
||||
void fetchStructureDef().then(
|
||||
(def) => {
|
||||
subs = precomboClasses(def);
|
||||
fill();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return [sub.root, detail.root];
|
||||
};
|
||||
|
||||
const editor = (body: HTMLElement): HTMLElement =>
|
||||
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
|
||||
host.replaceChildren(
|
||||
@@ -637,7 +585,9 @@ export function mountLaborDetail(
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
...(lib ? [nameField.root, ...libMeta()] : []),
|
||||
...(lib
|
||||
? [nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty()))]
|
||||
: []),
|
||||
unit.root,
|
||||
origin.root,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_LibMeta.ts
|
||||
* 서고 모드(일위대가 · 사전조합) 정보 칸 구분 · 상세구분 고르기(51-12 · 134-5 · `Labor_Detail` 에서 뗌 · 717-3)
|
||||
* 고침 = 문서 `구분` · `상세구분` 바로(화면 캐시) · 바꾼 뒤 `changed()`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSelectField } from "@ui/ui_template_elements";
|
||||
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
|
||||
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 ·
|
||||
* 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */
|
||||
export function libMeta(doc: unknown, edit: boolean, changed: () => void): HTMLElement[] {
|
||||
const meta = doc as { 구분?: string | null; 상세구분?: string | null };
|
||||
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
|
||||
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
|
||||
sub.root.dataset.laborField = "구분";
|
||||
detail.root.dataset.laborField = "상세구분";
|
||||
let subs: { name: string; details: string[] }[] = [];
|
||||
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
|
||||
const opts = (names: string[], now: string | null | undefined) =>
|
||||
[...new Set([now ?? "", ...names])]
|
||||
.filter((v, i) => v || i === 0)
|
||||
.map((v) => ({ value: v, text: v || "—" }));
|
||||
const fill = (): void => {
|
||||
sub.setOptions(
|
||||
opts(
|
||||
subs.map((s) => s.name),
|
||||
meta.구분,
|
||||
),
|
||||
meta.구분 ?? "",
|
||||
);
|
||||
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
|
||||
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
|
||||
};
|
||||
sub.select.addEventListener("change", () => {
|
||||
meta.구분 = sub.select.value;
|
||||
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
|
||||
fill();
|
||||
changed();
|
||||
});
|
||||
detail.select.addEventListener("change", () => {
|
||||
meta.상세구분 = detail.select.value;
|
||||
changed();
|
||||
});
|
||||
fill();
|
||||
void fetchStructureDef().then(
|
||||
(def) => {
|
||||
subs = precomboClasses(def);
|
||||
fill();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return [sub.root, detail.root];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_MatTable.ts
|
||||
* 일위대가 자재목록 표(43-3 · `Labor_Rows` 에서 뗌 · 717-3) — 상세 · 보기 갈래가 같이 씀.
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액.
|
||||
* 금액은 calc 답(없으면 「…」) · 줄 누름 · [삭제]는 `on` 이 있을 때만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type {
|
||||
LaborCalc,
|
||||
LaborCalcLine,
|
||||
LaborCalcMat,
|
||||
LaborMat,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import {
|
||||
moneyCell,
|
||||
PRICE_SLOTS,
|
||||
qtyCell,
|
||||
table,
|
||||
titled,
|
||||
type RowHandlers,
|
||||
} from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
export interface MatTableData {
|
||||
mats: LaborMat[];
|
||||
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
|
||||
lead?: HTMLElement[];
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
|
||||
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
|
||||
* 보기 갈래는 `on` 없이 */
|
||||
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const remove = on?.remove;
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
const trs = data.mats.map((mat, i) => {
|
||||
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
|
||||
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
|
||||
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
|
||||
const base = !!line?.기준칸 && line.기준칸 === slot;
|
||||
return el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
|
||||
text: show(line?.가격?.[slot]),
|
||||
attrs: {
|
||||
"data-label": slot,
|
||||
"data-price-slot": slot,
|
||||
...(base ? { title: tc("Labor_BasePrice") } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__code", text: mat.코드 }),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
),
|
||||
el("td", {
|
||||
className: "m02-labrow__spec",
|
||||
text: mat.규격 ?? "",
|
||||
attrs: { title: mat.규격 ?? "" },
|
||||
}),
|
||||
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
text: mat.단위 ?? "",
|
||||
attrs: { "data-label": tc("Labor_Unit") },
|
||||
}),
|
||||
...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined),
|
||||
...PRICE_SLOTS.map(priceCell),
|
||||
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
|
||||
...(remove
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "danger",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
remove(i);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
return tr;
|
||||
});
|
||||
const heads = [
|
||||
tc("Labor_Code"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_Spec"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Labor_Unit"),
|
||||
...markHeads(),
|
||||
...PRICE_SLOTS,
|
||||
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
const lead = data.lead ?? [];
|
||||
return data.mats.length || lead.length
|
||||
? table(heads, [...lead, ...trs], [])
|
||||
: el("p", { className: "ui-text ui-text--muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 ·
|
||||
* 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 ·
|
||||
* 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`)
|
||||
* 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를
|
||||
* 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
@@ -37,6 +39,8 @@ import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createTabs } from "@ui/ui_template_tabs";
|
||||
import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
|
||||
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
|
||||
|
||||
@@ -71,6 +75,8 @@ export interface LibraryPick {
|
||||
more?: () => Promise<PickExtra[]>;
|
||||
/** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */
|
||||
kindFilter?: Array<{ value: string; text: string }>;
|
||||
/** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */
|
||||
sangeun?: boolean;
|
||||
}
|
||||
|
||||
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
|
||||
@@ -107,6 +113,9 @@ export function openLogicPicker(
|
||||
const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") });
|
||||
detail.select.disabled = true;
|
||||
let close = (): void => {};
|
||||
let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈
|
||||
let tab: "std" | "sk" = "std";
|
||||
let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null
|
||||
|
||||
/* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */
|
||||
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
|
||||
@@ -141,15 +150,7 @@ export function openLogicPicker(
|
||||
{ key: "출처", label: tc("Std_Source"), width: 150 },
|
||||
],
|
||||
rows: [],
|
||||
onRowClick: (i) => {
|
||||
const row = listed[i];
|
||||
const logic = all.find((x) => x.키 === row?.키);
|
||||
if (row && logic)
|
||||
openStandardSheet(row, () => {
|
||||
close();
|
||||
done(logic);
|
||||
});
|
||||
},
|
||||
onRowClick: (i) => rowPick[i]?.(),
|
||||
});
|
||||
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
|
||||
const plain = (x: LogicSummary): StandardRow => ({
|
||||
@@ -170,6 +171,7 @@ export function openLogicPicker(
|
||||
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
if (tab === "sk") return findSangeun(q);
|
||||
const rows = std ?? all.map(plain);
|
||||
const found = rows.filter(
|
||||
(x) =>
|
||||
@@ -200,6 +202,14 @@ export function openLogicPicker(
|
||||
? `${shown} / ${total}`
|
||||
: tc("Pick_None");
|
||||
listed = found;
|
||||
rowPick = listed.map((row) => () => {
|
||||
const logic = all.find((x) => x.키 === row.키);
|
||||
if (logic)
|
||||
openStandardSheet(row, () => {
|
||||
close();
|
||||
done(logic);
|
||||
});
|
||||
});
|
||||
table.root.hidden = !!lib?.libOnly;
|
||||
table.setRows(
|
||||
found.map((x) => ({
|
||||
@@ -240,6 +250,32 @@ export function openLogicPicker(
|
||||
})),
|
||||
]);
|
||||
};
|
||||
/** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */
|
||||
const findSangeun = (q: string): void => {
|
||||
const rows = (skRows ?? []).filter(
|
||||
(x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q),
|
||||
);
|
||||
finder.hint.textContent = !skRows
|
||||
? tc("Loading")
|
||||
: rows.length
|
||||
? `${rows.length} / ${skRows.length}`
|
||||
: tc("Pick_None");
|
||||
rowPick = rows.map((x) => () => {
|
||||
close();
|
||||
lib?.pick(x.ref, x.제목 || x.이름);
|
||||
});
|
||||
table.root.hidden = false;
|
||||
table.setRows(
|
||||
rows.map((x) => ({
|
||||
품명: x.제목 || x.이름,
|
||||
규격: [x.구분, x.상세구분].filter(Boolean).join(" "),
|
||||
단위: x.단위 ?? "",
|
||||
금액: x.기준금액,
|
||||
출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`,
|
||||
})),
|
||||
);
|
||||
finder.setRows([]);
|
||||
};
|
||||
const loadLib = async (): Promise<void> => {
|
||||
if (!lib || !scope) return;
|
||||
const mine = ++seq;
|
||||
@@ -303,12 +339,46 @@ export function openLogicPicker(
|
||||
finder.search.addEventListener("input", find);
|
||||
|
||||
layerBar.style.display = "none";
|
||||
const hint = createText(tc("Std_Hint"));
|
||||
/** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */
|
||||
const tabs = lib?.sangeun
|
||||
? createTabs({
|
||||
tabs: [
|
||||
{ key: "std", label: L("M02_PickStd") },
|
||||
{ key: "sk", label: L("M02_PickSangeun") },
|
||||
],
|
||||
value: tab,
|
||||
kind: "line",
|
||||
onChange: (key) => {
|
||||
tab = key === "sk" ? "sk" : "std";
|
||||
const std = tab === "std";
|
||||
for (const f of [kindSel?.root, work.root, sub.root, detail.root])
|
||||
if (f) f.style.display = std ? "" : "none";
|
||||
layerBar.style.display = std && scopes.length ? "" : "none";
|
||||
hint.hidden = !std;
|
||||
find();
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (lib)
|
||||
void upper(lib.layer, lib.owner).then((got) => {
|
||||
scopes = got;
|
||||
if (tabs)
|
||||
listSangeun(got).then(
|
||||
(rows) => {
|
||||
skRows = rows;
|
||||
tabs.setBadge("sk", rows.length);
|
||||
find();
|
||||
},
|
||||
(error) => {
|
||||
skRows = [];
|
||||
find();
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||||
},
|
||||
);
|
||||
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
|
||||
if (!got.length) return;
|
||||
layerBar.style.display = "";
|
||||
layerBar.style.display = tab === "std" ? "" : "none";
|
||||
drawLayers();
|
||||
void loadLib();
|
||||
void fetchStructureDef().then(
|
||||
@@ -331,7 +401,7 @@ export function openLogicPicker(
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "m02-std-pick",
|
||||
children: [finder.root, createText(tc("Std_Hint")), table.root],
|
||||
children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
|
||||
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 구분 ~ 별칭에 걸친 한 줄(나머지 칸은 위 줄과 두 줄 병합 · 113-1) ·
|
||||
* 맨 아래 합계 줄(전체 합계 강조).
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
||||
* 자재목록 표 = `Labor_MatTable`(717-3 나눔).
|
||||
* 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] ·
|
||||
* 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 일위조합 줄 = [수정](`ref` · 서고 원본 · 옛 단가승격 참조 줄 = 단추 없음) ·
|
||||
@@ -20,14 +20,11 @@ import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type {
|
||||
LaborCalc,
|
||||
LaborCalcLine,
|
||||
LaborCalcMat,
|
||||
LaborCost,
|
||||
LaborMat,
|
||||
LaborRow,
|
||||
MatMarkKey,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
@@ -124,7 +121,7 @@ export function qtyShort(
|
||||
|
||||
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「수기 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
|
||||
* `open` 이 있으면 단추(줄 누름과 따로) */
|
||||
function qtyCell(
|
||||
export function qtyCell(
|
||||
line: LaborCalcLine | undefined,
|
||||
cells: ReadonlyMap<string, string> | undefined,
|
||||
open: (() => void) | null,
|
||||
@@ -180,12 +177,12 @@ export function moneyCell(
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
export const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
};
|
||||
|
||||
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
|
||||
export const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap",
|
||||
children: [
|
||||
@@ -565,107 +562,5 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface MatTableData {
|
||||
mats: LaborMat[];
|
||||
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
|
||||
lead?: HTMLElement[];
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
|
||||
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
|
||||
|
||||
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
|
||||
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
|
||||
* 보기 갈래는 `on` 없이 */
|
||||
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const remove = on?.remove;
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
const trs = data.mats.map((mat, i) => {
|
||||
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
|
||||
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
|
||||
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
|
||||
const base = !!line?.기준칸 && line.기준칸 === slot;
|
||||
return el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
|
||||
text: show(line?.가격?.[slot]),
|
||||
attrs: {
|
||||
"data-label": slot,
|
||||
"data-price-slot": slot,
|
||||
...(base ? { title: tc("Labor_BasePrice") } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__code", text: mat.코드 }),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
),
|
||||
el("td", {
|
||||
className: "m02-labrow__spec",
|
||||
text: mat.규격 ?? "",
|
||||
attrs: { title: mat.규격 ?? "" },
|
||||
}),
|
||||
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
text: mat.단위 ?? "",
|
||||
attrs: { "data-label": tc("Labor_Unit") },
|
||||
}),
|
||||
...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined),
|
||||
...PRICE_SLOTS.map(priceCell),
|
||||
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
|
||||
...(remove
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "danger",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
remove(i);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
return tr;
|
||||
});
|
||||
const heads = [
|
||||
tc("Labor_Code"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_Spec"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Labor_Unit"),
|
||||
...markHeads(),
|
||||
...PRICE_SLOTS,
|
||||
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
const lead = data.lead ?? [];
|
||||
return data.mats.length || lead.length
|
||||
? table(heads, [...lead, ...trs], [])
|
||||
: el("p", { className: "ui-text ui-text--muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
* M02_MasterTemplete_Labor_Sheet.ts
|
||||
* 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다
|
||||
* 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 ·
|
||||
* [담기] = 부르는 쪽 담기 동작 그대로(`put`).
|
||||
* [담기] = 부르는 쪽 담기 동작 그대로(`put`). 줄 · 소계 · 계 = 공용 계산서(`A00_Common/calc_sheet.ts` · 714-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
const won = (v: number | null, digits = 0): string =>
|
||||
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
|
||||
|
||||
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
|
||||
|
||||
export function openStandardSheet(row: StandardRow, put: () => void): void {
|
||||
const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } });
|
||||
sheet.append(createText(tc("Loading")));
|
||||
@@ -51,19 +45,12 @@ export function openStandardSheet(row: StandardRow, put: () => void): void {
|
||||
createText(head, "muted", 14),
|
||||
createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"),
|
||||
];
|
||||
for (const g of got.묶음) {
|
||||
for (const line of g.줄) {
|
||||
const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
|
||||
lines.push(
|
||||
createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)),
|
||||
...calcSheetRows({
|
||||
묶음: got.묶음,
|
||||
계: got.계,
|
||||
sumLabel: `${tc("Std_Sum")} (원/${got.단위 || "식"})`,
|
||||
}),
|
||||
);
|
||||
sheet.replaceChildren(...lines);
|
||||
source.show(got.출처);
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { logicTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Layer.ts
|
||||
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…`
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면|일위대가조합|단가산출&root=master|company|personal&owner=…&company=…&pre=…`
|
||||
* (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
|
||||
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용 복사] 막대.
|
||||
* ========================================================================== */
|
||||
@@ -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;
|
||||
@@ -29,7 +29,7 @@ export interface Entry {
|
||||
pre: Root | null;
|
||||
}
|
||||
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"];
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"];
|
||||
const ROOTS: Root[] = ["master", "company", "personal"];
|
||||
|
||||
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Library_List.ts
|
||||
* 일위대가 사전조합 서고 목록표(51-4) — 좌측 사전조합 층 단추를 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
|
||||
* 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 ·
|
||||
* 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`).
|
||||
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
|
||||
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
|
||||
* ========================================================================== */
|
||||
@@ -28,10 +29,10 @@ import {
|
||||
saveTemplate,
|
||||
type Layer,
|
||||
type LibraryBody,
|
||||
type LibraryKind,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */
|
||||
type Row = TemplateInfo & {
|
||||
@@ -40,13 +41,16 @@ type Row = TemplateInfo & {
|
||||
기준금액?: number | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
바탕?: string | null;
|
||||
고친줄수?: number;
|
||||
쓰는곳수?: number;
|
||||
};
|
||||
|
||||
export interface LibraryListOptions {
|
||||
layer: Layer;
|
||||
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */
|
||||
owner: string | null;
|
||||
kind: PrecomboKind;
|
||||
kind: LibraryKind;
|
||||
canEdit: boolean;
|
||||
/** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */
|
||||
open: (key: string, label: string) => void;
|
||||
@@ -60,6 +64,36 @@ const money = (v: number | null | undefined): string =>
|
||||
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름);
|
||||
|
||||
/** 새 단가산출 몸(717-2a) — 바탕 없음 · 빈 더한줄 · 단위 「식」(서버 필수 · 상세에서 고침 · 717-2b) */
|
||||
export const sangeunBlank = (name: string): Record<string, unknown> => ({
|
||||
이름: name,
|
||||
단위: "식",
|
||||
바탕: null,
|
||||
더한줄: [],
|
||||
});
|
||||
/** 표 열 — 머리 글 · 줄 값(일위대가 = 구분 둘 · 단가산출 = 바탕 · 고친 줄 · 쓰는 곳) */
|
||||
const COLUMNS: Record<LibraryKind, () => [string, (row: Row) => string][]> = {
|
||||
precombo: () => [
|
||||
[tc("Lib_Key"), (r) => r.이름],
|
||||
[tc("Sub"), (r) => r.구분 || tc("Lib_Unsorted")], // 빈 구분 = 미분류(149-2 · 나무와 같은 말)
|
||||
[tc("Detail"), (r) => r.상세구분 ?? ""],
|
||||
[tc("Lib_Name"), (r) => r.제목 ?? ""],
|
||||
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
|
||||
[tc("Lib_Base"), (r) => money(r.기준금액)],
|
||||
[tc("Lib_Date"), (r) => r.수정일],
|
||||
],
|
||||
sangeun: () => [
|
||||
[tc("Lib_Key"), (r) => r.이름],
|
||||
[tc("Lib_Name"), (r) => r.제목 ?? ""],
|
||||
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
|
||||
[L("M02_SangeunBase"), (r) => r.바탕 || L("M02_SangeunNoBase")],
|
||||
[L("M02_SangeunEdited"), (r) => String(r.고친줄수 ?? 0)],
|
||||
[L("M02_SangeunUsers"), (r) => String(r.쓰는곳수 ?? 0)],
|
||||
[tc("Lib_Base"), (r) => money(r.기준금액)],
|
||||
[tc("Lib_Date"), (r) => r.수정일],
|
||||
],
|
||||
};
|
||||
|
||||
/** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */
|
||||
export const nameDialog = (
|
||||
title: string,
|
||||
@@ -101,6 +135,7 @@ export function mountLibraryList(
|
||||
tools: HTMLElement,
|
||||
opt: LibraryListOptions,
|
||||
): { getDoc: () => null; destroy: () => void } {
|
||||
const columns = COLUMNS[opt.kind]();
|
||||
let rows: Row[] = [];
|
||||
let picked: string | null = null;
|
||||
let page = 1;
|
||||
@@ -144,15 +179,9 @@ export function mountLibraryList(
|
||||
attrs: { "data-lib-key": row.이름 },
|
||||
children: [
|
||||
pick,
|
||||
...[
|
||||
row.이름,
|
||||
row.구분 || tc("Lib_Unsorted"), // 빈 구분 = 미분류(149-2 · 나무와 같은 말)
|
||||
row.상세구분 ?? "",
|
||||
row.제목 ?? "",
|
||||
row.단위 ?? "",
|
||||
money(row.기준금액),
|
||||
row.수정일,
|
||||
].map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
...columns
|
||||
.map(([, value]) => value(row))
|
||||
.map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row)));
|
||||
@@ -160,7 +189,12 @@ export function mountLibraryList(
|
||||
})
|
||||
: [
|
||||
el("tr", {
|
||||
children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })],
|
||||
children: [
|
||||
el("td", {
|
||||
text: L("M02_NoTemplates"),
|
||||
attrs: { colspan: String(columns.length + 1) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -191,7 +225,10 @@ export function mountLibraryList(
|
||||
/** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */
|
||||
btnNew.addEventListener("click", () => {
|
||||
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
||||
const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
||||
const 문서 =
|
||||
opt.kind === "sangeun"
|
||||
? sangeunBlank(name)
|
||||
: { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
||||
await create({ 문서 }, name);
|
||||
});
|
||||
});
|
||||
@@ -244,16 +281,7 @@ export function mountLibraryList(
|
||||
page = 1;
|
||||
draw();
|
||||
});
|
||||
const heads = [
|
||||
"",
|
||||
"Lib_Key",
|
||||
"Sub",
|
||||
"Detail",
|
||||
"Lib_Name",
|
||||
"Lib_Unit",
|
||||
"Lib_Base",
|
||||
"Lib_Date",
|
||||
] as const;
|
||||
const heads = ["", ...columns.map(([head]) => head)];
|
||||
const gridBox = el("div", {
|
||||
className: "ui-table-wrap m01-master__grid-wrap",
|
||||
attrs: { "data-lib-list": opt.kind },
|
||||
@@ -262,9 +290,7 @@ export function mountLibraryList(
|
||||
className: "ui-table m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }),
|
||||
],
|
||||
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Pick.ts
|
||||
* M02 단가산출(SK) 고르기(PLAN 717-4) — 위 층 SK 목록 한 벌 · 단가산출 더한줄 고르기.
|
||||
* · `listSangeun` = 층 목록 길(`/layers/{층}/templates` · 종류 `sangeun`)을 마스터 › 회사 › 개인 차례로 이음 ·
|
||||
* 금액 = 서버가 붙인 1 단위 `기준금액` · 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 출처가 씀
|
||||
* · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침)
|
||||
* · `openSangeunLinePicker` = 단가산출 더한줄 고르기 — 같은 [로직 추가] 창(M01 로직 · 일위대가 · 단가산출 ·
|
||||
* M01 재료) → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴)
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
|
||||
export const SANGEUN_KIND = "sangeun";
|
||||
/** 층 차례 — 위에서 아래 */
|
||||
export const LAYER_ORDER = ["system", "company", "personal"] as const;
|
||||
|
||||
/** SK 목록 한 줄 */
|
||||
export interface SangeunRow {
|
||||
ref: LaborRef;
|
||||
이름: string;
|
||||
제목: string | null;
|
||||
단위: string | null;
|
||||
구분: string | null;
|
||||
상세구분: string | null;
|
||||
기준금액: number | null;
|
||||
}
|
||||
|
||||
interface ListRow {
|
||||
종류: string;
|
||||
이름: string;
|
||||
제목?: string | null;
|
||||
단위?: string | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
기준금액?: number | null;
|
||||
}
|
||||
|
||||
/** 위 층 SK — 층 차례(마스터 › 회사 › 개인) · 층 안은 서버 차례 */
|
||||
export async function listSangeun(scopes: Omit<LaborRef, "키">[]): Promise<SangeunRow[]> {
|
||||
const rank = (x: Omit<LaborRef, "키">): number =>
|
||||
LAYER_ORDER.indexOf(x.층 as (typeof LAYER_ORDER)[number]);
|
||||
const sorted = [...scopes].sort((a, b) => rank(a) - rank(b));
|
||||
const lists = await Promise.all(
|
||||
sorted.map(async (at) => {
|
||||
const q = at.주인 && at.층 !== "system" ? `?owner=${encodeURIComponent(at.주인)}` : "";
|
||||
const got = await call<{ 양식: ListRow[] }>(`/layers/${at.층}/templates${q}`);
|
||||
return got.양식
|
||||
.filter((r) => r.종류 === SANGEUN_KIND)
|
||||
.map<SangeunRow>((r) => ({
|
||||
ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 },
|
||||
이름: r.이름,
|
||||
제목: r.제목 ?? null,
|
||||
단위: r.단위 ?? null,
|
||||
구분: r.구분 ?? null,
|
||||
상세구분: r.상세구분 ?? null,
|
||||
기준금액: r.기준금액 ?? null,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
return lists.flat();
|
||||
}
|
||||
|
||||
/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */
|
||||
export type SangeunLine =
|
||||
| { 종류: "로직"; 로직: string; 입력값: Record<string, never>; 수량: number }
|
||||
| { 참조: LaborRef; 수량: number }
|
||||
| { 종류: "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number };
|
||||
|
||||
/** 단가산출 더한줄 고르기 — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */
|
||||
export function openSangeunLinePicker(
|
||||
scope: { layer: Layer; owner: string | null },
|
||||
done: (line: SangeunLine) => void,
|
||||
): void {
|
||||
const element = L("M02_PickElement");
|
||||
openLogicPicker((x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), {
|
||||
...scope,
|
||||
sangeun: true,
|
||||
pick: (ref) => done({ 참조: ref, 수량: 1 }),
|
||||
kindFilter: [
|
||||
{ value: "logic", text: tc("Kind_Logic") },
|
||||
{ value: "precombo", text: L("M02_PrecomboTitle") },
|
||||
{ value: "element", text: element },
|
||||
],
|
||||
extra: [
|
||||
{
|
||||
kind: "element",
|
||||
key: "element:material",
|
||||
tag: element,
|
||||
name: L("M02_PickMaterial"),
|
||||
sub: "",
|
||||
unit: "",
|
||||
onPick: () =>
|
||||
pickMaterial((m) =>
|
||||
done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 }),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_View.ts
|
||||
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
|
||||
* 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표.
|
||||
* 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 ·
|
||||
* [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createKv } from "@ui/ui_template_kv";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */
|
||||
export interface SangeunDoc {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
바탕?: { 로직?: string; 참조?: { 키?: string } } | null;
|
||||
덮개?: Record<string, unknown>;
|
||||
더한줄?: unknown[];
|
||||
뺀줄?: unknown[];
|
||||
열린입력?: string[];
|
||||
}
|
||||
|
||||
/** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */
|
||||
export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
|
||||
readOnly: boolean;
|
||||
onChanged: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */
|
||||
export const baseText = (doc: SangeunDoc): string =>
|
||||
doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
|
||||
|
||||
export function mountSangeunView(
|
||||
host: HTMLElement,
|
||||
doc: unknown,
|
||||
_opt: SangeunViewOptions,
|
||||
): { getDoc: () => unknown; destroy: () => void } {
|
||||
const sk = (doc ?? {}) as SangeunDoc;
|
||||
const count = (v: unknown[] | Record<string, unknown> | undefined): number =>
|
||||
Array.isArray(v) ? v.length : Object.keys(v ?? {}).length;
|
||||
const head = createKv([
|
||||
{ label: L("M02_Name"), value: sk.이름 ?? "" },
|
||||
{ label: tc("Lib_Unit"), value: sk.단위 ?? "" },
|
||||
{ label: L("M02_SangeunBase"), value: baseText(sk) },
|
||||
{ label: L("M02_SangeunCover"), value: count(sk.덮개) },
|
||||
{ label: L("M02_SangeunAdded"), value: count(sk.더한줄) },
|
||||
{ label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) },
|
||||
{ label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null },
|
||||
]);
|
||||
const slot = el("div", {
|
||||
attrs: { "data-sangeun-detail": sk.키 ?? "" },
|
||||
children: [createEmpty({ text: L("M02_SangeunDetailSoon") })],
|
||||
});
|
||||
host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] }));
|
||||
return { getDoc: () => doc, destroy: () => host.replaceChildren() };
|
||||
}
|
||||
@@ -357,6 +357,12 @@ export function createMain(
|
||||
const readOnly = !writable(at);
|
||||
const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠
|
||||
host.replaceChildren();
|
||||
if (at.kind === "sangeun") {
|
||||
const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a
|
||||
const onChanged = (d: boolean): boolean => (dirty = d && !readOnly);
|
||||
if (mine === seq) editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged });
|
||||
return;
|
||||
}
|
||||
if (at.kind === "table") {
|
||||
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
SHEET_MODS,
|
||||
@@ -503,7 +509,7 @@ export function createMain(
|
||||
return empty("");
|
||||
}
|
||||
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
|
||||
if (isPrecombo(next.kind) && !next.name) {
|
||||
if ((isPrecombo(next.kind) || next.kind === "sangeun") && !next.name) {
|
||||
// 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세
|
||||
save.hidden = true;
|
||||
const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List");
|
||||
|
||||
@@ -53,6 +53,7 @@ import { canEdit, createMain, newItemButton, type Selection } from "./M02_Master
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem";
|
||||
import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
@@ -248,6 +249,16 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
reselect: () =>
|
||||
void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))),
|
||||
});
|
||||
// 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음
|
||||
let sk = preScope("personal");
|
||||
const skBar = (): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: sk.root, owner: sk.owner },
|
||||
company,
|
||||
go: (next) => ((sk = preScope(next)), void side.refreshSangeun()),
|
||||
reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))),
|
||||
});
|
||||
const side = buildSide({
|
||||
layer,
|
||||
isAdmin,
|
||||
@@ -264,6 +275,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
bar: preBar,
|
||||
more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name),
|
||||
},
|
||||
sangeun: { scope: () => sk, bar: skBar },
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3) — 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. 「단가산출」 칸은 맨 위(717-2a).
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서.
|
||||
* 구조물 옵션 정의는 M01 로 옮김(716-1).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
@@ -36,6 +36,7 @@ import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
|
||||
@@ -67,6 +68,8 @@ export interface SideOptions {
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
|
||||
/** 「단가산출」 층(717-2a · 일위대가 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||
sangeun: Pick<SangeunOptions, "scope" | "bar">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -75,9 +78,11 @@ export interface SideHandle {
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합" | "단가산출") => void;
|
||||
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
|
||||
refreshPrecombo: () => Promise<void>;
|
||||
/** 단가산출 층이 바뀜(717-2a) — 그 컨테이너만 다시 그림 */
|
||||
refreshSangeun: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -133,7 +138,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 일위대가 서고(50-3 · 134-3) — 맨 위 칸 · 층은 페이지 층과 따로
|
||||
// 일위대가 서고(50-3 · 134-3) — 단가산출 다음 칸 · 층은 페이지 층과 따로
|
||||
const precombo = buildPrecomboSection({
|
||||
...opt.precombo,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
@@ -141,10 +146,18 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const precomboSec = section(L("M02_PrecomboTitle"), precombo.body);
|
||||
// 단가산출 서고(717-2a) — 맨 위 칸 · 층은 일위대가 층과 따로
|
||||
const sangeun = buildSangeunSection({
|
||||
...opt.sangeun,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
|
||||
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
|
||||
const onBuilt = (): void =>
|
||||
precombo.body.isConnected
|
||||
? void precombo.refresh()
|
||||
? void Promise.all([precombo.refresh(), sangeun.refresh()])
|
||||
: document.removeEventListener(HOPYO_BUILT, onBuilt);
|
||||
document.addEventListener(HOPYO_BUILT, onBuilt);
|
||||
// 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
@@ -171,6 +184,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
sangeunSec,
|
||||
precomboSec,
|
||||
summary,
|
||||
createDivider(),
|
||||
@@ -229,6 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
* (양식이 열려 있으면 실무문서 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
sangeun.paint();
|
||||
precombo.paint();
|
||||
hopyo.paint();
|
||||
paintButtons();
|
||||
@@ -253,7 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await precombo.refresh();
|
||||
await Promise.all([precombo.refresh(), sangeun.refresh()]);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -333,11 +348,25 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => {
|
||||
const sec = target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null;
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합" | "단가산출"): void => {
|
||||
const sec =
|
||||
target === "단가산출"
|
||||
? sangeunSec
|
||||
: target === "일위대가조합"
|
||||
? precomboSec
|
||||
: target === "도면"
|
||||
? drawing
|
||||
: null;
|
||||
sec?.classList.remove("is-collapsed");
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh };
|
||||
return {
|
||||
root,
|
||||
refresh,
|
||||
paint,
|
||||
reveal,
|
||||
refreshPrecombo: precombo.refresh,
|
||||
refreshSangeun: sangeun.refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Library.ts
|
||||
* 좌측 서고 칸(716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 두 칸이 같이 쓰는 층 단추 + 그 층 나무.
|
||||
* 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`).
|
||||
* 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버
|
||||
* (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] ·
|
||||
* [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기.
|
||||
* 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V ·
|
||||
* Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키).
|
||||
* 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createTreeRow,
|
||||
openTreeMenu,
|
||||
renderTree,
|
||||
type TreeMenuItem,
|
||||
type TreeNode,
|
||||
} from "@ui/ui_template_tree";
|
||||
import {
|
||||
StaleError,
|
||||
editPrecomboTree,
|
||||
fetchPrecomboTree,
|
||||
listTemplates,
|
||||
newLibrary,
|
||||
pasteLibraryTree,
|
||||
type Layer,
|
||||
type LibraryKind,
|
||||
type PrecomboTree,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { nameDialog } from "./M02_MasterTemplete_Library_List";
|
||||
import type { Root } from "./M02_MasterTemplete_Layer";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
|
||||
import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import {
|
||||
clipOf,
|
||||
keepClip,
|
||||
pickClick,
|
||||
refOf,
|
||||
type Target,
|
||||
} from "./M02_MasterTemplete_TreePanel_Pick";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** 서고 칸 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */
|
||||
export interface LibraryScope {
|
||||
root: Root;
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryOptions {
|
||||
kind: LibraryKind;
|
||||
scope: () => LibraryScope;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
||||
bar: () => HTMLElement;
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 그 층 목록표 고름 — 문서 열기의 바탕(종류 · 층 · 주인 · 권한) */
|
||||
list: (scope: LibraryScope) => Selection;
|
||||
/** [여기에 새 …] 문서 몸 */
|
||||
blank: (name: string) => Record<string, unknown>;
|
||||
/** 빈 나무 글 · 새 문서 메뉴 글 */
|
||||
text: { none: string; newHere: string };
|
||||
/** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */
|
||||
more?: (key: string, name: string) => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export interface LibraryHandle {
|
||||
body: HTMLElement;
|
||||
/** 고름 표시만 */
|
||||
paint: () => void;
|
||||
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LibraryRow {
|
||||
이름: string;
|
||||
종류: string;
|
||||
제목?: string | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
}
|
||||
|
||||
export interface LibraryFolder {
|
||||
path: string[];
|
||||
folders: LibraryFolder[];
|
||||
docs: LibraryRow[];
|
||||
}
|
||||
|
||||
/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */
|
||||
export function buildLibraryFolders(
|
||||
rows: LibraryRow[],
|
||||
view: Pick<PrecomboTree, "폴더" | "자리">,
|
||||
): LibraryFolder {
|
||||
const root: LibraryFolder = { path: [], folders: [], docs: [] };
|
||||
const at = (path: string[]): LibraryFolder =>
|
||||
path.reduce((node, name, i) => {
|
||||
let next = node.folders.find((f) => f.path[i] === name);
|
||||
if (!next) {
|
||||
next = { path: path.slice(0, i + 1), folders: [], docs: [] };
|
||||
node.folders.push(next);
|
||||
}
|
||||
return next;
|
||||
}, root);
|
||||
view.폴더.forEach(at);
|
||||
for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 폴더 안 문서 수(아래 폴더까지) */
|
||||
export const docCount = (node: LibraryFolder): number =>
|
||||
node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0);
|
||||
|
||||
type TreeBody = Parameters<typeof editPrecomboTree>[1];
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
const kind = opt.kind;
|
||||
const clipKind = `library:${kind}`;
|
||||
const barHost = el("div");
|
||||
const treeHost = el("div", {
|
||||
className: "ui-tree m02-tree__body",
|
||||
attrs: { "data-library-tree": kind, tabindex: "0" },
|
||||
});
|
||||
const leaves = new Map<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
const counts = new Map<string, number>();
|
||||
let picked = new Set<string>();
|
||||
let anchor: string | null = null;
|
||||
let rows: LibraryRow[] = [];
|
||||
let view: PrecomboTree | null = null;
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const open = async (row: LibraryRow): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
...opt.list(at),
|
||||
name: row.이름,
|
||||
label: row.제목 ? `${row.이름} ${row.제목}` : row.이름,
|
||||
});
|
||||
};
|
||||
|
||||
/** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(같은 이름 · 쓰는 곳) 그대로 */
|
||||
const apply = async (run: (판: string) => Promise<PrecomboTree>): Promise<void> => {
|
||||
if (!view || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
view = await run(view.판);
|
||||
const gone = view.알림?.지운문서 ?? [];
|
||||
if (gone.length) {
|
||||
rows = rows.filter((r) => !gone.includes(r.이름));
|
||||
const cur = opt.getOpen();
|
||||
if (cur && cur.kind === kind && gone.includes(cur.name)) opt.onOpen(null);
|
||||
}
|
||||
if (view.알림?.만든문서?.length) await refresh(true);
|
||||
else draw();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError && error.message === "HTTP 409") {
|
||||
await refresh();
|
||||
showToast(L("M02_TreeStale"), "error");
|
||||
} else {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
const edit = async (body: TreeBody): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
for (const path of [body.경로, body.새경로])
|
||||
path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true));
|
||||
await apply((판) => editPrecomboTree(at.layer, body, 판, at.owner, kind));
|
||||
};
|
||||
|
||||
/** [여기에 새 …] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */
|
||||
const make = (path: string[]): void => {
|
||||
const at = opt.scope();
|
||||
nameDialog(opt.text.newHere, "", async (name) => {
|
||||
const got = await newLibrary(at.layer, kind, { 문서: opt.blank(name) }, at.owner);
|
||||
showToast(L("M02_Created"), "success");
|
||||
await refresh();
|
||||
if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });
|
||||
await open({ 이름: got.이름, 종류: kind, 제목: name });
|
||||
});
|
||||
};
|
||||
const addFolder = async (parent: string[]): Promise<void> => {
|
||||
const got = await askText(L("M02_TreeAddFolder"), [
|
||||
{ key: "name", label: L("M02_TreeFolderName") },
|
||||
]);
|
||||
if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name });
|
||||
};
|
||||
const rename = async (path: string[]): Promise<void> => {
|
||||
const was = path[path.length - 1];
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{ key: "name", label: L("M02_TreeFolderName"), value: was },
|
||||
]);
|
||||
if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name });
|
||||
};
|
||||
|
||||
/* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */
|
||||
const pickedTargets = (): Target[] =>
|
||||
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
|
||||
/** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */
|
||||
const remove = async (list: Target[]): Promise<void> => {
|
||||
if (!list.length || !opt.scope().canWrite) return;
|
||||
const [one] = list;
|
||||
const ask =
|
||||
list.length > 1
|
||||
? L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length))
|
||||
: one.kind === "item"
|
||||
? L("M02_TreeDeleteItem").replace("{value}", one.item.이름)
|
||||
: L("M02_TreeDeleteFolder")
|
||||
.replace("{value}", one.path[one.path.length - 1])
|
||||
.replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0));
|
||||
if (!(await showConfirmDialog(ask, L("M02_Delete")))) return;
|
||||
await edit({ op: "delete", 대상: list.map(refOf) });
|
||||
};
|
||||
const copy = (list: Target[]): void => {
|
||||
if (!list.length) return;
|
||||
const at = opt.scope();
|
||||
keepClip({ kind: clipKind, from: { layer: at.layer, owner: at.owner }, refs: list.map(refOf) });
|
||||
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
|
||||
};
|
||||
const paste = async (dest: string[]): Promise<void> => {
|
||||
const got = clipOf(clipKind);
|
||||
const at = opt.scope();
|
||||
if (!got || !at.canWrite) return;
|
||||
toggled.set(folderIdOf(dest), true);
|
||||
await apply(() => pasteLibraryTree(kind, at.layer, at.owner, got, dest));
|
||||
};
|
||||
|
||||
const menuAt = (path: string[]): TreeMenuItem[] => {
|
||||
const off = opt.scope().canWrite ? undefined : tc("ReadOnly");
|
||||
const item = (label: string, run: () => void): TreeMenuItem => ({
|
||||
label,
|
||||
run,
|
||||
disabledReason: off,
|
||||
});
|
||||
const got = clipOf(clipKind);
|
||||
const target: Target = { kind: "folder", path };
|
||||
return [
|
||||
item(L("M02_TreeAddFolder"), () => void addFolder(path)),
|
||||
item(opt.text.newHere, () => make(path)),
|
||||
...(path.length
|
||||
? [
|
||||
item(L("M02_TreeRename"), () => void rename(path)),
|
||||
item(L("M02_Delete"), () => void remove([target])),
|
||||
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
|
||||
]
|
||||
: []),
|
||||
...(got
|
||||
? [item(L("M02_TreePaste").replace("{n}", String(got.refs.length)), () => void paste(path))]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
/** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(복사 · 지우기) · 아니면 그 줄 하나 */
|
||||
const menuFor = (id: string, single: () => TreeMenuItem[]): TreeMenuItem[] => {
|
||||
if (!picked.has(id)) setPicked(new Set([id]), id);
|
||||
const list = pickedTargets();
|
||||
if (list.length < 2) return single();
|
||||
const n = String(list.length);
|
||||
const off = opt.scope().canWrite ? undefined : tc("ReadOnly");
|
||||
return [
|
||||
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
|
||||
{
|
||||
label: L("M02_TreeDeleteMany").replace("{n}", n),
|
||||
run: () => void remove(list),
|
||||
disabledReason: off,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const leaf = (row: LibraryRow, folder: string[]): TreeNode => {
|
||||
const id = `doc:${row.이름}`;
|
||||
const label = row.제목 || row.이름;
|
||||
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
||||
const target: Target = { kind: "item", item };
|
||||
targets.set(id, target);
|
||||
const single = (): TreeMenuItem[] => [
|
||||
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
|
||||
{
|
||||
label: L("M02_Delete"),
|
||||
run: () => void remove([target]),
|
||||
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
|
||||
},
|
||||
...(opt.more?.(row.이름, label) ?? []),
|
||||
];
|
||||
const menu = () => menuFor(id, single);
|
||||
return { id, label, tip: row.이름, count: null, run: () => void open(row), menu };
|
||||
};
|
||||
const folderNode = (node: LibraryFolder): TreeNode => {
|
||||
const id = folderIdOf(node.path);
|
||||
const count = docCount(node);
|
||||
targets.set(id, { kind: "folder", path: node.path });
|
||||
counts.set(id, count);
|
||||
return {
|
||||
id,
|
||||
label: node.path[node.path.length - 1],
|
||||
count,
|
||||
folder: true,
|
||||
open: toggled.get(id) ?? count > 0,
|
||||
children: kids(node),
|
||||
menu: () => menuFor(id, () => menuAt(node.path)),
|
||||
};
|
||||
};
|
||||
const kids = (node: LibraryFolder): TreeNode[] => [
|
||||
...node.folders.map(folderNode),
|
||||
...node.docs.map((row) => leaf(row, node.path)),
|
||||
];
|
||||
|
||||
/** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 고른 것 전부 · 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 */
|
||||
const drag = attachTreeDrag({
|
||||
body: treeHost,
|
||||
targetOf: (id) => targets.get(id),
|
||||
listFor: (id) =>
|
||||
picked.size > 1 && picked.has(id)
|
||||
? pickedTargets()
|
||||
: targets.has(id)
|
||||
? [targets.get(id)!]
|
||||
: null,
|
||||
move: (list, dest) => {
|
||||
const from = (t: Target): string[] =>
|
||||
t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1);
|
||||
const moving = list.filter((t) => !samePath(from(t), dest));
|
||||
if (moving.length) void edit({ op: "move", 대상: moving.map(refOf), 새경로: dest });
|
||||
},
|
||||
});
|
||||
|
||||
const visibleIds = (): string[] =>
|
||||
[...treeHost.querySelectorAll<HTMLElement>("[data-node]")]
|
||||
.filter((b) => !b.closest("[hidden]"))
|
||||
.map((b) => b.dataset.node ?? "");
|
||||
const setPicked = (next: Set<string>, at: string | null): void => {
|
||||
picked = next;
|
||||
anchor = at;
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>("[data-node]"))
|
||||
row.classList.toggle("is-picked", picked.size > 1 && picked.has(row.dataset.node ?? ""));
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
leaves.clear();
|
||||
targets.clear();
|
||||
counts.clear();
|
||||
const tree = buildLibraryFolders(rows, view ?? { 폴더: [], 자리: {} });
|
||||
if (!tree.docs.length && !tree.folders.length)
|
||||
return void treeHost.replaceChildren(createEmpty({ text: opt.text.none, small: true }));
|
||||
const canWrite = opt.scope().canWrite;
|
||||
treeHost.replaceChildren(
|
||||
...renderTree(kids(tree), (node, caret, depth, onClick) => {
|
||||
const row = createTreeRow(node, caret, depth, () => {
|
||||
onClick();
|
||||
if (!node.children?.length) return;
|
||||
toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸");
|
||||
});
|
||||
row.dataset.node = node.id;
|
||||
// Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture)
|
||||
row.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const mod = { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey };
|
||||
const got = pickClick(picked, anchor, node.id, mod, visibleIds());
|
||||
if (got.handled) event.stopImmediatePropagation();
|
||||
setPicked(got.picked, got.anchor);
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
if (node.run) {
|
||||
row.dataset.libraryKey = node.id.slice(4);
|
||||
leaves.set(node.id.slice(4), row);
|
||||
}
|
||||
if (canWrite) drag.arm(row, node.id);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
setPicked(new Set([...picked].filter((id) => targets.has(id))), anchor);
|
||||
paint();
|
||||
};
|
||||
|
||||
// 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 문서 · 붙여넣기)
|
||||
treeHost.addEventListener("contextmenu", (event) => {
|
||||
if (!view || (event.target as HTMLElement).closest("[data-node]")) return;
|
||||
event.preventDefault();
|
||||
openTreeMenu(event, menuAt([]));
|
||||
});
|
||||
// 키 — Delete = 고른 것(없으면 초점 줄) 지움 · Ctrl+C 복사 · Ctrl+V = 초점 폴더 안(아니면 뿌리)
|
||||
treeHost.addEventListener("keydown", (event) => {
|
||||
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
|
||||
const id = row?.dataset.node ?? "";
|
||||
const target = targets.get(id);
|
||||
const list = picked.has(id) ? pickedTargets() : target ? [target] : [];
|
||||
if (event.key === "Delete" && list.length) {
|
||||
event.preventDefault();
|
||||
void remove(list);
|
||||
} else if (isCtrlKey(event, "c") && list.length) {
|
||||
event.preventDefault();
|
||||
copy(list);
|
||||
} else if (isCtrlKey(event, "v")) {
|
||||
event.preventDefault();
|
||||
void paste(target?.kind === "folder" ? target.path : []);
|
||||
}
|
||||
});
|
||||
|
||||
const paint = (): void => {
|
||||
const cur = opt.getOpen();
|
||||
const at = opt.scope();
|
||||
const mine =
|
||||
cur && cur.kind === kind && cur.layer === at.layer && (cur.owner ?? null) === at.owner;
|
||||
for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key);
|
||||
};
|
||||
|
||||
/** `keep` = 나무 자리 그대로 두고 목록만 다시(붙여넣기 뒤 새 문서) */
|
||||
const refresh = async (keep = false): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true }));
|
||||
const [got, tree] = await Promise.all([
|
||||
// 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리
|
||||
listTemplates(at.layer, null, at.owner).then(
|
||||
(list): LibraryRow[] => list.filter((r) => r.종류 === kind),
|
||||
(): LibraryRow[] => [],
|
||||
),
|
||||
keep && view
|
||||
? Promise.resolve(view)
|
||||
: fetchPrecomboTree(at.layer, at.owner, kind).catch(() => null),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
rows = got;
|
||||
view = tree;
|
||||
draw();
|
||||
};
|
||||
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
attrs: { "data-library": kind },
|
||||
children: [barHost, treeHost],
|
||||
});
|
||||
return { body, paint, refresh: () => refresh() };
|
||||
}
|
||||
@@ -1,44 +1,22 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
||||
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 일위대가 나무.
|
||||
* 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) —
|
||||
* 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 일위대가] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽
|
||||
* 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김.
|
||||
* 좌측 「일위대가」 칸(50-3 ③ · 51-4 · 134-3) — 서고 칸(`UI_Side_Library` · 716-4)에 일위대가 몫만 줌:
|
||||
* 종류 precombo · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(페이지 층과 따로).
|
||||
* 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관.
|
||||
* 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표
|
||||
* (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createTreeRow,
|
||||
openTreeMenu,
|
||||
renderTree,
|
||||
type TreeMenuItem,
|
||||
type TreeNode,
|
||||
} from "@ui/ui_template_tree";
|
||||
import {
|
||||
StaleError,
|
||||
editPrecomboTree,
|
||||
fetchPrecomboTree,
|
||||
listTemplates,
|
||||
newLibrary,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type PrecomboTree,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { nameDialog } from "./M02_MasterTemplete_Library_List";
|
||||
import type { Root } from "./M02_MasterTemplete_Layer";
|
||||
import type { TreeMenuItem } from "@ui/ui_template_tree";
|
||||
import type { Kind } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { StructDef } from "./StructOptions/M02_SO_Def_Model";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
|
||||
import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import type { Target } from "./M02_MasterTemplete_TreePanel_Pick";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import {
|
||||
buildLibrarySection,
|
||||
type LibraryHandle,
|
||||
type LibraryScope,
|
||||
} from "./M02_MasterTemplete_UI_Side_Library";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "precombo">;
|
||||
|
||||
@@ -60,12 +38,7 @@ export const precomboClasses = (
|
||||
}));
|
||||
|
||||
/** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */
|
||||
export interface PrecomboScope {
|
||||
root: Root;
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
canWrite: boolean;
|
||||
}
|
||||
export type PrecomboScope = LibraryScope;
|
||||
|
||||
/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */
|
||||
export const precomboList = (scope: PrecomboScope): Selection => ({
|
||||
@@ -89,257 +62,13 @@ export interface PrecomboOptions {
|
||||
more?: (key: string, name: string) => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
body: HTMLElement;
|
||||
/** 고름 표시만 */
|
||||
paint: () => void;
|
||||
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
export type PrecomboHandle = LibraryHandle;
|
||||
|
||||
interface Row {
|
||||
이름: string;
|
||||
종류: string;
|
||||
제목?: string | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
}
|
||||
|
||||
export interface PrecomboFolder {
|
||||
path: string[];
|
||||
folders: PrecomboFolder[];
|
||||
docs: Row[];
|
||||
}
|
||||
|
||||
/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */
|
||||
export function buildPrecomboFolders(
|
||||
rows: Row[],
|
||||
view: Pick<PrecomboTree, "폴더" | "자리">,
|
||||
): PrecomboFolder {
|
||||
const root: PrecomboFolder = { path: [], folders: [], docs: [] };
|
||||
const at = (path: string[]): PrecomboFolder =>
|
||||
path.reduce((node, name, i) => {
|
||||
let next = node.folders.find((f) => f.path[i] === name);
|
||||
if (!next) {
|
||||
next = { path: path.slice(0, i + 1), folders: [], docs: [] };
|
||||
node.folders.push(next);
|
||||
}
|
||||
return next;
|
||||
}, root);
|
||||
view.폴더.forEach(at);
|
||||
for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 폴더 안 문서 수(아래 폴더까지) */
|
||||
export const docCount = (node: PrecomboFolder): number =>
|
||||
node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0);
|
||||
|
||||
type TreeBody = Parameters<typeof editPrecomboTree>[1];
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
const barHost = el("div");
|
||||
const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } });
|
||||
const leaves = new Map<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
let rows: Row[] = [];
|
||||
let view: PrecomboTree | null = null;
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const open = async (row: Row): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
...precomboList(at),
|
||||
name: row.이름,
|
||||
label: row.제목 ? `${row.이름} ${row.제목}` : row.이름,
|
||||
});
|
||||
};
|
||||
|
||||
/** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */
|
||||
const edit = async (body: TreeBody): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
if (!view || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
view = await editPrecomboTree(at.layer, body, view.판, at.owner);
|
||||
for (const path of [body.경로, body.새경로])
|
||||
path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true));
|
||||
draw();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError && error.message === "HTTP 409") {
|
||||
await refresh();
|
||||
showToast(L("M02_TreeStale"), "error");
|
||||
} else {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** [여기에 새 일위대가] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */
|
||||
const make = (path: string[]): void => {
|
||||
const at = opt.scope();
|
||||
nameDialog(tc("Lib_NewHere"), "", async (name) => {
|
||||
const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
||||
const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner);
|
||||
showToast(L("M02_Created"), "success");
|
||||
await refresh();
|
||||
if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });
|
||||
await open({ 이름: got.이름, 종류: "precombo", 제목: name });
|
||||
});
|
||||
};
|
||||
const addFolder = async (parent: string[]): Promise<void> => {
|
||||
const got = await askText(L("M02_TreeAddFolder"), [
|
||||
{ key: "name", label: L("M02_TreeFolderName") },
|
||||
]);
|
||||
if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name });
|
||||
};
|
||||
const rename = async (path: string[]): Promise<void> => {
|
||||
const was = path[path.length - 1];
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{ key: "name", label: L("M02_TreeFolderName"), value: was },
|
||||
]);
|
||||
if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name });
|
||||
};
|
||||
/** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */
|
||||
const remove = async (path: string[], count: number): Promise<void> => {
|
||||
if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error");
|
||||
const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]);
|
||||
if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path });
|
||||
};
|
||||
|
||||
const menuAt = (path: string[], count: number): TreeMenuItem[] => {
|
||||
const off = opt.scope().canWrite ? undefined : tc("ReadOnly");
|
||||
const item = (label: string, run: () => void): TreeMenuItem => ({
|
||||
label,
|
||||
run,
|
||||
disabledReason: off,
|
||||
});
|
||||
return [
|
||||
item(L("M02_TreeAddFolder"), () => void addFolder(path)),
|
||||
item(tc("Lib_NewHere"), () => make(path)),
|
||||
...(path.length
|
||||
? [
|
||||
item(L("M02_TreeRename"), () => void rename(path)),
|
||||
item(L("M02_Delete"), () => void remove(path, count)),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
|
||||
const leaf = (row: Row, folder: string[]): TreeNode => {
|
||||
const id = `doc:${row.이름}`;
|
||||
const label = row.제목 || row.이름;
|
||||
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
||||
targets.set(id, { kind: "item", item });
|
||||
const menu = () => opt.more?.(row.이름, label) ?? [];
|
||||
return { id, label, tip: row.이름, count: null, run: () => void open(row), menu };
|
||||
};
|
||||
const folderNode = (node: PrecomboFolder): TreeNode => {
|
||||
const id = folderIdOf(node.path);
|
||||
const count = docCount(node);
|
||||
targets.set(id, { kind: "folder", path: node.path });
|
||||
return {
|
||||
id,
|
||||
label: node.path[node.path.length - 1],
|
||||
count,
|
||||
folder: true,
|
||||
open: toggled.get(id) ?? count > 0,
|
||||
children: kids(node),
|
||||
menu: () => menuAt(node.path, count),
|
||||
};
|
||||
};
|
||||
const kids = (node: PrecomboFolder): TreeNode[] => [
|
||||
...node.folders.map(folderNode),
|
||||
...node.docs.map((row) => leaf(row, node.path)),
|
||||
];
|
||||
|
||||
/** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */
|
||||
const drag = attachTreeDrag({
|
||||
body: treeHost,
|
||||
targetOf: (id) => targets.get(id),
|
||||
listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null),
|
||||
move: ([t], dest) => {
|
||||
const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1);
|
||||
if (samePath(from, dest)) return;
|
||||
void edit(
|
||||
t.kind === "item"
|
||||
? { op: "move", 키: t.item.key, 새경로: dest }
|
||||
: { op: "move", 경로: t.path, 새경로: dest },
|
||||
);
|
||||
},
|
||||
export const buildPrecomboSection = (opt: PrecomboOptions): PrecomboHandle =>
|
||||
buildLibrarySection({
|
||||
...opt,
|
||||
kind: "precombo",
|
||||
list: precomboList,
|
||||
blank: (name) => ({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }),
|
||||
text: { none: tc("Lib_None"), newHere: tc("Lib_NewHere") },
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
leaves.clear();
|
||||
targets.clear();
|
||||
const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} });
|
||||
if (!tree.docs.length && !tree.folders.length)
|
||||
return void treeHost.replaceChildren(createEmpty({ text: tc("Lib_None"), small: true }));
|
||||
const canWrite = opt.scope().canWrite;
|
||||
treeHost.replaceChildren(
|
||||
...renderTree(kids(tree), (node, caret, depth, onClick) => {
|
||||
const row = createTreeRow(node, caret, depth, () => {
|
||||
onClick();
|
||||
if (!node.children?.length) return;
|
||||
toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸");
|
||||
});
|
||||
row.dataset.node = node.id;
|
||||
if (node.run) {
|
||||
row.dataset.precomboKey = node.id.slice(4);
|
||||
leaves.set(node.id.slice(4), row);
|
||||
}
|
||||
if (canWrite) drag.arm(row, node.id);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
paint();
|
||||
};
|
||||
|
||||
// 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 일위대가)
|
||||
treeHost.addEventListener("contextmenu", (event) => {
|
||||
if (!view || (event.target as HTMLElement).closest("[data-node]")) return;
|
||||
event.preventDefault();
|
||||
openTreeMenu(event, menuAt([], 0));
|
||||
});
|
||||
|
||||
const paint = (): void => {
|
||||
const cur = opt.getOpen();
|
||||
const at = opt.scope();
|
||||
const mine =
|
||||
cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner;
|
||||
for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key);
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true }));
|
||||
const [got, tree] = await Promise.all([
|
||||
// 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리
|
||||
listTemplates(at.layer, null, at.owner).then(
|
||||
(list): Row[] => list.filter((r) => r.종류 === "precombo"),
|
||||
(): Row[] => [],
|
||||
),
|
||||
fetchPrecomboTree(at.layer, at.owner).catch(() => null),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
rows = got;
|
||||
view = tree;
|
||||
draw();
|
||||
};
|
||||
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
attrs: { "data-precombo": "" },
|
||||
children: [barHost, treeHost],
|
||||
});
|
||||
return { body, paint, refresh };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Sangeun.ts
|
||||
* 좌측 맨 위 「단가산출」 칸(717-2a · 요구서 4장 12) — 서고 칸(`UI_Side_Library` · 716-4)에 단가산출 몫만 줌:
|
||||
* 종류 sangeun(SK-) · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(칸마다 따로 · 기본 개인).
|
||||
* 문서 누름 = 편집 칸(UI_Main 종류 sangeun = `Sangeun_View`) · 켜진 층 단추 다시 누름 = 목록표(`Library_List`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { sangeunBlank } from "./M02_MasterTemplete_Library_List";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import {
|
||||
buildLibrarySection,
|
||||
type LibraryHandle,
|
||||
type LibraryOptions,
|
||||
type LibraryScope,
|
||||
} from "./M02_MasterTemplete_UI_Side_Library";
|
||||
|
||||
/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */
|
||||
export const sangeunList = (scope: LibraryScope): Selection => ({
|
||||
layer: scope.layer,
|
||||
owner: scope.owner,
|
||||
canWrite: scope.canWrite,
|
||||
projectId: null,
|
||||
kind: "sangeun",
|
||||
name: "",
|
||||
label: L("M02_SangeunTitle"),
|
||||
});
|
||||
|
||||
export type SangeunOptions = Pick<
|
||||
LibraryOptions,
|
||||
"scope" | "bar" | "confirmLeave" | "onOpen" | "getOpen"
|
||||
>;
|
||||
|
||||
export const buildSangeunSection = (opt: SangeunOptions): LibraryHandle =>
|
||||
buildLibrarySection({
|
||||
...opt,
|
||||
kind: "sangeun",
|
||||
list: sangeunList,
|
||||
blank: sangeunBlank,
|
||||
text: { none: L("M02_SangeunNone"), newHere: L("M02_SangeunNewHere") },
|
||||
});
|
||||
@@ -1,32 +1,42 @@
|
||||
"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유).
|
||||
"""M02 서고 나무 = 자유 폴더(PLAN 158-1 · 716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 한 벌.
|
||||
|
||||
자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` —
|
||||
층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 ·
|
||||
폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 ·
|
||||
문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409).
|
||||
자리 = 층 뿌리 `{종류}_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — 종류 · 층(마스터 · 회사 ·
|
||||
개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는
|
||||
문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면
|
||||
409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) —
|
||||
모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음).
|
||||
붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 ·
|
||||
못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`).
|
||||
이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
FILE = "precombo_폴더.json"
|
||||
OPS = ("add-folder", "rename", "move", "delete")
|
||||
USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴
|
||||
|
||||
|
||||
def _keys() -> set[str]:
|
||||
"""지금 층에 있는 사전조합 키(파일 이름)."""
|
||||
return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")}
|
||||
def file_of(kind: str) -> str:
|
||||
return f"{kind}_폴더.json"
|
||||
|
||||
|
||||
def _read() -> tuple[dict[str, Any], str]:
|
||||
path = store.root() / FILE
|
||||
def _keys(kind: str, root: Path | None = None) -> set[str]:
|
||||
"""그 층에 있는 서고 키(파일 이름)."""
|
||||
return {p.stem for p in ((root or store.root()) / kind).glob("[!._]*.json")}
|
||||
|
||||
|
||||
def _read(kind: str, root: Path | None = None) -> tuple[dict[str, Any], str]:
|
||||
path = (root or store.root()) / file_of(kind)
|
||||
if not path.is_file():
|
||||
return {"폴더": [], "자리": {}}, ""
|
||||
raw = path.read_bytes()
|
||||
@@ -45,29 +55,38 @@ def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]:
|
||||
return out
|
||||
|
||||
|
||||
def _places(doc: dict[str, Any], keys: set[str]) -> dict[str, list[str]]:
|
||||
return {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys}
|
||||
|
||||
|
||||
def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]:
|
||||
places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys}
|
||||
places = _places(doc, keys)
|
||||
return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}}
|
||||
|
||||
|
||||
def view() -> dict[str, Any]:
|
||||
doc, 판 = _read()
|
||||
return view_of(doc, 판, _keys())
|
||||
def view(kind: str = "precombo") -> dict[str, Any]:
|
||||
doc, 판 = _read(kind)
|
||||
return view_of(doc, 판, _keys(kind))
|
||||
|
||||
|
||||
def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]:
|
||||
return [*new, *path[len(old) :]] if tree._under(path, old) else path
|
||||
|
||||
|
||||
def _out(folders: list[list[str]], places: dict[str, list[str]]) -> dict[str, Any]:
|
||||
return {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}}
|
||||
|
||||
|
||||
def apply(
|
||||
doc: dict[str, Any], body: dict[str, Any], keys: set[str]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장은 `edit`)."""
|
||||
"""고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장 · 문서 파일 지우기는 `edit`).
|
||||
지우기 알림 `지운문서` = 지울 문서 키(폴더 안 · 아래 폴더까지)."""
|
||||
op = body.get("op")
|
||||
if op not in OPS:
|
||||
raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
|
||||
folders = _folders(doc, keys)
|
||||
places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys}
|
||||
places = _places(doc, keys)
|
||||
where = tree._path(body.get("경로"))
|
||||
key = str(body.get("키") or "")
|
||||
note: dict[str, Any] = {"op": op}
|
||||
@@ -78,6 +97,8 @@ def apply(
|
||||
if not path and what:
|
||||
raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음")
|
||||
|
||||
if key and op in ("move", "delete") and key not in keys:
|
||||
raise tree.TreeError(404, f"없는 문서 「{key}」")
|
||||
if op == "add-folder":
|
||||
need(where, "")
|
||||
made = [*where, tree._name(body.get("이름"))]
|
||||
@@ -86,12 +107,13 @@ def apply(
|
||||
folders.append(made)
|
||||
note["경로"] = made
|
||||
elif op == "move" and key:
|
||||
if key not in keys:
|
||||
raise tree.TreeError(404, f"없는 사전조합 「{key}」")
|
||||
to = tree._path(body.get("새경로"), "새경로")
|
||||
need(to, "")
|
||||
places[key] = to
|
||||
note["경로"] = to
|
||||
elif op == "delete" and key:
|
||||
places.pop(key, None)
|
||||
note["지운문서"] = [key]
|
||||
elif op in ("rename", "move"):
|
||||
need(where, "바꿀" if op == "rename" else "옮길")
|
||||
if op == "rename":
|
||||
@@ -109,28 +131,136 @@ def apply(
|
||||
folders = [_shift(f, where, new) for f in folders]
|
||||
places = {k: _shift(p, where, new) for k, p in places.items()}
|
||||
note["경로"] = new
|
||||
else: # delete — 빈 폴더만
|
||||
else: # delete — 안에 든 문서째(716-4)
|
||||
need(where, "지울")
|
||||
inside = sorted(k for k, p in places.items() if tree._under(p, where))
|
||||
if inside:
|
||||
raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움")
|
||||
folders = [f for f in folders if not tree._under(f, where)]
|
||||
places = {k: p for k, p in places.items() if k not in inside}
|
||||
note["경로"] = where
|
||||
out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}}
|
||||
return out, note
|
||||
note["지운문서"] = inside
|
||||
return _out(folders, places), note
|
||||
|
||||
|
||||
def apply_many(
|
||||
doc: dict[str, Any], body: dict[str, Any], keys: set[str]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""`대상` 이 있으면 옮기기 · 지우기를 하나씩 차례로(하나라도 막히면 아무것도 안 함) ·
|
||||
없으면 `apply`."""
|
||||
targets = body.get("대상")
|
||||
if targets is None:
|
||||
return apply(doc, body, keys)
|
||||
op = body.get("op")
|
||||
if op not in ("move", "delete") or not targets:
|
||||
raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함")
|
||||
note: dict[str, Any] = {"op": op, "지운문서": [], "바뀐이름": []}
|
||||
for one in targets:
|
||||
if not isinstance(one, dict):
|
||||
raise tree.TreeError(422, "「대상」 은 {경로, 키?} 목록")
|
||||
part = {
|
||||
"op": op,
|
||||
"경로": one.get("경로"),
|
||||
"키": one.get("키"),
|
||||
"새경로": body.get("새경로"),
|
||||
}
|
||||
if op == "delete" and not part["키"] and not tree._path(part["경로"]):
|
||||
continue # 맨 위는 못 지움
|
||||
try:
|
||||
doc, got = apply(doc, part, keys)
|
||||
except tree.TreeError as e:
|
||||
if e.status == 404 and op == "delete":
|
||||
continue # 고른 폴더 안의 것 — 앞에서 이미 지움
|
||||
raise
|
||||
note["지운문서"] += [k for k in got.get("지운문서", []) if k not in note["지운문서"]]
|
||||
note["바뀐이름"] += got.get("바뀐이름", [])
|
||||
return doc, note
|
||||
|
||||
|
||||
def _refuse_used(kind: str, gone: list[str]) -> None:
|
||||
"""지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 —
|
||||
모든 층 훑음(프로젝트 뺌)."""
|
||||
layer, owner = refs.here()
|
||||
rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone}
|
||||
mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone}
|
||||
found = []
|
||||
for what_kind, what in USERS:
|
||||
for at, path in layers.layer_files(what_kind):
|
||||
if path.resolve() in mine:
|
||||
continue
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids})
|
||||
if hit:
|
||||
name = doc.get("일위대가번") or doc.get("이름") or path.stem
|
||||
found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}")
|
||||
if found:
|
||||
raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found))
|
||||
|
||||
|
||||
# ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임
|
||||
# (키 재사용을 막을 때 함께 고침)
|
||||
def edit(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409."""
|
||||
doc, 판 = _read()
|
||||
def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]:
|
||||
"""지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409."""
|
||||
doc, 판 = _read(kind)
|
||||
if 판 != str(body.get("판") or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": 판})
|
||||
keys = _keys()
|
||||
raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판})
|
||||
keys = _keys(kind)
|
||||
try:
|
||||
out, note = apply(doc, body, keys)
|
||||
out, note = apply_many(doc, body, keys)
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
atomic_write_json(store.root() / FILE, out)
|
||||
return {**view(), "알림": note}
|
||||
gone = note.get("지운문서") or []
|
||||
if gone:
|
||||
_refuse_used(kind, gone) # 모두 검사 뒤 한 번에
|
||||
for key in gone:
|
||||
(store.root() / kind / f"{key}.json").unlink(missing_ok=True)
|
||||
atomic_write_json(store.root() / file_of(kind), out)
|
||||
return {**view(kind), "알림": note}
|
||||
|
||||
|
||||
# ponytail: 붙여넣기 도중 막히면 앞서 만든 사본은 뿌리에 남음 — 한 번에 되돌림이 필요하면 그때
|
||||
def paste(source: Path, picks: list[dict[str, Any]], dest: Any, kind: str) -> dict[str, Any]:
|
||||
"""복사한 층(`source`) 고른 것 `[{경로, 키?}]` → 지금 층 `dest` 아래 새 키(본뜬 = 원본) ·
|
||||
폴더는 안의 것 · 아래 폴더까지 · 같은 폴더 이름 「(1)」 → 새 나무 + 알림 `만든문서`."""
|
||||
src = refs.where(source)
|
||||
src_keys = _keys(kind, source)
|
||||
src_doc, _ = _read(kind, source)
|
||||
src_folders, src_places = _folders(src_doc, src_keys), _places(src_doc, src_keys)
|
||||
doc, _ = _read(kind)
|
||||
keys = _keys(kind)
|
||||
folders, places = _folders(doc, keys), _places(doc, keys)
|
||||
try:
|
||||
to = tree._path(dest)
|
||||
if to and to not in folders:
|
||||
raise tree.TreeError(404, f"없는 폴더 「{' / '.join(to)}」")
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
|
||||
def copy(key: str) -> str:
|
||||
ref = {"층": src[0], "주인": src[1], "키": key}
|
||||
got = refs.read_ref(ref)
|
||||
if got is None:
|
||||
raise store.StoreError(404, f"없는 문서 「{key}」")
|
||||
return refs.create(kind, refs.carry(refs.origin_mark(got, ref), src))["이름"]
|
||||
|
||||
made: list[str] = []
|
||||
for pick in picks:
|
||||
key = str(pick.get("키") or "") if isinstance(pick, dict) else ""
|
||||
path = tree._clean(pick.get("경로")) if isinstance(pick, dict) else []
|
||||
if key:
|
||||
if key not in src_keys:
|
||||
raise store.StoreError(404, f"없는 문서 「{key}」")
|
||||
made.append(copy(key))
|
||||
places[made[-1]] = to
|
||||
continue
|
||||
if not path or path not in src_folders:
|
||||
raise store.StoreError(404, f"없는 폴더 「{' / '.join(path)}」")
|
||||
top = tree.unique_path([*to, path[-1]], lambda p: p in folders)
|
||||
folders += [[*top, *f[len(path) :]] for f in src_folders if tree._under(f, path)]
|
||||
for k, p in sorted(src_places.items()):
|
||||
if tree._under(p, path):
|
||||
made.append(copy(k))
|
||||
places[made[-1]] = [*top, *p[len(path) :]]
|
||||
atomic_write_json(store.root() / file_of(kind), _out(folders, places))
|
||||
return {**view(kind), "알림": {"op": "paste", "경로": to, "만든문서": made}}
|
||||
|
||||
@@ -47,9 +47,8 @@ def times_of(row: dict) -> Decimal:
|
||||
"""결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
per = combo_store._PER.findall(str(row.get("결과단위") or ""))
|
||||
head = per[0][0].replace(",", "") if len(per) == 1 else ""
|
||||
return Decimal(head) if head else Decimal(1)
|
||||
per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌
|
||||
return per[0][0] if len(per) == 1 else Decimal(1)
|
||||
|
||||
|
||||
def fingerprint(row: dict) -> str:
|
||||
@@ -170,6 +169,10 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
|
||||
return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]}
|
||||
|
||||
|
||||
def head_of(doc: dict) -> str:
|
||||
return f"1 {doc.get('단위')} 당"
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def _check_refs(doc: dict, here_: refs.Here) -> None:
|
||||
me = {"층": here_[0], "주인": here_[1], "키": doc["키"]}
|
||||
@@ -227,4 +230,22 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
|
||||
got = run(clean, whole, given, trail=(me,))
|
||||
except sd.DocError as e:
|
||||
raise _fail(e) from e
|
||||
return labor._json(got)
|
||||
return labor._json({**got, "머리": head_of(clean)})
|
||||
|
||||
|
||||
def from_line(line: Any, name: str, unit: str) -> dict[str, Any]:
|
||||
"""[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK ·
|
||||
답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||||
|
||||
if not isinstance(line, dict):
|
||||
raise store.StoreError(400, "줄이 묶음 아님")
|
||||
doc = patch.to_sangeun(line, name, unit, m01.loaded()[1])
|
||||
made = refs.create(sd.KIND, doc)
|
||||
at = refs.here()
|
||||
keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line}
|
||||
return {
|
||||
"이름": made["이름"],
|
||||
"줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`.
|
||||
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` ·
|
||||
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한).
|
||||
|
||||
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
|
||||
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값).
|
||||
@@ -37,6 +38,24 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]:
|
||||
return calc.calc_doc(doc, body.입력값)
|
||||
|
||||
|
||||
class FromLineBody(BaseModel):
|
||||
줄: dict[str, Any]
|
||||
이름: str
|
||||
단위: str
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/from-line")
|
||||
def sangeun_from_line(
|
||||
layer: Layer,
|
||||
body: FromLineBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""[단가산출로 저장](715-3) — 일위대가 줄 → 그 층 새 단가산출 · 답 = 새 키 + 바꿀 참조 줄."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, calc.from_line, body.줄, body.이름, body.단위)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/calc")
|
||||
def calc_sangeun(
|
||||
layer: Layer,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 553 결과 — 라이다 지형: 외부 무료 도구 시험 비교
|
||||
|
||||
시험만 · 앱 코드 · storage · git 손대지 않음. 자료 = 용화.las(4,900.7 만 점 · 업체 분류 1 · 2) · 검사점 = 업체 지면점 20 %(22.8 만 · 씨앗 553 · 비교 범위 안 20.2 만) · 모든 방법을 `dtm_classification.npz` 의 x · y(1 m)에 맞춰 잼. 차 = 검사점 − DEM(+ 이면 DEM 이 낮음).
|
||||
|
||||
## ① 결론 다섯 줄
|
||||
|
||||
1. **평활 뒤에도 차이가 그대로 남음.** 지금 RMS 0.77 m → 평활 0.75 m(2 % 줄어듦) · DEM 이 0.60 m 낮은 치우침은 평활 전후 같음. 같은 업체 지면점을 TIN 으로만 지으면 0.12 m(약 6.5 배 나음).
|
||||
2. 사용자 말(평활 · 평균에서 조정)은 **잔요철엔 일부 맞음** — 급경사 빨강 32 % → 27 % · 기울기 p90 120 → 107 %. 다만 TIN(빨강 16 % · p90 89 %)에는 못 미치고, **높이 치우침 · 깎기 비탈 1 m 넘는 오차는 평활로 안 풀림**(그림 숲 가장자리).
|
||||
3. 흠의 주인은 **지면 가르기가 아니라 격자 짓기**(칸 최저점 · 반 칸 자리 · 가까운 값 메움). 이 사업 유효 칸의 49 % 가 지면점 없는 메운 칸.
|
||||
4. 원자료로 지면을 다시 가르는 도구 가운데 이 숲(숲 98 % · 기울기 중앙 70 %)에 쓸 만한 것은 **PDAL SMRF(0.27 m)** · **WhiteboxTools 경사 필터(0.26 m)** 둘. CSF(PDAL · 원 라이브러리)와 **Whitebox Workflows NG 개선 필터는 급경사에서 실패**(절반 빔 · 또는 나무 위를 땅으로 잡아 RMS 3.7~4.4 m) — 553-B 의 「버금 = WbW NG」는 이 자료로는 맞지 않음.
|
||||
5. **권함** — 업체 분류가 있는 LAS: 지금 지면 가르기 그대로 두고 **격자만 TIN** 으로(앱이 이미 가진 WhiteboxTools `LidarTINGridding` · 66 만 점 0.8 초 · 새 의존성 없음). 분류 없는 LAS: PDAL SMRF + TIN(값 맞추기 시험 뒤).
|
||||
|
||||
## ② 수치 표
|
||||
|
||||
방법 — A 앱 저장 파일 그대로 · B PDAL 2.10.2 · C Whitebox · D CSF 원 라이브러리. TIN 은 모두 1 m · 긴 변 50 m 끊기. 「t」= 급경사용 값 덧 시험.
|
||||
|
||||
### 높이 오차(검사점 대비 · m) · 평활 전 → 후
|
||||
|
||||
| 방법 | RMS | 평균 차 | 95 % 절대 오차 | 검사점 덮임 % | 유효 범위 안 빈칸 % |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A0 지금 업체 분류(A1 = 평활본) | 0.767 → 0.751 | +0.601 → +0.602 | 1.371 → 1.337 | 97.8 | 0 (지면점 없어 가까운 값 메움 49.4) |
|
||||
| A2 지금 CSF(A3 = 평활본) | 0.711 → 0.687 | +0.542 → +0.543 | 1.286 → 1.228 | 97.6 | 0 (메움 53.7) |
|
||||
| **B1 업체 분류 + PDAL TIN** | **0.117 → 0.131** | −0.008 → −0.009 | 0.232 → 0.264 | 97.8 | 0.02 |
|
||||
| B1w 업체 분류 + WBT TIN | 0.117 → 0.131 | −0.007 → −0.009 | 0.232 → 0.264 | 97.8 | 0.02 |
|
||||
| B2 PDAL SMRF(기본값) | 0.265 → 0.272 | −0.108 → −0.121 | 0.492 → 0.513 | 97.8 | 0.03 |
|
||||
| B3 PDAL CSF(기본값) | 0.944 → 0.932 | +0.304 → +0.301 | 1.695 → 1.686 | 50.6 | 47.4 |
|
||||
| B3t PDAL CSF(무른 천 · 0.5 m) | 0.684 → 0.687 | −0.282 → −0.303 | 1.544 → 1.534 | 97.6 | 0.2 |
|
||||
| C1 WbW NG 개선 필터(기본값 · 타일 2×2) | 4.416 → 4.532 | −0.934 → −1.020 | 7.86 → 7.81 | 47.8 | 51.6 |
|
||||
| C1t WbW NG(경사 45° · 0.3 m) | 3.727 → 3.812 | −0.983 → −1.060 | 4.03 → 4.07 | 71.6 | 30.0 |
|
||||
| C2 WBT 경사 필터(기본값) | 0.257 → 0.264 | +0.056 → +0.047 | 0.439 → 0.471 | 97.8 | 0.02 |
|
||||
| D1 CSF 원 라이브러리(앱 CSF 와 같은 값) | 0.930 → 0.948 | +0.233 → +0.233 | 1.942 → 1.964 | 20.6 | 80.7 |
|
||||
|
||||
### 지형 꼴 · 분류 · 비용
|
||||
|
||||
| 방법 | 빨강 %(83.9 % 이상) 전 → 후 | 기울기 p50 / p90 % 전 → 후 | 업체 지면 대비 맞음 · 놓침 · 잘못 넣음 % | 시간 s | 최대 메모리 GB |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A0 | 32.0 → 26.8 | 69 / 120 → 68 / 107 | 100 · 0 · 0 | 앱 WF1 239(기록) | 기록 없음 |
|
||||
| A2 | 35.2 → 29.4 | 70 / 129 → 69 / 113 | 55.1 · 44.9 · 12.2 | (같은 WF1 · CSF 4.1) | 기록 없음 |
|
||||
| B1 | 16.4 → 14.8 | 70 / 89 → 69 / 88 | 100 · 0 · 0 | 20.3 | 3.6 |
|
||||
| B1w | 16.3 → 14.8 | 70 / 89 → 69 / 88 | 100 · 0 · 0 | 4.1 | 1.8 |
|
||||
| B2 | 22.5 → 20.1 | 70 / 98 → 69 / 95 | 94.6 · 5.4 · 53.4 | 57.8 | 6.7 |
|
||||
| B3 | 2.7 → 2.2 ※ | 63 / 76 → 63 / 75 | 21.9 · 78.1 · 11.1 | 101.9 | 7.9 |
|
||||
| B3t | 34.0 → 31.3 | 75 / 117 → 74 / 112 | 74.3 · 25.7 · 77.9 | 273.5 | 8.6 |
|
||||
| C1 | 38.8 → 36.6 | 76 / 211 → 75 / 190 | 12.7 · 87.3 · 15.0 | 173.3 | 20.1 |
|
||||
| C1t | 52.1 → 49.5 | 86 / 234 → 83 / 210 | 41.4 · 58.6 · 51.1 | 176.5 | 19.8 |
|
||||
| C2 | 27.5 → 26.1 | 72 / 130 → 72 / 122 | 61.3 · 38.7 · 9.2 | 453.4 | 5.4 |
|
||||
| D1 | 2.2 → 1.7 ※ | 57 / 71 → 56 / 70 | 8.8 · 91.2 · 7.4 | 25.1 | 6.0 |
|
||||
|
||||
- ※ B3 · D1 은 급경사 절반 이상이 비어 남은 완경사만 잰 값 — 좋은 값 아님.
|
||||
- 시간 = LAS 읽기 ~ 1 m 격자 GeoTIFF 쓰기까지(분류 LAS 쓰기 포함). 메모리 = 프로세스 나무 합.
|
||||
- C1 은 통째(4,900 만 점)로 돌리면 16 GB 넘어 멈춤 → 2×2 타일(겹침 30 m)로 돌림 · 메모리 20 GB 중 약 5 GB 는 타일 나누는 laspy 몫.
|
||||
- 「잘못 넣음」은 업체가 지면으로 안 본 점 — 진짜 오류인지 업체 누락인지 가르지 못함(SMRF 는 지도로 보면 덤불 · 그루터기 자리 +1 m 혹이 많음).
|
||||
- 앱 평활 재현 확인 — 이 시험의 평활(가우스 σ 0.5 m + 쌍삼차 → 1.078 m 칸)로 A0 를 다시 평활하면 저장된 A1 과 최대 0.1 mm 차.
|
||||
- 잘 지은 DEM(B1 · B2 · C2)에는 앱 평활이 오히려 오차를 조금 늘림(0.117 → 0.131).
|
||||
|
||||
## ③ 그림(`figs/` · 100 m × 100 m · 각 구역 두 장)
|
||||
|
||||
| 파일 | 보이는 것 |
|
||||
| --- | --- |
|
||||
| `steep_1_음영기복.png` · `steep_2_차이.png` | 가장 가파른 곳(평균 86 %). A0 는 1 m 계단 · 줄무늬가 음영 전체에 깔림 · A1 은 무늬가 흐려질 뿐 남음. B1 은 매끈한 비탈 · 골. 차이 지도에서 A0 · A1 은 거의 전면 파랑(B1 보다 0.5~1 m 낮음) · RMS 1.09 / 0.98 m. SMRF 는 군데군데 빨강 혹(덤불) · 나머지는 흰색(같음). |
|
||||
| `edge_1_음영기복.png` · `edge_2_차이.png` | 빈터가 가장 많은 곳 — 임도 노면 · 깎기 비탈이 지나감. A0 · A1 은 **깎기 비탈 띠를 따라 1 m 넘게 낮음**(진한 파랑 띠) — 평활 뒤에도 그대로. SMRF 는 노면 · 비탈을 잘 따라감(RMS 0.45 m) · WBT 경사 필터는 비탈 끝을 깎아 먹음. |
|
||||
| `flat_1_음영기복.png` · `flat_2_차이.png` | 가장 완만한 곳(그래도 평균 57 % · 이 사업엔 평지가 없음). A0 줄무늬 · 전면 파랑, A1 은 무늬만 흐려짐(RMS 0.66 → 0.54 m). SMRF 0.37 m · WBT 0.96 m(나무 둘레 혹). |
|
||||
|
||||
## ④ 설치한 것(모두 `lidar_lab/` 안 · git 무시 · 시스템 · PATH · 레지스트리 손대지 않음)
|
||||
|
||||
| 것 | 판 | 크기 | 걸린 시간 |
|
||||
| --- | --- | --- | --- |
|
||||
| micromamba(단일 실행 파일) | latest(26-11) | 4.7 MB 받음 | 수 초 |
|
||||
| PDAL 환경 `env_pdal`(pdal · python-pdal · gdal · python 3.12 · conda-forge) | PDAL 2.10.2 | 4.0 GB + 받은 꾸러미 보관 1.3 GB(`mamba_root` · 지워도 됨) | 약 6 분 |
|
||||
| 파이썬 venv(데스크탑 3.12) — whitebox-workflows · cloth-simulation-filter · laspy[lazrs] · numpy · scipy · rasterio · matplotlib · psutil | WbW 2.0.6 · CSF 1.1.7 · laspy 2.7.0 · lazrs 0.8.2 · rasterio 1.5.2 | 428 MB | 약 2 분 |
|
||||
| WhiteboxTools Open Core | 2.4.0 | 받음 25 MB · 풀어 99 MB | 수 초 |
|
||||
| QGIS | 설치 안 함(지면점 분류 = PDAL SMRF → B2 로 갈음) | — | — |
|
||||
|
||||
- 시험 자료 `data/`(검사점 표시 LAS 1.6 GB · 분류 표) 2.0 GB — 다시 돌릴 일 없으면 지워도 됨. 시험 스크립트 `scripts/` · 원 기록 `runs/`(방법마다 시간 · 메모리 json · 로그 · `metrics.json`).
|
||||
|
||||
## ⑤ 앱에 넣는다면(553-B ④ 와 맞춤) · 위험
|
||||
|
||||
| 길 | 이번 시험이 말하는 것 |
|
||||
| --- | --- |
|
||||
| **격자만 TIN 으로(553-A 경계 C · D)** — 업체 분류 LAS | 가장 큰 이득(0.77 → 0.12 m) · 새 도구 없이 지금 스택의 WhiteboxTools `LidarTINGridding`(MIT · 0.8 초)로 됨 · 553-B 길 A(Whitebox)와 같은 집안 |
|
||||
| 553-B 길 B — PDAL 따로 실행 | 분류 없는 LAS 의 지면 가르기 = SMRF 가 가장 나음(0.27 m · 58 초) · 다만 환경 4 GB · 낮은 덤불 혹 → `slope` · `threshold` 값 시험 필요 |
|
||||
| 553-B 길 A — WbW NG 개선 필터 | **이 숲에선 쓰지 않음**(RMS 3.7 m 넘음 · 4,900 만 점 통째 16 GB 넘어 멈춤) |
|
||||
| 553-B 길 C — CSF 원 라이브러리 | **쓰지 않음** — 앱 자체 CSF(0.71 m)보다도 못함(급경사 80 % 빔) |
|
||||
| WBT 경사 필터(Open Core) | 0.26 m 로 쓸 만하나 4,900 만 점 7.5 분 · 업체 지면 39 % 놓침 |
|
||||
|
||||
위험
|
||||
|
||||
- **WhiteboxTools GeoTIFF 반 칸 어긋남** — Open Core 가 쓴 TIFF 를 rasterio 로 그대로 읽으면 반 칸(0.5 m) 밀려 같은 B1 이 0.12 → 0.39 m 로 나빠짐(지금 앱의 칸 모서리 흠과 같은 꼴). 넣을 때 칸 자리 대조 시험 필수. WbW NG(geotiff-rs)는 어긋남 없음.
|
||||
- **값이 바뀜** — DEM 이 평균 약 0.6 m 오르고 깎기 비탈 · 노면 꼴이 바뀜 → 기존 프로젝트의 종단 · 토량 · 급경사 칸 % 가 달라짐. 새 프로젝트부터 또는 고르는 칸으로(사용자 협의).
|
||||
- 앱 평활은 좋은 DEM 엔 오히려 오차를 조금 늘림 → TIN 으로 바꾸면 평활 기본값도 다시 볼 거리.
|
||||
- TIN 긴 변 끊기(이번 50 m) 값에 따라 큰 빈터가 빔 — 이번엔 유효 범위 안 0.02 %.
|
||||
- 이 시험은 업체 분류를 정답으로 둠 — SMRF 의 「잘못 넣음」 53 % 중 진짜 지면 몫은 가르지 못함. 숲 · 급경사 한 사업 하나만 잼(평지 · 미분류 LAS 는 따로 시험 필요).
|
||||
- 두 PC 판 차이 · 도구 판 고정(553-B ⑤ 그대로).
|
||||
@@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected)
|
||||
app.include_router(m02_combos_router, dependencies=protected)
|
||||
app.include_router(m02_drawing_router, dependencies=protected)
|
||||
app.include_router(m02_labor_router, dependencies=protected)
|
||||
# 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router,
|
||||
dependencies=protected,
|
||||
)
|
||||
# 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router,
|
||||
|
||||
@@ -15,10 +15,9 @@ import re
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
import master_unit as mu
|
||||
from check_master_body import section_of
|
||||
|
||||
# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움
|
||||
_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
|
||||
_UNITS = sorted(
|
||||
(
|
||||
"㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 "
|
||||
@@ -33,11 +32,10 @@ _UNIT_AT_END = re.compile(
|
||||
)
|
||||
_HANGUL = re.compile(r"[가-힣]")
|
||||
_HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」
|
||||
_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」
|
||||
|
||||
|
||||
def unit(text: str) -> str:
|
||||
return _ALIAS.get(text, text)
|
||||
return mu.shown(text) # 보이는 꼴 별칭 한 벌(712-2 `master_unit`)
|
||||
|
||||
|
||||
def _times(text: str | None) -> Decimal:
|
||||
@@ -97,7 +95,7 @@ def basis(table: dict) -> dict:
|
||||
|
||||
def result_per(result_unit) -> list[tuple[Decimal, str]]:
|
||||
"""로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿."""
|
||||
return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))]
|
||||
return [(n, unit(u)) for n, u in mu.per(result_unit)]
|
||||
|
||||
|
||||
def _formula_text(row: dict) -> str:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""인력 · 재료 · 경비 묶음 — **셈 때 파생**(정본에 칸 없음 · PLAN 712-1 · 요구서 원칙 5).
|
||||
|
||||
규칙 한 줄:
|
||||
- 호표 · 덧줄 = `비목` 따라(노무비 → 인력 · 재료비 → 재료 · 경비 → 경비).
|
||||
- 비목 없는 로직 줄 = 부르는 로직이 기계경비(`is_machine`)면 경비 + `장비` · 아니면 하위.
|
||||
두 줄 모두 엔진 줄 `비목` 에 노무비 · 재료비 · 경비 세 몫이 그대로 남음(한 줄 · 흩어 넣기 둘 다).
|
||||
|
||||
기계경비 판별 = `is_machine` 한 곳 — M01 엔진 · M02 엑셀 맞춤(`M02_Hopyo_Ours`) ·
|
||||
B09 산근(`B09_Estimation_Sangeun` · `_Text`)이 같이 씀.
|
||||
엔진 줄 덧칸 = `묶음`(출력 꼬리표만 · 입력 값 덧칸 `값묶음` 은 715-1b 몫) · 기계경비 줄 `장비` true.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import master_formula as mf
|
||||
|
||||
BUNDLES = ("인력", "재료", "경비", "하위")
|
||||
BY_ITEM = {"노무비": "인력", "재료비": "재료", "경비": "경비"}
|
||||
BY_KIND = {"인력": "인력", "재료": "재료", "기계": "경비"} # 비목 빈 옛 줄 받침
|
||||
|
||||
|
||||
def is_machine(row: dict | None) -> bool:
|
||||
"""기계경비 로직 — 돈 로직이고 결과단위 「원/시간」(작업량 「㎥/시간」 · 이름 「시간당」 아님)."""
|
||||
if not row or "결과" in row or isinstance(row.get("흐름"), dict):
|
||||
return False
|
||||
return re.sub(r"\s", "", str(row.get("결과단위") or "")) == "원/시간"
|
||||
|
||||
|
||||
def called(master, item: dict, env: dict | None = None) -> dict | None:
|
||||
"""로직 줄이 부르는 로직 줄(정본) — 못 찾으면 None."""
|
||||
try:
|
||||
node = mf.parse(item.get("요소") or "")
|
||||
if not node or node[0] != "logic":
|
||||
return None
|
||||
key = mf.fill(node[1], env) if env is not None and "{" in node[1] else node[1]
|
||||
return master.logic(key)
|
||||
except (mf.FormulaError, mf.EmptyInput, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def bundle(master, item: dict, env: dict | None = None) -> tuple[str, bool]:
|
||||
"""줄 하나(호표 · 덧줄 정본 줄) → (묶음, 장비인가)."""
|
||||
if item.get("비목") in BY_ITEM:
|
||||
return BY_ITEM[item["비목"]], False
|
||||
if item.get("종류") == "로직":
|
||||
return ("경비", True) if is_machine(called(master, item, env)) else ("하위", False)
|
||||
return BY_KIND.get(str(item.get("종류")), "하위"), False
|
||||
|
||||
|
||||
def tag(master, row: dict, lines: list[dict], env: dict | None = None) -> list[dict]:
|
||||
"""엔진 `run` 줄에 `묶음`(+ 기계경비 줄 `장비`) — 줄 차례 = 호표 줄 하나씩 뒤 덧줄 하나씩."""
|
||||
for item, line in zip([*row.get("호표", []), *row.get("덧줄", [])], lines):
|
||||
name, machine = bundle(master, item, env)
|
||||
line["묶음"] = name
|
||||
if machine:
|
||||
line["장비"] = True
|
||||
return lines
|
||||
@@ -657,6 +657,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
}
|
||||
)
|
||||
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
|
||||
mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1)
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
|
||||
|
||||
|
||||
@@ -676,6 +677,7 @@ from master_material import ( # noqa: E402, F401
|
||||
pick,
|
||||
)
|
||||
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
|
||||
import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음
|
||||
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
|
||||
_check_cut,
|
||||
_check_node,
|
||||
|
||||
@@ -12,6 +12,8 @@ import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
import master_unit as mu
|
||||
|
||||
LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = "MT" # 자재품목 테이블ID
|
||||
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
|
||||
@@ -27,7 +29,6 @@ PICK_KEYS = (
|
||||
) # 재료 고르기 조건 칸 (`_틀.md` 7장)
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
|
||||
|
||||
def plain(s) -> str:
|
||||
@@ -41,8 +42,7 @@ def plain_spec(s) -> str:
|
||||
|
||||
|
||||
def plain_unit(s) -> str:
|
||||
s = plain(s).replace("ℓ", "l")
|
||||
return _UNITS.get(s, s)
|
||||
return mu.plain(s) # 별칭 · 맞대기 꼴 한 벌(712-2)
|
||||
|
||||
|
||||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||||
|
||||
@@ -9,18 +9,10 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from decimal import Decimal
|
||||
|
||||
import master_material as mm
|
||||
from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
|
||||
|
||||
# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴)
|
||||
UNITS = {
|
||||
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
|
||||
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
|
||||
"넓이": {"cm2": 1, "m2": 10000},
|
||||
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
|
||||
}
|
||||
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
|
||||
REGIONS = (
|
||||
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
|
||||
" 전남광주"
|
||||
@@ -60,27 +52,6 @@ def machine(spec: dict, value) -> bool:
|
||||
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
|
||||
|
||||
|
||||
# ── 단위 ───────────────────────────────────────────────────────────────
|
||||
def kind_of(unit) -> str | None:
|
||||
got = _SCALE.get(mm.plain_unit(unit))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def same_kind(a, b) -> bool:
|
||||
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피)."""
|
||||
pa, pb = mm.plain_unit(a), mm.plain_unit(b)
|
||||
return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b))
|
||||
|
||||
|
||||
def convert(price, have, want):
|
||||
"""`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로."""
|
||||
if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want):
|
||||
return price
|
||||
if not same_kind(have, want) or kind_of(have) is None:
|
||||
return price
|
||||
return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1])
|
||||
|
||||
|
||||
# ── 찾기 ───────────────────────────────────────────────────────────────
|
||||
def _words(term) -> tuple[list[str], list[str]]:
|
||||
"""(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임."""
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8).
|
||||
|
||||
`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet`
|
||||
두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌.
|
||||
- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 ·
|
||||
덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만).
|
||||
- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로).
|
||||
- `펼침`(장비 줄만 · 714-1b) = 부른 기계경비 로직의 계산서 줄(조종원 · 주연료 · 잡재료비 · 기계손료 …) ·
|
||||
시간당 비목 소계 · 계 · 부른 키 · 인자 — 셈 결과를 그대로 실음(환산 규칙을 여기 두지 않음).
|
||||
- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리
|
||||
`기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」).
|
||||
- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위
|
||||
(712-2 `master_unit.note`) · 없으면 「」.
|
||||
줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import master_basis as mbs
|
||||
import master_bundle as mb
|
||||
import master_formula as mf
|
||||
import master_text as mtx
|
||||
import master_unit as mu
|
||||
|
||||
KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침")
|
||||
_HOLE = re.compile(r"\{([^{}]+)\}")
|
||||
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
|
||||
|
||||
|
||||
def _names(expr) -> tuple[set[str], set[str]]:
|
||||
"""식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈."""
|
||||
if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만
|
||||
return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set()
|
||||
if expr in (None, ""):
|
||||
return set(), set()
|
||||
try:
|
||||
tree = mf.parse(expr)
|
||||
except mf.FormulaError:
|
||||
return set(_HOLE.findall(str(expr))), set()
|
||||
names, tables = set(), set()
|
||||
for node in mf.walk(tree):
|
||||
if node[0] == "name":
|
||||
names.add(node[1])
|
||||
elif node[0] in ("find", "logic", "ref"):
|
||||
names |= set(_HOLE.findall(node[1]))
|
||||
if node[0] == "find":
|
||||
tables.add(node[1])
|
||||
return names, tables
|
||||
|
||||
|
||||
def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]:
|
||||
"""식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로."""
|
||||
steps = {s["이름"]: s["식"] for s in row.get("중간", [])}
|
||||
inputs = [s["이름"] for s in row.get("입력", [])]
|
||||
seen, tables, todo = set(), [], list(exprs)
|
||||
while todo:
|
||||
names, found = _names(todo.pop())
|
||||
tables += [t for t in sorted(found) if t not in tables]
|
||||
for name in names - seen:
|
||||
seen.add(name)
|
||||
if name in steps:
|
||||
todo.append(steps[name])
|
||||
return [n for n in inputs if n in seen], tables
|
||||
|
||||
|
||||
def _times(x: Decimal) -> str:
|
||||
return format(x.normalize(), "f")
|
||||
|
||||
|
||||
def _per_text(per: list[tuple[Decimal, str]]) -> str:
|
||||
return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per))
|
||||
|
||||
|
||||
def _basis(master, tables: list[str], env: dict) -> dict | None:
|
||||
for ref in tables:
|
||||
try:
|
||||
got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref))
|
||||
except (*_ERRORS, mf.EmptyInput):
|
||||
continue
|
||||
if got["갈래"] == "단위":
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _note(basis: dict | None, per: list, line: dict) -> str:
|
||||
notes = []
|
||||
if basis and len(per) == 1:
|
||||
times, one = per[0]
|
||||
if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times:
|
||||
ratio = basis["기준배"] / times
|
||||
how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}"
|
||||
notes.append(
|
||||
f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}"
|
||||
)
|
||||
goods = line.get("품목") or {}
|
||||
if goods.get("단위") and line.get("단위"):
|
||||
notes.append(mu.note(goods["단위"], line["단위"]))
|
||||
return " · ".join(n for n in notes if n)
|
||||
|
||||
|
||||
def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict:
|
||||
uses, tables = _closure(row, exprs)
|
||||
basis = _basis(master, tables, env)
|
||||
out = {
|
||||
"쓰는입력": uses,
|
||||
"기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}"
|
||||
if basis
|
||||
else None,
|
||||
"환산근거": _note(basis, per, line),
|
||||
}
|
||||
if bundle:
|
||||
out["묶음"] = bundle[0]
|
||||
if bundle[1]:
|
||||
out["장비"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _spread(files: dict, master, item: dict, env: dict) -> dict:
|
||||
"""장비 줄이 부른 기계경비 로직 한 장 — `{키, 이름, 단위, 입력, 줄, 소계, 계}` · 못 셈 = 까닭."""
|
||||
node = mf.parse(item["요소"])
|
||||
key = node[1]
|
||||
try:
|
||||
key = mf.fill(key, env) if "{" in key else key
|
||||
given = {k: mf.evaluate(v, env, master, 1) for k, v in node[2]}
|
||||
got = mtx.lines(files, key, given)
|
||||
except (*_ERRORS, mf.EmptyInput) as e:
|
||||
return {"키": key, "까닭": str(e) or type(e).__name__}
|
||||
if not got.get("ok"):
|
||||
return {"키": key, "입력": given, "까닭": got.get("reason")}
|
||||
groups = got.get("groups") or []
|
||||
return {
|
||||
"키": key,
|
||||
"이름": master.logic(key).get("이름") or "",
|
||||
"단위": _per_text(mbs.result_per(master.logic(key).get("결과단위"))) or None,
|
||||
"입력": given,
|
||||
"줄": [
|
||||
{"비목": g["비목"], **{k: one.get(k) for k in ("이름", "글", "금액")}}
|
||||
for g in groups
|
||||
for one in g["줄"]
|
||||
],
|
||||
"소계": {g["비목"]: g.get("소계") for g in groups},
|
||||
"계": got.get("계"),
|
||||
}
|
||||
|
||||
|
||||
def annotate(files: dict, key: str, given: dict, answer: dict) -> dict:
|
||||
"""`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로."""
|
||||
if not answer.get("ok") or "groups" not in answer:
|
||||
return answer
|
||||
master = mf.Master(files)
|
||||
row = master.logic(key)
|
||||
per = mbs.result_per(row.get("결과단위"))
|
||||
answer["기준단위"] = _per_text(per) or None
|
||||
try:
|
||||
env = mtx._env_of(master, row, given)
|
||||
except _ERRORS:
|
||||
env = {}
|
||||
if "결과" in row:
|
||||
for line in answer["groups"][0]["줄"]:
|
||||
line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None))
|
||||
return answer
|
||||
try:
|
||||
result = mf.run(master, key, given)
|
||||
except _ERRORS:
|
||||
result = None
|
||||
slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]}
|
||||
items = row.get("호표") or []
|
||||
for n, item in enumerate(items):
|
||||
line = result["줄"][n] if result else {}
|
||||
if result:
|
||||
parts = [p for p, _ in mtx._shares(line)]
|
||||
elif item.get("종류") == "로직" and "비목" not in item:
|
||||
parts = list(mf.COST_ITEMS)
|
||||
else:
|
||||
parts = [item.get("비목")]
|
||||
exprs = [item.get("수량"), item.get("요소")]
|
||||
got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env))
|
||||
if got.get("장비"):
|
||||
got["펼침"] = _spread(files, master, item, env)
|
||||
for part in parts:
|
||||
target = next(slots.get(part, iter(())), None)
|
||||
if target is not None:
|
||||
target.update(got)
|
||||
for extra in row.get("덧줄") or []:
|
||||
target = next(slots.get(extra["비목"], iter(())), None)
|
||||
if target is not None:
|
||||
target.update(
|
||||
_fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra))
|
||||
)
|
||||
return answer
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""단위 한 벌 — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 환산 근거 글(PLAN 712-2).
|
||||
|
||||
쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) ·
|
||||
조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) ·
|
||||
표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀.
|
||||
- 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿).
|
||||
- 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음
|
||||
(사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
# 맞대기 꼴(`plain`) 별칭 — NFKC · 소문자 · 빈칸 없앤 뒤
|
||||
ALIAS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
# 보이는 꼴(`shown`) 별칭 — 표 기준 · 결과단위 분모(원문 기호 ㎡ · ㎥ 그대로)
|
||||
SHOWN = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
|
||||
# 같은 성격 — {성격: {맞대기 단위: 기준 단위 몇 배}}
|
||||
UNITS = {
|
||||
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
|
||||
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
|
||||
"넓이": {"cm2": 1, "m2": 10000},
|
||||
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
|
||||
"개수": {"개": 1, "본": 1, "매": 1},
|
||||
}
|
||||
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
|
||||
WEIGHT_TABLE = "CC000337" # 건설품셈 공통 1-2-4 재료의 단위중량(㎏/㎥)
|
||||
# 결과단위 분모 — 「원/10㎡」 → (10, ㎡) · 「원/1,000본」 → (1000, 본)
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)")
|
||||
|
||||
|
||||
def plain(unit) -> str:
|
||||
"""맞대기 꼴 — 「㎥」 = 「m3」 · 「EA」 = 「개」 · 「Ton」 = 「톤」 · 「리터」 = 「ℓ」."""
|
||||
s = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(unit or "")).lower())
|
||||
s = s.replace("ℓ", "l")
|
||||
return ALIAS.get(s, s)
|
||||
|
||||
|
||||
def shown(unit) -> str:
|
||||
return SHOWN.get(unit, unit)
|
||||
|
||||
|
||||
def per(result_unit) -> list[tuple[Decimal, str]]:
|
||||
"""결과단위 분모마다 (배, 단위 원문 글) — 배 없으면 1 · 분모가 여럿이면 여럿."""
|
||||
return [
|
||||
(Decimal(n.replace(",", "")) if n else Decimal(1), u)
|
||||
for n, u in _PER.findall(str(result_unit or ""))
|
||||
]
|
||||
|
||||
|
||||
def kind_of(unit) -> str | None:
|
||||
got = _SCALE.get(plain(unit))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def same_kind(a, b) -> bool:
|
||||
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피 · 개수)."""
|
||||
return plain(a) == plain(b) or (kind_of(a) is not None and kind_of(a) == kind_of(b))
|
||||
|
||||
|
||||
def _weighs(a, b) -> bool:
|
||||
return {kind_of(a), kind_of(b)} == {"무게", "부피"}
|
||||
|
||||
|
||||
def factor(have, want, density=None) -> Decimal | None:
|
||||
"""`have` 1 단위가 `want` 몇 단위인가 — 단가는 ÷, 수량은 ×.
|
||||
부피 ↔ 무게는 단위중량(㎏/㎥) `density` 가 있을 때만 · 못 하면 None."""
|
||||
a, b = plain(have), plain(want)
|
||||
if a == b:
|
||||
return Decimal(1)
|
||||
if same_kind(a, b):
|
||||
return Decimal(_SCALE[a][1]) / Decimal(_SCALE[b][1])
|
||||
if density and _weighs(a, b):
|
||||
grams_per_cm3 = Decimal(str(density)) / Decimal(1000)
|
||||
base = Decimal(_SCALE[a][1]) * (
|
||||
grams_per_cm3 if kind_of(a) == "부피" else 1 / grams_per_cm3
|
||||
)
|
||||
return base / Decimal(_SCALE[b][1])
|
||||
return None
|
||||
|
||||
|
||||
def convert(price, have, want, density=None):
|
||||
"""`have` 단위 단가 → `want` 단위 단가 — 못 바꾸면 그대로(경고는 부르는 쪽)."""
|
||||
if price is None or not want or not have or plain(have) == plain(want):
|
||||
return price
|
||||
if same_kind(have, want): # × 뒤 ÷ — 옛 셈과 같은 Decimal 꼴(자릿수)
|
||||
return price * Decimal(_SCALE[plain(want)][1]) / Decimal(_SCALE[plain(have)][1])
|
||||
got = factor(want, have, density) # want 1 단위 = have 몇 단위
|
||||
return price if got is None or got == 1 else price * got
|
||||
|
||||
|
||||
def note(have, want, density=None) -> str:
|
||||
"""환산 근거 글 — 「kg → 톤 ×1000」 · 「본 = 개(개수 1:1)」 · 못 바꾸면 까닭."""
|
||||
if not have or not want or plain(have) == plain(want):
|
||||
return ""
|
||||
got = factor(want, have, density)
|
||||
if got is not None:
|
||||
if kind_of(have) == "개수" and got == 1:
|
||||
return f"{have} = {want}(개수 1:1)"
|
||||
how = f" · 단위중량 {density}㎏/㎥" if _weighs(have, want) else ""
|
||||
return f"{have} → {want} ×{_num(got)}{how}"
|
||||
if _weighs(have, want):
|
||||
return f"{have} ↔ {want} — 단위중량({WEIGHT_TABLE}) 골라야 환산 · 지금 그대로"
|
||||
return f"{have} ↔ {want} — 성격 다름 · 환산 없음(경고)"
|
||||
|
||||
|
||||
def _num(x: Decimal) -> str:
|
||||
return format(x.normalize(), "f") if x == x.to_integral_value() else format(x, "f")[:10]
|
||||
|
||||
|
||||
def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | None:
|
||||
"""단위중량 표에서 고른 줄의 ㎏/㎥ — 기본 줄 없음(고르지 않으면 None)."""
|
||||
for row in master.get(WEIGHT_TABLE).get("줄") or []:
|
||||
if row.get("종별") == kind and row.get("형상") == shape:
|
||||
got = row.get(end)
|
||||
return None if got is None else Decimal(str(got))
|
||||
return None
|
||||
@@ -0,0 +1,47 @@
|
||||
/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const { load } = makeLoader({
|
||||
root: ROOT,
|
||||
stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} },
|
||||
});
|
||||
const urls = [];
|
||||
global.fetch = async (url) => {
|
||||
urls.push(String(url).replace("http://fake", ""));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "",
|
||||
json: async () => ({ logics: [], subs: [], items: [], rows: [] }),
|
||||
};
|
||||
};
|
||||
|
||||
const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts");
|
||||
const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts");
|
||||
const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts");
|
||||
|
||||
async function round(tag) {
|
||||
await api.fetchLogic("K");
|
||||
await api.fetchAuto("K");
|
||||
await api.searchElements("재료", "q");
|
||||
await api.fetchMaterials({ sub: "s" });
|
||||
await api.runCalc({ key: "K", inputs: {} });
|
||||
await api.runText({ key: "K", inputs: {} });
|
||||
await rows.fetchRows("기계.json", 1, 5, "q");
|
||||
await rows.fetchPick("price", "q");
|
||||
await note.loadTable(`T-${tag}`);
|
||||
await rows.fetchGroups(); // 관리자 길 — 늘 /m01
|
||||
await api.saveFiles([]); // 저장 — 늘 /m01
|
||||
return urls.splice(0).map((u) => u.split("?")[0]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = { before: await round("a") };
|
||||
api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" });
|
||||
out.injected = await round("b");
|
||||
api.setLogicApiPaths();
|
||||
out.after = await round("c");
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -1,5 +1,5 @@
|
||||
/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 ·
|
||||
* `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 ·
|
||||
* `M02_MasterTemplete_UI_Side_Library.ts` `buildLibraryFolders`(158-1 자유 폴더 · 716-4) 를 트랜스파일해 Node 로 돌림 ·
|
||||
* `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 ·
|
||||
* stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */
|
||||
const fs = require("fs");
|
||||
@@ -31,6 +31,7 @@ global.fetch = async (url) => {
|
||||
const side = loadTs(
|
||||
path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"),
|
||||
);
|
||||
const lib = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Library.ts"));
|
||||
api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중
|
||||
await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람
|
||||
await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인
|
||||
@@ -38,11 +39,11 @@ global.fetch = async (url) => {
|
||||
await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null);
|
||||
const shape = (f) => ({
|
||||
path: f.path,
|
||||
count: side.docCount(f),
|
||||
count: lib.docCount(f),
|
||||
docs: f.docs.map((r) => r.이름),
|
||||
folders: f.folders.map(shape),
|
||||
});
|
||||
const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} }));
|
||||
const tree = shape(lib.buildLibraryFolders(input.rows, input.view ?? { 폴더: [], 자리: {} }));
|
||||
const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5
|
||||
process.stdout.write(JSON.stringify({ urls, tree, classes }));
|
||||
})();
|
||||
|
||||
@@ -111,7 +111,8 @@ def test_글_속_금액은_산근_풀이_값과_같음():
|
||||
whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code])))
|
||||
sheets, price_of = _solver_prices(whole, code, given)
|
||||
block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1")
|
||||
times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
|
||||
# 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
|
||||
times = sg._unit(whole.logic(code))[1]
|
||||
got = st.render_sangeun_text(whole, code, given, price_of, times=times)
|
||||
parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS)
|
||||
assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code
|
||||
|
||||
@@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None}
|
||||
|
||||
def _save(folder, doc):
|
||||
with store.at(folder):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"]
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"]
|
||||
return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes())
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811
|
||||
# MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐)
|
||||
mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}]
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
|
||||
"이름"
|
||||
]
|
||||
key = refs.create(
|
||||
"precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats}
|
||||
)["이름"]
|
||||
stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"]))
|
||||
return key, stone + _price(MAT) * 2
|
||||
|
||||
@@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F
|
||||
with store.at(world["system"]):
|
||||
outer = refs.create(
|
||||
"precombo",
|
||||
{"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]},
|
||||
{
|
||||
"이름": "겹",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
|
||||
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
|
||||
|
||||
@@ -49,16 +49,17 @@ def test_줄_해시_옵션은_M01():
|
||||
from test_b01_settings_cards import _cards
|
||||
|
||||
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]]
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:8]]
|
||||
assert heads == [
|
||||
"m01-master-data?open=로직",
|
||||
"m01-master-data?open=옵션",
|
||||
"m01-master-data?open=별칭", # 718-3
|
||||
"m02-master-template?open=단가산출", # 717-2a
|
||||
"m02-master-template?open=일위대가조합",
|
||||
"m02-master-template?open=구조물조합",
|
||||
"m02-master-template?open=도면",
|
||||
]
|
||||
assert all("&root=master" in c[2] for c in cards[4:7])
|
||||
assert all("&root=master" in c[2] for c in cards[4:8])
|
||||
|
||||
|
||||
@needs_node
|
||||
@@ -77,25 +78,22 @@ def test_해시_진입_셋():
|
||||
+ fn.group(0).replace("export function", "function")
|
||||
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
|
||||
)
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합"):
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합", "단가산출"):
|
||||
got = _run(script, f"open={want}&root=master&company=3")
|
||||
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
|
||||
|
||||
|
||||
def test_reveal_옵션만_줄을_누름():
|
||||
def test_reveal_옵션은_M01_로():
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
body = side.split("const reveal = ")[1].split("\n };")[0]
|
||||
assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음
|
||||
assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만
|
||||
# 716-1 · 716-3 · 717-2a — 옵션 정의는 M01 로 옮김 · M02 reveal 은 단가산출 · 일위대가 · 도면만 펼침
|
||||
flat = " ".join(body.split())
|
||||
assert (
|
||||
'if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();' in body
|
||||
)
|
||||
# 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐
|
||||
options = _text(M02 / "StructOptions" / "M02_SO_Options.ts")
|
||||
assert options.count('el("button"') == 1
|
||||
assert "openDefScreen" in options
|
||||
# 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만
|
||||
assert "옵션: section(options.title, options.body)," in side
|
||||
'target === "단가산출" ? sangeunSec : target === "일위대가조합" ? precomboSec'
|
||||
' : target === "도면" ? drawing : null'
|
||||
) in flat
|
||||
assert 'sec?.classList.remove("is-collapsed");' in body
|
||||
assert "options" not in body and "옵션: section(" not in side
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch):
|
||||
company = layers.company_dir(7)
|
||||
company.mkdir(parents=True)
|
||||
with store.at(company):
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"]
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"]
|
||||
project = tmp_path / "storage" / "7" / "1" / "proj"
|
||||
project.mkdir(parents=True)
|
||||
return {"system": system, "key": key, "company": other, "root": project}
|
||||
|
||||
@@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None:
|
||||
assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick
|
||||
assert "(!work.select.value || x.공종 === work.select.value)" in pick
|
||||
sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8")
|
||||
assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet
|
||||
# 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀)
|
||||
assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet
|
||||
for name in ("Labor_Pick", "Labor_Sheet"):
|
||||
text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8")
|
||||
assert len(text.splitlines()) <= 700
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""712-1 지킴 — 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳(`master_bundle`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_auto as mau # noqa: E402
|
||||
import master_bundle as mb # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
|
||||
FILES = cm.load()
|
||||
MASTER = mf.Master(FILES)
|
||||
LOGICS = {r["키"]: r for d in FILES.values() if d.get("그룹") == "로직" for r in d.get("줄") or []}
|
||||
|
||||
|
||||
def test_every_line_bundled():
|
||||
"""호표 · 덧줄 전수 — 하위(못 가름) ≤ 31 · 나머지는 인력 · 재료 · 경비 · 기계경비 줄 = 경비."""
|
||||
count = dict.fromkeys(mb.BUNDLES, 0)
|
||||
machine = 0
|
||||
for row in LOGICS.values():
|
||||
for item in [*row.get("호표", []), *row.get("덧줄", [])]:
|
||||
name, is_mach = mb.bundle(MASTER, item)
|
||||
count[name] += 1
|
||||
machine += is_mach
|
||||
if is_mach:
|
||||
assert name == "경비"
|
||||
assert sum(count.values()) > 5_000
|
||||
assert count["하위"] <= 31, count
|
||||
assert machine >= 1_000, machine
|
||||
|
||||
|
||||
def test_machine_rule_one_place():
|
||||
"""기계경비 = 결과단위 원/시간 돈 로직 — 작업량(㎥/시간) · 이름 「시간당」 은 아님."""
|
||||
machines = {k for k, r in LOGICS.items() if mb.is_machine(r)}
|
||||
assert {"GC000267", "GC000268", "GF000145"} <= machines
|
||||
assert not machines & {"GF000102", "GC000422", "GC000260"} # 작업량 · ㎡/시간 · 원/㎡
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
|
||||
assert all(ours.is_mach_logic(r) == mb.is_machine(r) for r in LOGICS.values())
|
||||
|
||||
|
||||
def test_run_lines_tagged():
|
||||
"""엔진 `run` 줄마다 `묶음` · 기계경비 줄은 `장비` + 비목 노 · 재 · 경 세 몫."""
|
||||
seen = set()
|
||||
for key in ("GF000169", "GF000103"):
|
||||
row = LOGICS[key]
|
||||
given = {k: v for k, v in mau.values(MASTER, row).items() if v is not None}
|
||||
got = mf.run(MASTER, key, given)
|
||||
for line in got["줄"]:
|
||||
assert line["묶음"] in mb.BUNDLES
|
||||
seen.add(line["묶음"])
|
||||
if line.get("장비"):
|
||||
assert set(line["비목"]) == set(mf.COST_ITEMS)
|
||||
total = sum((line["비목"].get(k) or Decimal(0)) for line in got["줄"] for k in line["비목"])
|
||||
assert abs(total - got["계"]) < 1
|
||||
assert {"인력", "재료", "하위"} <= seen
|
||||
@@ -0,0 +1,59 @@
|
||||
"""712-2 지킴 — 단위 한 벌(`master_unit`) · 다섯 자리가 같은 모듈을 씀 · 알약 배 전수."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_basis as mbs # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
import master_material as mm # noqa: E402
|
||||
import master_pick as mp # noqa: E402
|
||||
import master_unit as mu # noqa: E402
|
||||
|
||||
|
||||
def test_alias_and_count_group():
|
||||
assert mu.plain("EA") == mu.plain("ea") == "개"
|
||||
assert mu.plain("Ton") == mu.plain("t") == mu.plain("톤") == "톤"
|
||||
assert mu.plain("㎥") == "m3" and mu.plain("리터") == mu.plain("ℓ") == "ℓ"
|
||||
assert mu.same_kind("본", "개") and mu.same_kind("매", "EA")
|
||||
assert not mu.same_kind("조", "개") # 한 조 = 여럿 — 묶음 밖
|
||||
assert mu.note("본", "개") == "본 = 개(개수 1:1)"
|
||||
assert mm.plain_unit("EA") == mu.plain("EA")
|
||||
assert mp.convert is mu.convert and mp.UNITS is mu.UNITS
|
||||
|
||||
|
||||
def test_same_kind_convert():
|
||||
assert mu.convert(Decimal(1000), "톤", "kg") == Decimal(1) # 톤 당 1000 원 = kg 당 1 원
|
||||
assert mu.convert(Decimal(5), "kg", "톤") == Decimal(5000)
|
||||
assert mu.convert(Decimal(7), "m", "km") == Decimal(7000)
|
||||
assert mu.factor("kg", "ton") == Decimal("0.001") # B08 「/1000」(kg → ton) 손 식과 같은 값
|
||||
assert mu.note("kg", "톤") == "kg → 톤 ×1000"
|
||||
assert mu.convert(Decimal(3), "kg", "ℓ") == Decimal(3) # 밀도 = 경고만 · 그대로
|
||||
assert "성격 다름" in mu.note("ℓ", "㎾h")
|
||||
|
||||
|
||||
def test_volume_weight_only_when_picked():
|
||||
"""부피 ↔ 무게 = 단위중량 표 고른 줄이 있을 때만 · 기본 줄 없음."""
|
||||
master = mf.Master(cm.load())
|
||||
assert mu.factor("㎥", "톤") is None
|
||||
assert "단위중량(CC000337)" in mu.note("㎥", "톤")
|
||||
rho = mu.unit_weight(master, "모래", "건조")
|
||||
assert rho is not None and rho > 1000
|
||||
assert mu.factor("㎥", "톤", rho) == rho / 1000 # 1 ㎥ = ρ/1000 톤
|
||||
assert mu.convert(Decimal(100), "㎥", "톤", rho) == Decimal(100) * 1000 / rho
|
||||
assert mu.unit_weight(master, "없는 종별", "건조") is None
|
||||
|
||||
|
||||
def test_result_per_one_parser():
|
||||
assert mu.per("원/10㎡") == [(Decimal(10), "㎡")]
|
||||
assert mu.per("원/1,000본") == [(Decimal(1000), "본")]
|
||||
assert mu.per("원/㎡ · 원/㎥(단위 따름)") == [(Decimal(1), "㎡"), (Decimal(1), "㎥")]
|
||||
assert mu.per("㎥/시간") == []
|
||||
assert mbs.result_per("원/ton") == [(Decimal(1), "ton")]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""713-2 정본 개정 확인 창 지킴 시험 — 글로 잡음(화면 · 서버 없음).
|
||||
|
||||
- `/save` 몸에 `개정확인` 이 실림 · 로직 화면 [저장]은 `saveRevised` 한 길(개정 409 → 창 → 다시 보냄)
|
||||
- 창은 부품 상자(모달 · 목록 줄 · 배지)만 · 옛 `stale` 409 는 이 창 대상 밖
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (M01 / f"M01_MasterData_UI_{name}.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_save_body_carries_confirm():
|
||||
api = _read("Logic_Api")
|
||||
assert 'request("/save", { files, 개정확인 })' in api
|
||||
|
||||
|
||||
def test_lab_save_goes_through_revise():
|
||||
lab = _read("LogicLab")
|
||||
assert "saveRevised((ok) => saveFiles(" in lab
|
||||
assert "await saveFiles(" not in lab.replace("saveRevised((ok) => saveFiles(", "")
|
||||
|
||||
|
||||
def test_revise_window_parts_only():
|
||||
rev = _read("LogicLab_Revise")
|
||||
for part in ("ui_template_modal", "ui_template_list_row", "ui_template_badge"):
|
||||
assert f"@ui/{part}" in rev
|
||||
assert "<button" not in rev and "document.createElement" not in rev
|
||||
# 개정 409 만 창 — 옛 stale 409 는 부르는 쪽 토스트 그대로
|
||||
assert "개정?: ReviseHit[]" in rev and "if (!hits) throw error" in rev
|
||||
assert "save(true)" in rev
|
||||
@@ -0,0 +1,75 @@
|
||||
"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와
|
||||
M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
|
||||
from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402
|
||||
from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402
|
||||
|
||||
msl = sheet.msl
|
||||
num = standard.labor_inputs._num # Decimal → JSON 수(두 길 같은 규칙)
|
||||
|
||||
|
||||
def _text(key: str) -> dict:
|
||||
auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None}
|
||||
return sheet.text(key, auto)
|
||||
|
||||
|
||||
def _lines(got: dict, groups: str = "groups") -> list[dict]:
|
||||
return [line for g in got[groups] for line in g["줄"]]
|
||||
|
||||
|
||||
def test_line_keys_and_bundle():
|
||||
got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄
|
||||
assert got["ok"] and got["기준단위"] == "㎥"
|
||||
lines = _lines(got)
|
||||
assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines)
|
||||
machine = [line for line in lines if line.get("장비")]
|
||||
assert machine and all(line["묶음"] == "경비" for line in machine)
|
||||
assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"])
|
||||
assert any(line["묶음"] == "인력" for line in lines)
|
||||
|
||||
|
||||
def test_machine_spread():
|
||||
"""714-1b — 장비 줄 「펼침」 = 부른 기계경비 로직 계산서(조종원 · 연료 · 손료) · 시간당 계."""
|
||||
lines = _lines(_text("GF000103"))
|
||||
spread = next(line for line in lines if line.get("장비"))["펼침"]
|
||||
assert spread["키"].startswith("GC0002") and spread["단위"] == "시간"
|
||||
names = " ".join(one["이름"] for one in spread["줄"])
|
||||
assert "조종원" in names and "손료" in names
|
||||
sums = [v for v in spread["소계"].values() if v is not None]
|
||||
assert spread["계"] == sum(sums) and spread["계"] > 0
|
||||
assert not any(line.get("펼침") for line in lines if not line.get("장비"))
|
||||
|
||||
|
||||
def test_uses_follow_middle_values():
|
||||
"""중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀."""
|
||||
lines = _lines(_text("GF000219"))
|
||||
assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"])
|
||||
|
||||
|
||||
def test_basis_note_tenfold():
|
||||
"""표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음."""
|
||||
one = _lines(_text("GC000037"))
|
||||
assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10"
|
||||
ten = _text("GF000124")
|
||||
assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten))
|
||||
|
||||
|
||||
def test_two_routes_same_shape():
|
||||
"""M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음)."""
|
||||
for key in ("GF000169", "GF000103", "GF000219"):
|
||||
m01 = _lines(_text(key))
|
||||
m02 = _lines(standard.sheet(key, {}), "묶음")
|
||||
assert len(m01) == len(m02)
|
||||
for a, b in zip(m01, m02):
|
||||
assert set(b) == {"이름", "글", "금액", *msl.KEYS}
|
||||
assert a["이름"] == b["이름"]
|
||||
assert num([a.get(k) for k in msl.KEYS]) == [b.get(k) for k in msl.KEYS]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
|
||||
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SHEET = ROOT / "A00_Common" / "calc_sheet.ts"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_한_벌을_씀():
|
||||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
|
||||
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
|
||||
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
|
||||
assert "calcSheetRows(" in est and "buildEst(" in detail
|
||||
|
||||
|
||||
def test_부품_상자만():
|
||||
s = _text(SHEET)
|
||||
assert "<button" not in s and "document.createElement" not in s and "<table" not in s
|
||||
assert "@ui/ui_template_calc_row" in s and "@ui/ui_template_input" in s
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_식_글():
|
||||
s = _text(SHEET)
|
||||
pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
|
||||
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_묶음_장비_한_줄():
|
||||
s = _text(SHEET)
|
||||
pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
|
||||
body = """
|
||||
const eq = (n, a, 쓰는입력 = []) => ({ 이름: n, 글: "", 금액: a, 묶음: "경비", 장비: true, 쓰는입력, 기준단위: "일" });
|
||||
const groups = [
|
||||
{ 비목: "노무비", 소계: 0, 줄: [
|
||||
{ 이름: "포설공", 글: "", 금액: 10, 묶음: "인력", 쓰는입력: [] },
|
||||
eq("굴착기", 100, ["지역"]), eq("굴착기", 7),
|
||||
{ 이름: "보통인부", 글: "", 금액: 5, 묶음: "인력", 쓰는입력: ["두께"], 기준단위: "10㎡", 환산근거: "10㎡당 → 1㎡당 ÷10" } ] },
|
||||
{ 비목: "재료비", 소계: 0, 줄: [ eq("굴착기", 20), eq("굴착기", 2), { 이름: "모래", 글: "", 금액: 3 } ] },
|
||||
{ 비목: "경비", 소계: 0, 줄: [ eq("굴착기", 30), eq("굴착기", null), { 이름: "공구손료", 글: "", 금액: 1, 묶음: "경비" } ] },
|
||||
];
|
||||
const got = bundleLines(groups).map((b) => ({ 묶음: b.묶음, 소계: b.소계, 쓰는입력: b.쓰는입력, 기준단위: b.기준단위, 환산근거: b.환산근거,
|
||||
줄: b.줄.map((l) => "몫" in l ? [l.이름, l.금액, l.몫.map((x) => x.비목)] : l.이름) }));
|
||||
console.log(JSON.stringify(got));
|
||||
"""
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + chr(10) + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
got = json.loads(out.stdout)
|
||||
# 차례 = 인력 · 재료 · 경비 · 같은 이름 장비 둘은 k 번째끼리 한 줄(노 · 재 · 경 몫) · 비목 노무비 장비는 경비(장비) 묶음
|
||||
assert [b["묶음"] for b in got] == ["인력", "재료", "경비"]
|
||||
assert got[0]["줄"] == ["포설공", "보통인부"] and got[0]["소계"] == 15
|
||||
assert got[0]["쓰는입력"] == ["두께"] and got[0]["기준단위"] == ["10㎡"]
|
||||
assert got[0]["환산근거"] == ["10㎡당 → 1㎡당 ÷10"]
|
||||
assert got[1]["줄"] == ["모래"]
|
||||
eq = got[2]
|
||||
assert eq["줄"][0] == ["굴착기", 150, ["노무비", "재료비", "경비"]]
|
||||
assert eq["줄"][1] == ["굴착기", 9, ["노무비", "재료비", "경비"]]
|
||||
assert eq["줄"][2] == "공구손료" and eq["소계"] == 160 and eq["쓰는입력"] == ["지역"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글.
|
||||
|
||||
`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _run(body: str) -> object:
|
||||
text = FX.read_text(encoding="utf-8")
|
||||
pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")]
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_표_행_열쇠와_이름_조각():
|
||||
got = _run(
|
||||
"""
|
||||
const line = {
|
||||
조각: [
|
||||
{ kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } },
|
||||
{ kind: "기호", 글: "*" },
|
||||
{ kind: "값", 글: "두께", 값: "20" },
|
||||
{ kind: "값", 글: "0.009" },
|
||||
{ kind: "값", 글: "뒷길이", 값: "99" },
|
||||
],
|
||||
요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }],
|
||||
};
|
||||
console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"])));
|
||||
"""
|
||||
)
|
||||
# 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌
|
||||
assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]]
|
||||
|
||||
|
||||
def test_빈_줄():
|
||||
assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == []
|
||||
|
||||
|
||||
def test_이음_시험_입력만_고침():
|
||||
lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8")
|
||||
detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
|
||||
# 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감
|
||||
assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab
|
||||
assert "onInput: ctx.onInput" in detail
|
||||
fx = FX.read_text(encoding="utf-8")
|
||||
assert "touch" not in fx and "drafts" not in fx
|
||||
@@ -0,0 +1,135 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage.
|
||||
|
||||
줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 ·
|
||||
줄 고름(차례)이 뺀줄 뒤 제자리 ·
|
||||
[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
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_Sangeun_Calc as calc
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」
|
||||
TWO_MATS = "GC000565" # 원/m · 재료 줄 둘
|
||||
COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _sc(folder, lines, unit="㎡"):
|
||||
with store.at(folder):
|
||||
return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _value(folder, key):
|
||||
with store.at(folder):
|
||||
return refs.Expand(_whole())({**MINE, "키": key})
|
||||
|
||||
|
||||
def test_logic_line_cover_in_precombo(world):
|
||||
"""사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄)."""
|
||||
bare = labor.run_line(_whole(), PCT, {})
|
||||
sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}])
|
||||
with store.at(world["personal"]):
|
||||
kept = store.read("precombo", sc)["문서"]["담은로직"][0]
|
||||
assert kept["패치"] == COVER
|
||||
got = _value(world["personal"], sc)["기준"]
|
||||
labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal(
|
||||
str(bare["줄"][1]["금액"])
|
||||
)
|
||||
assert got["노무비"] == pytest.approx(labor_sum)
|
||||
assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1)
|
||||
|
||||
|
||||
def test_line_pick_by_order_stays_after_drop(world):
|
||||
"""줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에."""
|
||||
import master_pick as mp
|
||||
|
||||
whole = _whole()
|
||||
row = whole.logic(TWO_MATS)
|
||||
item = row["호표"][2]
|
||||
want = mp.search(whole, item["요소"], {}, item.get("단위"))[1]
|
||||
one = {
|
||||
"로직": TWO_MATS,
|
||||
"고름": {"2": want},
|
||||
"자재표시": {"2": {"별도계상": "직접계산"}},
|
||||
"패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]},
|
||||
}
|
||||
with store.at(world["personal"]):
|
||||
doc = labor.clean({"담은로직": [one]})
|
||||
got = labor.calc(doc, {})["줄"][0]
|
||||
picked = [x for x in got["줄"] if x.get("품목")]
|
||||
assert [x["품목"]["키"] for x in picked] == [want]
|
||||
|
||||
|
||||
def test_ref_line_cover_only_that_line(world):
|
||||
"""SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로."""
|
||||
with store.at(world["personal"]):
|
||||
sk = refs.create(
|
||||
"sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
|
||||
)["이름"]
|
||||
before = store.read("sangeun", sk)["판"]
|
||||
ref = {**MINE, "키": sk}
|
||||
sc = _sc(
|
||||
world["personal"],
|
||||
[{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}],
|
||||
)
|
||||
inner = _value(world["personal"], sc)["안"]
|
||||
plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"]
|
||||
covered = calc.calc_doc(
|
||||
{"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER}
|
||||
)
|
||||
assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"])))
|
||||
assert inner[0]["기준"]["계"] != plain["계"]
|
||||
with store.at(world["personal"]):
|
||||
assert store.read("sangeun", sk)["판"] == before
|
||||
|
||||
|
||||
def test_save_as_sangeun(world):
|
||||
"""[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값."""
|
||||
one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"}
|
||||
with store.at(world["personal"]):
|
||||
line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"]
|
||||
got = calc.from_line(one, "줄로 만든", "㎡")
|
||||
doc = store.read("sangeun", got["이름"])["문서"]
|
||||
value = calc.calc_doc(doc)["기준"]
|
||||
assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"}
|
||||
assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5
|
||||
assert value["계"] == pytest.approx(line_value["계"])
|
||||
|
||||
|
||||
def test_refusals(world):
|
||||
with store.at(world["personal"]):
|
||||
with pytest.raises(store.StoreError, match="단위가 빔"):
|
||||
refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]})
|
||||
assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"]
|
||||
bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}}
|
||||
with pytest.raises(store.StoreError, match="참조 못 넣음"):
|
||||
labor.clean({"담은로직": [bad]})
|
||||
with pytest.raises(store.StoreError, match="수가 아님"):
|
||||
labor.clean(
|
||||
{"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]}
|
||||
)
|
||||
sc = _sc(world["personal"], [{"로직": PCT}])
|
||||
with pytest.raises(store.StoreError, match="줄 덮개는"):
|
||||
labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]})
|
||||
|
||||
|
||||
def test_no_patch_same_value(world):
|
||||
"""덮개 없는 줄 = run_line 값 그대로(옛 셈 길)."""
|
||||
sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}])
|
||||
with store.at(world["personal"]):
|
||||
assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0]
|
||||
assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx(
|
||||
labor.run_line(_whole(), PCT, {})["기준"]["계"]
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage.
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage.
|
||||
|
||||
고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 ·
|
||||
순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값.
|
||||
@@ -46,7 +47,7 @@ def _bare(code):
|
||||
|
||||
|
||||
def test_qty_cover_hand_and_pct_recomputed(world):
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값."""
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈."""
|
||||
bare = _bare(PCT)
|
||||
first, second = bare["줄"][0], bare["줄"][1]
|
||||
key = _sk(
|
||||
@@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world):
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]},
|
||||
{
|
||||
"이름": "조합",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.delete("sangeun", sk)
|
||||
one = _calc(world["personal"], sk)["1단위"]["계"]
|
||||
assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1)
|
||||
assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail)
|
||||
assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail)
|
||||
|
||||
|
||||
def test_layer_copy_round_trip(world):
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""서고 나무 폴더(PLAN 716-4) — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기.
|
||||
|
||||
임시 storage.
|
||||
|
||||
`/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 ·
|
||||
폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 ·
|
||||
지우는 것끼리 참조는 됨) ·
|
||||
`대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_Precombo_Folders as pre
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_precombo_folders import Tree, client # noqa: F401 — 같은 임시 층
|
||||
|
||||
MINE = {"층": "personal", "주인": "7/42"}
|
||||
SK = "/api/m02/layers/personal/library/sangeun"
|
||||
|
||||
|
||||
def _sk(c: TestClient, name: str, **doc) -> str:
|
||||
body = {"문서": {"이름": name, "단위": "㎡", "더한줄": [], **doc}}
|
||||
res = c.post(SK, json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()["이름"]
|
||||
|
||||
|
||||
def _files(kind: str) -> list[str]:
|
||||
return sorted(p.stem for p in (layers.personal_dir(7, 42) / kind).glob("*.json"))
|
||||
|
||||
|
||||
def _sk_tree(c: TestClient) -> Tree:
|
||||
t = Tree(c)
|
||||
t.url = "/api/m02/layers/personal/sangeun-tree"
|
||||
t.now = c.get(t.url).json()
|
||||
return t
|
||||
|
||||
|
||||
def test_종류마다_따로_자리_파일(client: TestClient): # noqa: F811
|
||||
_sk(client, "갑")
|
||||
sk = _sk_tree(client)
|
||||
sk.do(op="add-folder", 경로=[], 이름="단가")
|
||||
sk.do(op="move", 키="SK-000001", 새경로=["단가"])
|
||||
assert Tree(client).now["폴더"] == [] # 일위대가 나무는 그대로
|
||||
root = layers.personal_dir(7, 42)
|
||||
assert (root / pre.file_of("sangeun")).is_file()
|
||||
assert not (root / pre.file_of("precombo")).exists()
|
||||
assert client.get("/api/m02/layers/personal/labor-tree").status_code == 422 # 모르는 종류
|
||||
|
||||
|
||||
def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨(client: TestClient): # noqa: F811
|
||||
a = _sk(client, "바탕")
|
||||
b = _sk(client, "위", 바탕={"참조": {**MINE, "키": a}})
|
||||
sk = _sk_tree(client)
|
||||
sk.do(op="add-folder", 경로=[], 이름="묶음")
|
||||
sk.do(op="add-folder", 경로=["묶음"], 이름="안")
|
||||
sk.do(op="move", 키=a, 새경로=["묶음", "안"])
|
||||
sk.do(op="move", 키=b, 새경로=["묶음"])
|
||||
got = sk.do(op="delete", 경로=["묶음"])
|
||||
assert sorted(got["알림"]["지운문서"]) == [a, b]
|
||||
assert _files("sangeun") == [] and sk.now["폴더"] == []
|
||||
|
||||
|
||||
def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811
|
||||
a = _sk(client, "바탕")
|
||||
body = {"문서": {"이름": "쓰는 일위대가", "담은로직": [{"참조": {**MINE, "키": a}}]}}
|
||||
assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200
|
||||
sk = _sk_tree(client)
|
||||
sk.do(op="add-folder", 경로=[], 이름="묶음")
|
||||
sk.do(op="move", 키=a, 새경로=["묶음"])
|
||||
detail = sk.do(409, op="delete", 경로=["묶음"])["detail"]
|
||||
assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail
|
||||
assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]]
|
||||
|
||||
|
||||
def test_여럿_옮기기_지우기(client: TestClient): # noqa: F811
|
||||
t = Tree(client) # 개인 층 일위대가 SC-000001~3
|
||||
t.do(op="add-folder", 경로=[], 이름="가")
|
||||
t.do(op="add-folder", 경로=[], 이름="나")
|
||||
t.do(
|
||||
op="move", 대상=[{"키": "SC-000001"}, {"키": "SC-000002"}, {"경로": ["나"]}], 새경로=["가"]
|
||||
)
|
||||
assert t.now["폴더"] == [["가"], ["가", "나"]]
|
||||
assert t.now["자리"] == {"SC-000001": ["가"], "SC-000002": ["가"]}
|
||||
got = t.do(op="delete", 대상=[{"경로": ["가"]}, {"키": "SC-000001"}, {"키": "SC-000003"}])
|
||||
assert sorted(got["알림"]["지운문서"]) == ["SC-000001", "SC-000002", "SC-000003"]
|
||||
assert _files("precombo") == [] and t.now["폴더"] == []
|
||||
|
||||
|
||||
def test_복사_붙여넣기_새_키_같은_이름_폴더는_1(client: TestClient): # noqa: F811
|
||||
t = Tree(client)
|
||||
t.do(op="add-folder", 경로=[], 이름="가")
|
||||
t.do(op="add-folder", 경로=["가"], 이름="속")
|
||||
t.do(op="move", 키="SC-000001", 새경로=["가", "속"])
|
||||
url = "/api/m02/layers/personal/precombo-tree/paste"
|
||||
picks = [{"경로": ["가"]}, {"경로": [], "키": "SC-000002"}]
|
||||
res = client.post(url, json={"from_layer": "personal", "대상": picks, "경로": ["가"]})
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert got["알림"]["만든문서"] == ["SC-000004", "SC-000005"]
|
||||
assert ["가", "가"] in got["폴더"] and ["가", "가", "속"] in got["폴더"]
|
||||
assert got["자리"]["SC-000004"] == ["가", "가", "속"] and got["자리"]["SC-000005"] == ["가"]
|
||||
again = client.post(url, json={"from_layer": "personal", "대상": [{"경로": ["가"]}]}).json()
|
||||
assert ["가(1)"] in again["폴더"] # 같은 이름 = 「(1)」
|
||||
doc = client.get("/api/m02/layers/personal/templates/precombo/SC-000005").json()["문서"]
|
||||
assert doc["이름"] == "나" and doc["본뜬"]["키"] == "SC-000002"
|
||||
bad = client.post(url, json={"from_layer": "personal", "대상": [{"키": "SC-000099"}]})
|
||||
assert bad.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["precombo", "sangeun"])
|
||||
def test_순수_지우기_알림(kind: str, tmp_path: Path):
|
||||
doc = {"폴더": [["가"], ["가", "나"]], "자리": {"X-1": ["가", "나"], "X-2": []}}
|
||||
out, note = pre.apply(doc, {"op": "delete", "경로": ["가"]}, {"X-1", "X-2"})
|
||||
assert out == {"폴더": [], "자리": {}} and note["지운문서"] == ["X-1"]
|
||||
assert pre.file_of(kind) == f"{kind}_폴더.json"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user