Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-11-05 18:49:35 +09:00
21 changed files with 1239 additions and 89 deletions
+101
View File
@@ -0,0 +1,101 @@
/* =============================================================================
* A00_Common/sheet_formula_cell.ts
* 줄 식 시트 식 칸 한 벌(PLAN 792 · 794-1) — M02 단가산출 시트 · M01 로직 시트가 같이 씀.
* 칠 때마다 검사(`check` · 150 ms 쉼 · 앞 요청 끊음) → 틀리면 빨간 칸 + 까닭 · 알림 한 줄
* 바꿀 때(Enter · 떠남) — 빈 칸 · 원래 식 그대로 = `put(null)` · 되면 `put(엔진 식)` · 틀리면 안 넣음
* 치는 중 글 = `drafts`(다시 그려도 안 잃음) · 부품 = 입력 · 글 · 서버 검사 길은 부르는 쪽 몫
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
const TEXT = {
Formula: ["식", "Formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** 검사 답 — 되나 · 까닭 · 알림(없으면 빈) · 엔진 식 */
export interface FormulaVerdict {
되나: boolean;
까닭: string[];
알림?: string[];
식: string | null;
}
export interface FormulaCellOptions {
/** 시트 칸 열쇠(초점 되찾기 · 치는 중 글) */
key: string;
/** 칸 그릇 이름표 값(`data-sheet-fx`) */
spot: string;
shown: string;
/** 원래 식 글 — 그대로 두면 `put(null)` */
origin: string;
drafts: Map<string, string>;
check: (text: string, signal: AbortSignal) => Promise<FormulaVerdict>;
put: (engine: string | null) => void;
className?: string;
}
export function createFormulaCell(o: FormulaCellOptions): HTMLElement {
const box = createInputField({
value: o.drafts.get(o.key) ?? o.shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = o.key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = o.key;
let timer = 0;
let ctl: AbortController | null = null;
const run = (text: string): Promise<FormulaVerdict | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
return o.check(text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = (got.알림 ?? []).join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
};
box.input.addEventListener("input", () => {
o.drafts.set(o.key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === o.origin) {
o.drafts.delete(o.key);
return o.put(null);
}
const got = await run(text);
if (!got?.되나 || !got.식) return void (note.textContent = t("Bad"));
o.drafts.delete(o.key);
o.put(got.식);
});
return el("div", {
className: o.className ?? "m02-sks__fx",
attrs: { "data-sheet-fx": o.spot },
children: [box.root, note],
});
}
+24
View File
@@ -12,6 +12,7 @@ from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M01_MasterData import M01_MasterData_Store_Flow as flow
@@ -347,6 +348,29 @@ def post_logic_draft(body: DraftLogic) -> dict:
return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner)
class SheetRow(BaseModel):
row: dict[str, Any]
class SheetCheck(BaseModel):
row: dict[str, Any]
갈래: Literal["중간", "호표", "덧줄"]
차례: int
식: str
@router.post("/logic/sheet")
def post_logic_sheet(body: SheetRow) -> dict:
"""줄 식 시트(794-1) — 줄마다 이름 꼴 식 글 · 식 칸 이름표(셈 · 쓰기 없음)."""
return _call(line_sheet.names, body.row)
@router.post("/logic/sheet/check")
def post_logic_sheet_check(body: SheetCheck) -> dict:
"""시트 식 칸 칠 때마다 — {되나, 까닭, 식(엔진), 글(이름 꼴)} · 저장 때와 같은 엔진 검사."""
return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식)
# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ──
@router.get("/logic/combos")
def get_logic_combos(key: str) -> dict:
+70
View File
@@ -0,0 +1,70 @@
"""M01 로직 줄 식 시트(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — 관리자 시트 식 칸 서버 몫.
이름 꼴 ↔ 엔진 식 · 상한 = M02 단가산출 식 칸과 한 벌(`M02_Sangeun_Formula` 불러 씀 · 고치지 않음) ·
검사 = 저장 때와 같은 엔진 검사(`check_logic`) 중 그 자리 까닭만 · 셈 · 쓰기 없음(③ 서버 단독).
자리 = (갈래 중간 | 호표 | 덧줄, 차례 0 부터) — 저장은 화면 캐시 줄 → 기존 M01 [저장](`/save`).
짝 = `logic/M01_Logic_Sheet_Model.ts`(줄 고치기 — 서버 짝 함수 없음 · 자리 꼴만 같음) ·
시험 `resources/tester/test_794_1_m01_sheet.py`.
"""
from __future__ import annotations
import copy
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
def _fx() -> Any:
from M02_MasterTemplete import M02_Sangeun_Formula as fx # M02 가 M01 을 부름 — 늦게
return fx
def _rows(row: dict, part: str) -> list:
got = row.get(part)
return got if isinstance(got, list) else []
def names(row: dict) -> dict[str, Any]:
"""줄마다 이름 꼴 식 글 {중간: [글], 호표: [글], 덧줄: [글], 이름표: [{글, 갈래}]}."""
fx, out = _fx(), {}
for part, field in FIELD.items():
out[part] = [fx.to_name(str(x.get(field) or ""), row) for x in _rows(row, part)]
try:
out["이름표"] = fx.name_table(row, {})
except (KeyError, TypeError, ValueError):
out["이름표"] = []
return out
def _where(row: dict, part: str, at: int) -> str:
"""`check_logic` 까닭 머리(그 자리)."""
key = f"{row.get('키')} {row.get('원문번호')}"
name = _rows(row, part)[at].get("이름", "")
return f"{key} {part} {name}" + (" 수량" if part == "호표" else "")
def check(row: dict, part: str, at: int, text: str) -> dict[str, Any]:
"""식 글 하나 → {되나, 까닭, 식(엔진), 글(이름 꼴)} — 그 자리에 넣은 사본을 엔진 검사."""
fx = _fx()
text = str(text or "").strip()
out: dict[str, Any] = {"되나": False, "까닭": [], "식": None, "글": None}
if part not in FIELD or not 0 <= at < len(_rows(row, part)):
return out | {"까닭": [f"없는 자리 「{part} {at}」"]}
if not text:
return out | {"까닭": ["식이 빔"]}
if bad := fx.limits(text):
return out | {"까닭": bad}
got, why = fx.to_engine(text, row)
if why:
return out | {"까닭": why}
out |= {"식": got, "글": fx.to_name(got, row)}
trial = copy.deepcopy(row)
trial[part][at][FIELD[part]] = got
head = _where(trial, part, at)
found = store.cm.mf.check_logic(store.loaded()[1], trial)[0]
mine = [w[len(head) :].lstrip(" ·") for w in found if w.startswith(head)]
return out | {"되나": not mine, "까닭": mine}
+11 -1
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 흐름도
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
@@ -54,6 +54,7 @@ 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 { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
@@ -128,6 +129,12 @@ export async function mountM01LogicLab(
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const sheet = createLabSheet({
admin,
onInput: (name, v) => opened && syncLeft(opened, { [name]: v }, true),
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
});
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유)
const panes = buildLogicSide(editor);
@@ -225,6 +232,7 @@ export async function mountM01LogicLab(
if (text.ok) current.lastText = text;
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
},
onAnswer: sheet.answer,
onLines: (lines) => {
current.lines = lines;
drawEditor();
@@ -260,7 +268,9 @@ export async function mountM01LogicLab(
calcHost: calc,
flowHost: flowOf(current.row) ? current.flowHost : undefined,
admin,
sheet: sheet.root,
});
sheet.draw(current.row as LogicRow, current.original, current.values);
flowNow?.redrawLeft();
};
@@ -39,6 +39,8 @@ export interface DetailContext extends HoContext {
onTrial?: () => void;
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */
sheet?: HTMLElement;
}
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
@@ -166,7 +168,11 @@ function ownEditor(ctx: DetailContext): HTMLElement {
return el("details", {
className: "m01lab__edit",
attrs: { open: "" },
children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost],
children: [
el("summary", { text: tl("Edit_Title") }),
el("p", { className: "ui-text ui-text--muted", text: tl("Edit_SheetHint") }),
ctx.ownHost,
],
});
}
@@ -230,6 +236,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
host.replaceChildren(
...infoBox(ctx),
...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []),
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
block(
section(tl("View_Est"), [
@@ -0,0 +1,324 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Sheet.ts
* M01 단가산출 로직 상세 「시트」 보기(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴.
* 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M01_Logic_Sheet_Model`) · 상태줄 = 지금 · 고치기 전 + [계산]
* 식 글 = 서버 이름 꼴(`POST /api/m01/logic/sheet` · 줄이 바뀔 때만 · 옛 요청 끊음)
* 고침 = 시스템 관리자만 — [식 고침] → 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/logic/sheet/check`)
* → 되면 엔진 식을 줄 사본에(`onEdit`) → 상세 캐시(drafts) → 기존 M01 [저장](서버 검사 · 개정 확인)
* 옵션 칸 = 시험 값(저장 안 함 · 고치면 시험 계산) · 자동저장 없음 · 부품 = calc_row · 입력 · 고르기 · 단추 · 배지
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } 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 { createToolbar } from "@ui/ui_template_toolbar";
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { CalcAnswer, LogicRow } from "./M01_MasterData_UI_Logic_Api";
import {
formulaAt,
labItems,
setLabFormula,
spotKey,
type LabItem,
type LabKind,
type LabNames,
type LabPart,
} from "./logic/M01_Logic_Sheet_Model";
import "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css";
const TEXT = {
Sheet: ["시트", "Sheet"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before edit"],
Run: ["계산", "Calculate"],
근거: ["근거", "Basis"],
옵션: ["옵션 · 시험 값", "Options · trial values"],
계산: ["변수 · 계산", "Variables"],
자원: ["자원", "Resources"],
율: ["율 · 덧줄", "Rates"],
단계: ["단계", "Stages"],
소계: ["소계 · 합계", "Subtotals"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Back: ["원래로", "Revert"],
Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"],
Flow: ["흐름 로직 — 시트로 못 봄", "Flow logic — no sheet"],
Address: ["번지", "Address"],
} 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 SECTION: Record<LabKind, keyof typeof TEXT> = {
근거: "근거",
옵션: "옵션",
계산: "계산",
자원: "자원",
율: "율",
단계: "단계",
소계: "소계",
합계: "소계",
};
const ORDER: (keyof typeof TEXT)[] = ["근거", "옵션", "계산", "자원", "율", "단계", "소계"];
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
export interface LabSheetDraw {
row: LogicRow;
/** 저장본 줄(새 로직 = null) */
original: LogicRow | null;
values: Record<string, string>;
answer: CalcAnswer | null;
}
export interface LabSheetOptions {
/** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */
admin: boolean;
onInput: (name: string, value: string) => void;
/** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */
edited: () => void;
/** 시험 계산 [계산] 누름 */
run: () => void;
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : r.statusText);
return data;
}
export function createLabSheet(opt: LabSheetOptions) {
const status = el("div", { className: "m02-sks__status", attrs: { "data-lab-status": "" } });
const body = el("div", { className: "m02-sks__body" });
const run = createButton({ label: t("Run"), variant: "ghost", size: 28, onClick: opt.run });
const root = el("div", {
className: "m02-sks",
attrs: { "data-lab-sheet": "" },
children: [createToolbar({ items: [run, "divider", status], label: t("Sheet") }), body],
});
let last: LabSheetDraw | null = null;
let names: LabNames | null = null;
let asked = "";
let ctl: AbortController | null = null;
let before: number | null = null;
let beforeAt = ""; // 고치기 전 값을 잰 시험 값(옵션) — 옵션이 바뀌면 고친 줄의 「고치기 전」 은 모름
const open = new Set<string>();
const drafts = new Map<string, string>();
/** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */
const askNames = (row: LogicRow): void => {
const text = JSON.stringify(row);
if (text === asked) return;
asked = text;
ctl?.abort();
const mine = (ctl = new AbortController());
post<LabNames>("/logic/sheet", { row }, mine.signal).then(
(got) => {
if (mine !== ctl) return;
names = got;
if (last) paint(last);
},
() => undefined, // 못 받으면 엔진 식 그대로 보임
);
};
const check =
(row: LogicRow, part: LabPart, at: number) =>
(text: string, signal: AbortSignal): Promise<FormulaVerdict> =>
post<FormulaVerdict>("/logic/sheet/check", { row, 갈래: part, 차례: at, 식: text }, signal);
const money = (it: LabItem): string => {
const v = it.값;
if (typeof v !== "number") return it.kind === "옵션" ? (it.input?.단위 ?? "") : String(v ?? "");
if (it.kind === "자원" || it.kind === "율") return formatLineMoney(v);
return it.kind === "소계" || it.kind === "합계" ? formatMoney(v) : NUM.format(v);
};
function optionCell(d: LabSheetDraw, it: LabItem): HTMLElement {
const spec = it.input!;
const value = d.values[spec.이름] ?? "";
if (spec.고르기?.length) {
const sel = createSelectField({
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
value,
compact: true,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => opt.onInput(spec.이름, v),
});
sel.select.dataset.sheetCell = `opt|${spec.이름}`;
return sel.root;
}
const box = createInputField({ value, compact: true, size: 24 });
box.input.setAttribute("aria-label", spec.이름);
box.input.dataset.sheetCell = `opt|${spec.이름}`;
box.input.addEventListener("change", () => opt.onInput(spec.이름, box.input.value.trim()));
return box.root;
}
function tail(d: LabSheetDraw, it: LabItem): HTMLElement {
const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, "neutral")] : [];
if (it.번지) kids.push(createBadge(`${t("Address")} ${it.번지}`, "info"));
if (opt.admin && it.part && it.at !== undefined) {
const key = spotKey(it.part, it.at);
const on = open.has(key);
const toggle = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) open.delete(key);
else open.add(key);
drafts.delete(key);
paint(d);
},
});
toggle.dataset.sheetFxToggle = key;
kids.push(toggle);
if (it.changed && d.original) {
const { part, at } = it;
const back = createButton({
label: t("Back"),
variant: "ghost",
size: 22,
onClick: () => edit(d, part, at, null),
});
back.dataset.sheetRevert = key;
kids.push(back);
}
}
return el("span", { children: kids });
}
function rowOf(d: LabSheetDraw, it: LabItem): HTMLElement {
const key = it.part && it.at !== undefined ? spotKey(it.part, it.at) : "";
const editing = opt.admin && key && open.has(key) && it.part && it.at !== undefined;
const middle =
it.kind === "옵션"
? optionCell(d, it)
: el("div", {
className: "m02-sks__mid",
children: [
createCalcFormula(it.식 || (["근거", "소계", "합계"].includes(it.kind) ? "" : "—")),
...(editing
? [
createFormulaCell({
key,
spot: key,
shown: it.식,
origin: it.식,
drafts,
check: check(d.row, it.part!, it.at!),
put: (engine) => {
if (engine === null) return;
open.delete(key);
edit(d, it.part!, it.at!, engine);
},
}),
]
: []),
],
});
const value = el("span", {
className: `ui-calc-row__out${it.kind === "소계" || it.kind === "합계" ? " is-key" : ""}`,
text: money(it),
});
const row = createCalcSheetRow(it.이름, middle, value, tail(d, it));
row.dataset.sheetKind = it.kind;
if (key) row.dataset.sheetSpot = key;
if (it.changed) row.classList.add("is-changed");
return row;
}
function drawStatus(d: LabSheetDraw): void {
const ok = d.answer?.ok ? d.answer : null;
const now = ok?.sums?.계 ?? null;
const at = JSON.stringify(d.values);
const same = !d.original || JSON.stringify(d.row) === JSON.stringify(d.original);
if (same && now !== null)
[before, beforeAt] = [now, at]; // 저장본 그대로 = 지금이 고치기 전
else if (!same && at !== beforeAt) before = null;
const part = (k: string, label: string, v: number | null): HTMLElement =>
el("span", {
className: "m02-sks__amount",
attrs: { "data-lab-amount": k },
children: [createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14)],
});
const diff = now !== null && before !== null && now !== before ? now - before : 0;
status.replaceChildren(
part("now", t("Now"), now),
part("before", t("Before"), before),
...(diff
? [createBadge(`${diff > 0 ? "+" : "−"}${NUM.format(Math.abs(diff))}`, "warning")]
: []),
);
}
function paint(d: LabSheetDraw): void {
last = d;
drawStatus(d);
if (d.row.흐름) return void body.replaceChildren(createText(t("Flow"), "muted"));
const items = labItems({ ...d, names });
const active = document.activeElement as HTMLElement | null;
const keep = active && body.contains(active) ? active.dataset.sheetCell : "";
const typed = active instanceof HTMLInputElement && keep ? active.value : null; // 치던 글 지킴
const out: HTMLElement[] = [];
for (const sec of ORDER) {
const mine = items.filter((it) => SECTION[it.kind] === sec);
if (!mine.length) continue;
out.push(
el("div", {
className: "m02-sks__head",
attrs: { "data-sheet-section": sec },
children: [createText(t(sec), "muted", 14)],
}),
...mine.map((it) => rowOf(d, it)),
);
}
if (!d.answer) out.push(createText(t("Wait"), "muted"));
body.replaceChildren(...out);
const back = keep
? body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)
: null;
if (back instanceof HTMLInputElement && typed !== null) back.value = typed;
back?.focus();
}
/** 식 칸 고침(엔진 식) · null = 저장본 식 → 상세 캐시 · 시험 계산 다시 */
function edit(d: LabSheetDraw, part: LabPart, at: number, engine: string | null): void {
const text = engine ?? (d.original ? formulaAt(d.original, part, at) : null);
if (text === null || !setLabFormula(d.row, part, at, text)) return;
opt.edited();
opt.run();
}
let answer: CalcAnswer | null = null;
let answerOf: Record<string, string> | null = null; // 답을 받은 로직(시험 값 그릇 = 연 로직마다 하나)
let base: { src: string | null; row: LogicRow | null } = { src: null, row: null };
return {
root,
/** 시험 계산 답 — 상세 `buildCalc` onAnswer */
answer: (a: CalcAnswer) => void (answer = a),
/** 상세가 그릴 때마다 — 줄 · 저장본 글(`original`) · 시험 값 그릇 */
draw: (row: LogicRow, original: string | null, values: Record<string, string>) => {
if (answerOf !== values) [answer, answerOf] = [null, values]; // 다른 로직 · 저장 뒤
if (base.src !== original) {
base = { src: original, row: original ? (JSON.parse(original) as LogicRow) : null };
open.clear(); // 연 식 칸 · 치던 글 버림
drafts.clear();
}
askNames(row);
paint({ row, original: base.row, values, answer });
},
};
}
@@ -199,6 +199,7 @@
}
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
.m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]),
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
@@ -33,6 +33,8 @@ const TEXT = {
],
Calc_Title: ["시험 계산", "Trial calculation"],
View_Est: ["인력·재료·경비", "Labor·material·expense"],
View_Sheet: ["시트", "Sheet"],
Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"],
View_Label: ["보기", "View"],
Now_Inputs: ["지금 입력", "Current inputs"],
Now_None: ["없음", "none"],
@@ -1,10 +1,10 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
* 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -12,7 +12,7 @@ import { createSegment } from "@ui/ui_template_segment";
import { createToolbar } from "@ui/ui_template_toolbar";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export type LabView = "ho" | "est" | "calc" | "formula";
export type LabView = "sheet" | "ho" | "est" | "calc" | "formula";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
@@ -22,15 +22,16 @@ export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
host.dataset.view = "ho";
host.dataset.view = "sheet";
const segment = createSegment({
options: [
{ value: "sheet", label: tl("View_Sheet") },
{ value: "ho", label: tl("Ho_Title") },
{ value: "est", label: tl("View_Est") },
{ value: "calc", label: tl("Calc_Title") },
{ value: "formula", label: tl("Formula_Title") },
],
value: "ho",
value: "sheet",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => (host.dataset.view = v),
@@ -70,6 +70,8 @@ export interface CalcContext {
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines?: (lines: CalcLine[] | null) => void;
/** 794-1 셈 답 통째(중간 값 · 소계) — M01 줄 식 시트 값 */
onAnswer?: (answer: CalcAnswer) => void;
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
onText?: (answer: TextAnswer) => void;
/** (M02 750-2) 있으면 [계산] = 이 셈(SK 셈 답 하나) · `/calc` · `/text` · 미리보기 안 부름 */
@@ -215,6 +217,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
};
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onAnswer?.(answer);
ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null);
if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines);
out.replaceChildren(...answerView(answer, draft, money));
+10 -5
View File
@@ -18,6 +18,7 @@ import type {
} from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { createBadge } from "@ui/ui_template_badge";
import { createCalcFormula } from "@ui/ui_template_calc_row";
import { createText } from "@ui/ui_template_text";
import { createIconButton } from "@ui/ui_template_button";
import { formatLineMoney, formatNumber, formatPrice } from "../common_util/common_util_money";
@@ -107,6 +108,13 @@ function grid(headers: string[], rows: HTMLElement[][], className: string): HTML
return el("div", { className: "m01-logic__scroll", children: [table] });
}
/** 식 칸 — 읽기만(고침은 「시트」 보기 식 칸 · 칠 때마다 검사 · 794-1) */
const sheetOnly = (formula: string): HTMLElement => {
const box = createCalcFormula(formula || "—");
box.dataset.sheetOnly = "";
return box;
};
export function qtyKind(qty: string): string {
const text = qty.trim();
if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber");
@@ -308,10 +316,7 @@ function hoSection(
});
const brief = ctx.prices[item.요소];
const spec = item.규격 ?? brief?.규격 ?? "";
const qty = textBox(item.수량, (v) => {
set(() => (item.수량 = v));
tag.textContent = qtyKind(v);
});
const qty = sheetOnly(item.수량); // 794-1 식 = 「시트」 보기 식 칸(날 입력칸 갈음)
const tag = createBadge(qtyKind(item.수량), "accent");
const table = createButton({
label: tx("Ho_PickTable"),
@@ -387,7 +392,7 @@ function formulaRows(
return list.map((item, i) => {
const cells: HTMLElement[] = [
textBox(item.이름, (v) => set(() => (item.이름 = v))),
textBox(item.식, (v) => set(() => (item.식 = v)), "m01-logic__formula"),
sheetOnly(item.식),
];
if (withCost) {
cells.push(
@@ -0,0 +1,158 @@
/* =============================================================================
* M01_MasterData/logic/M01_Logic_Sheet_Model.ts
* M01 로직 줄 식 시트 꼴(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴
* 「이름 | 식 | 값 | 비목」 · 줄 갈래 = 근거(출처 · 비고 · 끝수) · 옵션(입력) · 계산(중간) · 자원(호표) ·
* 율(덧줄) · 단계 · 소계 · 합계 · 식 글 = 서버 이름 꼴(`/logic/sheet` · 없으면 엔진 식 「*」 → 「×」)
* 값 = 시험 계산 답(줄 금액 = 호표 → 덧줄 차례 · 중간 값 · 비목 소계)
* 고침 = 줄 사본 식 칸 하나(`setLabFormula`) — 저장은 화면이 기존 M01 [저장] 길로
* 짝 = 서버 `M01_MasterData_Sheet.py`(자리 = 갈래 · 차례 꼴만 같음) · 화면 · fetch · sessionStorage 없음
* ========================================================================== */
import type {
CalcAnswer,
CalcLine,
LogicInput,
LogicRow,
NamedFormula,
} from "../M01_MasterData_UI_Logic_Api";
export type LabPart = "중간" | "호표" | "덧줄";
export type LabKind = "근거" | "옵션" | "계산" | "자원" | "율" | "단계" | "소계" | "합계";
/** 줄마다 식 칸 이름(갈래 → 칸) — 서버 `FIELD` 와 같음 */
export const FIELD: Record<LabPart, "식" | "수량"> = { 중간: "식", 호표: "수량", 덧줄: "식" };
export interface LabNames {
중간: string[];
호표: string[];
덧줄: string[];
이름표?: { 글: string; 갈래: string }[];
}
export interface LabItem {
kind: LabKind;
이름: string;
/** 이름 꼴 식 글 · 빈 글 = 식 없음 */
식: string;
값: number | string | null;
/** 비목 줄임(노 · 재 · 경) */
비목: string;
/** 식 칸 자리(중간 · 호표 · 덧줄) */
part?: LabPart;
at?: number;
input?: LogicInput;
/** 금액번지(791-1) */
번지?: string;
/** 저장본과 식이 다름 */
changed: boolean;
}
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
const short = (v: unknown): string => (typeof v === "string" ? (SHORT[v] ?? v) : "");
/** 엔진 식 → 보기 글(이름 꼴이 아직 없을 때) */
export const plain = (s: string): string => s.replace(/\s*\*\s*/g, " × ");
export const spotKey = (part: LabPart, at: number): string => `${part}#${at}`;
/** 줄 식 칸 값(엔진 식) */
export function formulaAt(row: LogicRow, part: LabPart, at: number): string {
const list = (row[part] ?? []) as unknown as Record<string, unknown>[];
return String(list[at]?.[FIELD[part]] ?? "");
}
/** 줄 식 칸 하나를 엔진 식으로 바꿈(줄 객체 그대로 고침) · 없는 자리 = false */
export function setLabFormula(row: LogicRow, part: LabPart, at: number, engine: string): boolean {
const list = row[part] as unknown as Record<string, unknown>[] | undefined;
if (!list || at < 0 || at >= list.length) return false;
list[at][FIELD[part]] = engine;
return true;
}
/** 끝수 글 — 글자면 그대로 · 묶음이면 「대상 방법 자리」 */
export function cutText(rule: unknown): string {
if (rule == null || rule === "") return "";
if (typeof rule === "string") return rule;
const r = rule as Record<string, unknown>;
return [r.대상, r.방법, r.자리 != null ? `${String(r.자리)} 자리` : ""]
.filter((x) => x !== undefined && x !== "")
.join(" · ");
}
const valueOf = (v: unknown): number | string | null =>
typeof v === "number" || typeof v === "string" ? v : null;
/** 시트 줄 — 근거 · 옵션 · 계산 · 자원 · 율 · 단계 · 소계 · 합계 */
export function labItems(o: {
row: LogicRow;
/** 저장본 줄(새 로직 = null) — 식이 다르면 고친 줄 */
original: LogicRow | null;
values: Record<string, string>;
names: LabNames | null;
answer: CalcAnswer | null;
}): LabItem[] {
const { row, original } = o;
const out: LabItem[] = [];
const item = (x: Partial<LabItem> & { kind: LabKind; 이름: string }): void =>
void out.push({ 식: "", 값: null, 비목: "", changed: false, ...x });
if (row.출처) item({ kind: "근거", 이름: row.원문번호 || "", 식: row.출처 });
for (const line of (row.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
item({ kind: "근거", 이름: "", 식: line.trim() });
for (const input of row.입력 ?? [])
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
const ok = o.answer?.ok ? o.answer : null;
const mid = ok?.middle ?? {};
const shown = (part: LabPart, at: number): string =>
o.names?.[part]?.[at] ?? plain(formulaAt(row, part, at));
const changed = (part: LabPart, at: number): boolean =>
!!original && formulaAt(original, part, at) !== formulaAt(row, part, at);
const spot = (part: LabPart, at: number) => ({
part,
at,
식: shown(part, at),
changed: changed(part, at),
});
(row.중간 ?? []).forEach((f: NamedFormula, at) =>
item({ kind: "계산", 이름: f.이름, 값: valueOf(mid[f.이름]), ...spot("중간", at) }),
);
const ho = row.호표 ?? [];
const extra = row.덧줄 ?? [];
const lines: (CalcLine | undefined)[] =
ok?.lines && ok.lines.length === ho.length + extra.length ? ok.lines : [];
ho.forEach((h, at) => {
const b = (h as { 번지?: string }).번지;
item({
kind: "자원",
이름: h.단위 ? `${h.이름 ?? ""} (${h.단위})` : (h.이름 ?? ""),
값: lines[at]?.금액 ?? null,
비목: short(h.비목),
...(b ? { 번지: b } : {}),
...spot("호표", at),
});
});
extra.forEach((x, at) => {
const b = (x as { 번지?: string }).번지;
item({
kind: "율",
이름: x.이름,
값: lines[ho.length + at]?.금액 ?? null,
비목: short(x.비목),
...(b ? { 번지: b } : {}),
...spot("덧줄", at),
});
});
const steps = (row.단계 ?? []) as { 이름?: string; 줄?: unknown }[];
for (const s of Array.isArray(steps) ? steps : [])
item({
kind: "단계",
이름: String(s.이름 ?? ""),
식: Array.isArray(s.줄) ? s.줄.map(String).join(" + ") : "",
});
const cut = cutText(row.끝수);
if (cut) item({ kind: "근거", 이름: "끝수", 식: cut });
const sums = ok?.sums ?? null;
for (const k of Object.keys(SHORT))
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
if (sums && "계" in sums) item({ kind: "합계", 이름: "계", 값: sums.계 });
return out;
}
@@ -14,7 +14,7 @@ import { createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createFormulaCell } from "../A00_Common/sheet_formula_cell";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
@@ -28,16 +28,12 @@ import {
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Formula: ["식", "Formula"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Var: ["변수", "Variable"],
VarName: ["변수 이름", "Variable name"],
AddVar: ["+ 변수 줄", "+ Variable"],
DropVar: ["변수 빼기", "Remove variable"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
@@ -97,7 +93,7 @@ export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
return out as T;
}
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` · 몸 = 공용 `sheet_formula_cell` */
function formulaBox(
c: FormulaCtx,
spot: string,
@@ -106,63 +102,14 @@ function formulaBox(
origin: string,
put: (engine: string | null) => void,
): HTMLElement {
const box = createInputField({
value: c.drafts.get(key) ?? shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = key;
let timer = 0;
let ctl: AbortController | null = null;
let last: Promise<CheckAnswer | null> | null = null;
const run = (text: string): Promise<CheckAnswer | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
last = checkFormula(c, spot, text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = got.알림.join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
return last;
};
box.input.addEventListener("input", () => {
c.drafts.set(key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === origin) {
c.drafts.delete(key);
return put(null);
}
const got = await run(text);
if (!got?.되나) return void (note.textContent = t("Bad"));
c.drafts.delete(key);
put(got.식);
});
return el("div", {
className: "m02-sks__fx",
attrs: { "data-sheet-fx": spot },
children: [box.root, note],
return createFormulaCell({
key,
spot,
shown,
origin,
drafts: c.drafts,
check: (text, signal) => checkFormula(c, spot, text, signal),
put,
});
}
+20 -10
View File
@@ -41,6 +41,7 @@ from M02_MasterTemplete import M02_Sangeun_Views as views
COSTS = labor.COSTS
Ref = dict[str, Any]
_SHARED = ("_market", "_by_class", "_found") # 마스터에만 따르는 자재 색인 · 찾기 답
class _Over:
@@ -55,6 +56,14 @@ class _Over:
def __getattr__(self, name: str) -> Any:
return getattr(self._whole, name)
def __setattr__(self, name: str, value: Any) -> None:
"""자재 색인(`master_material`)은 속 M01 색인에 둠 — 셈마다 다시 안 지음(792-2) ·
M01 파일 자국이 바뀌면 새 색인 = 새로 지음 · 그 밖은 이 덮개에만."""
if name in _SHARED:
setattr(self._whole, name, value)
else:
super().__setattr__(name, value)
def _fail(e: sd.DocError) -> store.StoreError:
return store.StoreError(e.status, str(e))
@@ -570,16 +579,17 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str
`seen` = 보기 덧칸(`글` · `중간`) 지음."""
from M01_MasterData import M01_MasterData_Store as m01
whole = m01.loaded()[1]
try:
clean = sd.clean(doc, None, whole)
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]})
line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1)
got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen)
except sd.DocError as e:
raise _fail(e) from e
return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2)
with m01.cache.steady(): # 한 셈 동안 M01 파일 자국 한 번(792-2)
whole = m01.loaded()[1]
try:
clean = sd.clean(doc, None, whole)
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]})
line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1)
got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen)
except sd.DocError as e:
raise _fail(e) from e
return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2)
def from_line(
@@ -0,0 +1,114 @@
# 794-2a M01 보강 · 품셈 판 차이 — 조사 · 제안
- 근거 = 793-3a 근거 제안 엔진(`M02_San_Basis`)을 실무 여덟 파일 459 블록에 다시 돌린 판정 — 확정 125 · 근접 135 · 사람 114 · 없음 85
- 짝 자료 = `794_2a_M01보강_판차이.json`(갈래 · 블록 목록 · 원문 자리 · 근접 43 줄 판정 · 어림 수) · 만든 스크립트 `dump.py` · `build.py` · 갈래 묶음 `groups.json`(같은 폴더)
- 원문 자리 앞머리 `resources/knowledge/original/원가계산/` 줄임 · 「건설」 = 2026 건설공사 표준품셈 · 「산림」 = 산림사업 표준품셈(2026-01-01 시행)
- 읽기만 — M01 마스터 · 코드 · 지식DB 고침 없음
## 1. 한눈에
| 묶음 | 갈래(번) | 블록 |
| --- | --- | --- |
| M01 보강 대상(없음 · 꼴 모자람) | 1 · 2 · 3 · 6 · 9 | 37 |
| 현행 판 원문에 없음(옛 판 · 실무만) | 5 · 10 · 11 · 15 | 4 |
| M01 있음 — 엔진이 못 이음 · 판 차이 | 4 · 7 · 8 · 13 | 33 |
| 조합 블록 · 빈 블록(보강 아님) | 12 · 14 · 16 | 11 |
| 계 | | 85 |
- 「없음」 85 가운데 M01 에 정말 없는 품셈 항목은 다섯 갈래 37 블록 — 나머지 48 은 M01 에 있으나 이름 · 옛 번호 · 조합 때문에 엔진이 못 이은 것
- 판 차이 — 근접 후보 중 품 다름 43 줄 판정: 확정 4 · 의심 7 · 옛 임도품셈 3 · 못 가름 6 · 판 아님 19 · 기초 잘못 짚음 4
- 철근 조립 3.4 ↔ 3.5(×0.9714)는 봉화#98 하나 — 판 차이 확정(4 장)
- M01 값 = 현행 판(건설 2026 · 산림 2026-01-01) — 예정가격작성기준 제6조② 「표준품셈을 이용하여 원가계산을 하는 경우에는 가장 최근의 표준품셈을 이용」(`행정규칙/(계약예규) 예정가격작성기준/현행_20260825.md:51`) → M01 값은 그대로 · 옛 판은 표지로만
## 2. M01 에 없는 항목 — 보강 후보
| 번 | 갈래 | 판정 | 블록 · 파일 | 원문 자리 | 원문 표 · 식 | M01 에 들 꼴 | 우선 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 자재 운반 — 구역화물 운임 + 하차비(철근 · 시멘트 · 철강재) | M01 없음 · 원문 있음 | 10 · 북부청임도 · 북부청사방 · 거창 · 봉화 · 동부청영월 · 울진대흥 | 산림 10-1 `산림_표준품셈/본문/제10장_자재․장비_운반/10-01_시멘트운반.md:16` · 10-2 `10-02_철근운반.md:16` · 건설 공통 1-2-7 `건설공사_표준품셈/본문/01_공통부문/제01장_적용기준/1-02_설계_및_수량.md:445` | 표 있음(운반비 = 10.5ton 구역화물 · 하차비 1회) · 품 수 없음 — 하차 품(철근 11분/ton · 시멘트 적상 0.18 · 적하 0.13 인)은 옛 건설품셈 「품종별 적상·적하 기준」 값 · 현행 원문에 없음 | `로직_산림품셈_10장_자재․장비_운반.json` GF 두 줄 — 입력 = 구역화물 톤급 · 거리(운임 = 자재단가 구역화물 줄) · 하차 = 입력 칸 | 상 |
| 3 | 덤프 운반 조합 — 적재기계(굴착기 0.2~1.0 · 로더) · 덤프 톤급(8 · 15 · 24) | M01 일부 있음 · 고르기 꼴 없음 | 14 · 여덟 파일 중 일곱 | 산림 10-12-1 `제10장_자재․장비_운반/10-12_덤프운반.md:18` · 10-3 `10-03_골재운반.md:16` · 건설 공통 8-2-8 `제08장_건설기계/8-02-08_덤프트럭.md:16` · 8-2-3 `8-02-03_굴착기.md:16` · 8-2-5 `8-02-05_로더.md:16` | 식 있음(8-2-8 Cm = t1+t2+t3+t4 · 8-2-3 · 8-2-5 Q 식) · 산림 10-12-1 = 굴착기 0.7 + 덤프 15ton 고정 | GF000284 입력에 적재기계 · 덤프 톤급 고르기 더함(계수 = 8-2-3 · 8-2-5 · 8-2-8 표) 또는 자체 GX 조합 한 줄 | 상 |
| 9 | 야면석 · 깬잡석 · 고임돌 · 파쇄석 기계 채집 — 굴착기 1회 N개 | M01 없음 · 원문 품 없음 | 8 · 봉화 · 울진대흥 · 울진소광 | 산림 13-2-4 `제13장_구조물/13-02_채집_및_세척.md:76` · 13-2-3 같은 파일 `:43` · 건설 공통 8-2-3 | 13-2-4 주② 「기계화 시공이 가능한 지역은 상기 품을 적용하지 않는다」 · 13-2-3 주⑪ 「건설 8-2-3 굴착기(2025) 참조」 — 기계 채집 품 없음 · 실무 = 8-2-3 Q 식 + 1회 개수 | 자체 GX 한 줄(`로직_자체.json`) — 입력 = 돌 뒷길이 · 1회 개수 · 굴착기 규격 · 소운반 덤프 → GC001147 부름 | 상 |
| 6 | 잔토처리(인력) 단독 — ㎥당 보통인부 0.2인 | M01 옵션만 있음 · 단독 로직 없음 | 3 · 북부청임도 · 북부청사방 · 동부청영월 | 건설 공통 3-2-1 `제03장_토공사/3-02_굴착.md:18`('08 · '20 · '25 보완) | 표 비고 한 줄 | `로직_건설품셈_03장_토공사.json` GC 한 줄(호표 보통인부 = QC000172 「잔토처리 보통인부」 · GC000083 옵션과 같은 표) | 중 |
| 2 | 소형 기계 운반 — 구역화물 대당(믹서 · 소형 중기) | 원문 없음 · 실무 근거 | 2 · 봉화 · 울진대흥 | 산림 10-4 `10-04_중기운반.md:16`(트레일러만) | 구역화물 대당 운반 원문 없음 · 실무 = 운임 ÷ 1.1 | 1번과 한 꼴(자체 GX) | 중 |
| 5 | 노면정리(옛 임도품셈 7-3) | 현행 판 원문 없음 | 1 · 봉화 | 산림 9-19 `제09장_토공/9-19_면고르기.md:16`(가까운 절) | 현행 산림품셈에 노면정리 절 없음 · 블록은 건설 8-2-3 식 | 보강 보류 — 4번 길로 덮임 | 하 |
| 10 | 글자새김(2011 건설품셈 7-5) | 현행 판 원문 없음 · 옛 판 표 | 1 · 봉화 | 없음 | 블록 글에 2011 판 표(글자 6~30cm × 한글 · 한문 × 둥글게 · 보통) | 자체 GX + 계수 표(출처 「건설품셈 2011 7-5」) | 하 |
| 11 | 컨테이너 운반 — 트럭탑재형 크레인 10ton(옛 9-38-2) | 현행 판 원문 없음 | 1 · 동부청영월 | 산림 10-4 · 건설 공통 8-3(손료만) | 현행 8-2 에 트럭탑재형 크레인 작업량 절 없음 | 자체 GX(왕복 시간 식 · 크레인 시간당 경비) | 하 |
| 15 | 떼 하차비 — 인력 적하(2인 × 16분/450분) | 현행 판 원문 없음 | 1 · 울진소광 | 건설 공통 1-2-7 | 현행 1-2-7 = 인력운반 기본공식만 | 1번 하차비 칸과 한 꼴 | 하 |
- 블록 번호 전부는 json `없음갈래[].블록`
- 우선 = 걸린 블록 수 · 임도 · 사방에서 자주 씀(운반 · 채집 · 덤프는 여덟 파일 거의 모두)
## 3. M01 에 있는데 「없음」 — 보강 아님
| 번 | 갈래 | 블록 | 이을 M01 | 몫 |
| --- | --- | --- | --- | --- |
| 4 | 굴착기 단독 작업(잔토정리 · 사토처리 · 관리도로개설 · 토사절취 · 노면 고르기 …) | 13 | GC001147 · GC001148(건설 공통 8-2-3) | 엔진 — 제목에 품셈 이름 없는 Q 식 블록도 기계 작업량 길로 |
| 7 | 구조물터파기(인력) — 옛 3-1-3 ㎥당 0.26인 · 2025 3-2-1 세 토질 평균 | 3 | GC000083 | 판 표지 + 여러 토질 평균 = 현장 값 |
| 8 | 암 터파기(대형브레이커 + 굴착기) | 6 | GF000263 · GF000265 · GC000090 | 판 표지(옛 임도품셈 5-1-2 Q 3.2~3.8 평균 3.5) + 여러 암 평균 |
| 13 | 이름 · 옛 번호 · 단위로 못 이음 | 11 | 휠라 = GC000227 · 잡관목제거 = GC000129 · 줄떼 = GF000033 · 평떼 = GC000143 · 단끊기 = GF000298 · 씨뿌리기(줄) = GF000029(원/m ↔ ㎡) · 기계비빔 = GF000156 · 뒷채움 = GF000215 · GF000218 · 보조기층 = GC000281~283 · 구조물터파기 토사 = GF000101 · GF000103 · 콘크리트 소운반 = GC001140 | 엔진 같은 말 묶음 · 옛 번호 대조표 · 단위 환산 · 콘크리트 소운반만 GC001140 입력(V · t) 넓힘 |
| 12 | 교통통제(신호수 · 공사안내판 · PE드럼) | 1 | GC000052(건설 공통 2-9-4 · 블록은 옛 2-8-20) | 조합 · 안내판 설치 품은 현행 원문 없음(2-9-2 주③ 별도 계상) |
| 14 | 조합 블록(연결철근 30% · 발생석 50% · 배수관 유출 · 유입구 · 차수벽) | 5 | 793-2 SK 조합 | 보강 아님 |
| 16 | 빈 블록(무대 0 원 · 글 없음) | 5 | — | 보강 아님 |
## 4. 판 차이
### 4-1. 블록별
| 블록(파일 연도) | M01 기초 · 절 | 원문 개정 | 엑셀 ↔ M01 | 판정 |
| --- | --- | --- | --- | --- |
| 봉화#98 (2024) | GC000207 철근 현장조립 · 건설 공통 6-2-3 `제06장_철근콘크리트공사/6-02_철근.md:59` | '08 · '14 · '22 · '24 | 철근공 6/3.5 = 1.714 ↔ 6/3.4 = 1.765 (×0.9714) | 판 차이 확정 — 같은 해 동부청영월#60(「24년 건설표준품셈 6-2-3」)은 3.4 로 확정 → 봉화 = '24 보완 전 판 |
| 거창#45 (2025) | GC000211 합판거푸집 · 6-3-1 | 2017 뒤 '18 · '22 · '24 · '26 | 보통인부 0.02 ↔ 0.04 | 판 차이 확정 — 블록 글 「2017년 품셈 6-3-1」 |
| 동부청영월#644 (2024) | GC000263 찰붙임 · 7-2-2 `제07장_돌공사/7-02_돌붙임.md:35` | '12 · '19 · '26 | ㎡당 석공 0.05 · 보통인부 0.02 · 굴착기 0.12h ↔ 3/30 · 1/30 · 8/30 | 판 차이 확정 — '26 보완이 ㎡당 표를 일당 편성 꼴로 바꿈 |
| 동부청영월#1843 (2024) | GC000262 메붙임 · 7-2-1 `7-02_돌붙임.md:18` | '12 · '19 · '26 | ㎡당 석공 0.13 · 굴착기 0.25h ↔ 3/26 · 8/26 | 판 차이 확정 · 블록 글은 「찰붙임(7-2-1)」 — 기초 고름도 확인 |
| 거창#15 (2025) | GC000264 전석쌓기 · 7-3-1 | '92 · '12 · '18 · '26 | 품 대조 못 함 | 판 차이 의심 |
| 봉화#55 (2024) | GC000094 암발파 TYPE-Ⅳ · 3-3-4 | '20 · '26 | 크롤러드릴 ×0.56 · 블록은 「공통품셈 3-1-8 … TYPE-Ⅲ」(옛 번호) | 판 차이 의심 + 유형 확인 |
| 거창#28 (2025) · 봉화#8 (2024) | GC000106 뒤채움 및 다짐(소형장비) · 3-4-4 | '20 · '25 | 보통인부 0.018 ↔ 0.009091 (×1.98 · 두 파일 같은 값 · 울진대흥#159 「3-4-2 기초다짐 및 뒷채움」도 0.018) | 판 차이 의심 — 옛 3-4-2 판 값 · 기초 고름 확인 |
| 봉화#23 · 봉화#38 · 동부청영월#27 (2024) | GF000101 · GF000123 · 산림 9-13-1 · 9-18 | 산림 2026-01-01 제정 | 굴착기 ×1.36 · ×1.65 · ×3.18 · 블록 「임도표준품셈 5-1-2 · 3-7」 | 판 차이(옛 임도품셈) — E · K 값 다름 |
| 울진대흥#2276 · 2289 · 2294 (2024) | GF000091 암절취 집토 · 산림 9-4-2 | 산림 2026-01-01 | 세 블록 같은 배 ×0.6776 · 블록 「건설표준품셈 11-3(굴삭기)」(옛 번호) | 판 차이 의심 — 덤프 조합(3번 갈래) 쪽 |
| 동부청영월#111 · 울진대흥#2264 · 2269 · 2290 | GF000132 섞기 · 9-22 | — | ×0.89 ~ ×1.52 | 기초 잘못 짚음(굴착기 0.7 Q 값 우연 · 임목파쇄는 현행 8-2-32 GC001189) — 판 아님 |
- 못 가름 6 줄(거창#12 · 울진 불도저 운반 다섯)은 운반거리 · 현장 값과 섞여 판을 가르지 못함 · 판 아님 19 줄은 2026 파일(같은 판)의 현장 값 · 단위 기준(÷10) · 감률 — json `근접_원문개정`
- 같은 배수 묶음: ×0.9714(봉화#98 두 줄) · ×1.98(거창#28 · 봉화#8) · ×0.6776(울진대흥 셋) 만 판 차이 쪽 · ×3.3 · ×2.917 · ×0.08 · ×0.5 묶음은 현장 값 · 소요량 · 표기(공구손료 %) · 감률
### 4-2. 파일별 옛 번호 인용
| 파일(연도) | 블록 | 옛 번호 인용 |
| --- | --- | --- |
| 북부청임도(2026) | 95 | 9 |
| 북부청사방(2026) | 67 | 2 |
| 거창(2025) | 61 | 1 |
| 봉화(2024) | 65 | 18 |
| 동부청영월(2024) | 69 | 43 |
| 울진대흥(2024) | 47 | 26 |
| 영덕계류(2025) | 15 | 7 |
| 울진소광(2025) | 40 | 8 |
- 옛 번호 = 엔진이 「건설품셈(옛) · 임도품셈(옛) · 사방품셈(옛)」 으로 본 출처 — 2024 파일일수록 옛 판 번호 · 값을 씀
- 지식DB 에는 현행 판 원문만(건설 2026 · 산림 2026-01-01 · 산림 CHANGELOG 「교본 이후 개정 1회」) — 옛 판 값은 원문으로 확인 못 함 · 엑셀 값과 절 머리 개정 연도로만 판정 · 건설 「2026 개정사항」 PDF 는 md 없음
## 5. 판 표지 꼴 제안
- M01 로직 파일 머리 `판`(건설 「2026」 · 산림 「2026-01-01」)은 이미 있음 — 줄 단위 판 정보가 없음
- 로직 줄 칸 「개정」 더함 — 원문 절 머리 개정 연도 목록(예 GC000207 = [2008, 2014, 2022, 2024]) · 빌더가 본문 md 머리에서 뽑음(사람 입력 없음) · 산림 줄 = [2026](제정)
- 로직 줄 칸 「옛판」(선택) — `[{판, 값, 근거}]` · 사용자 확정분만(예 GC000207 「'24 보완 전 · 토목 Type-Ⅰ 시공량 3.5」 근거 실무 봉화 2024) · 지식DB 에 옛 원문이 없어 엑셀 값만 근거
- 근거 제안 근접 까닭 「판 차이」에 판 번호 붙임 — 블록 판(글 속 「20xx년 품셈」 · 옛 번호 · 파일 연도) < 기초 절 최신 개정 연도 이면 까닭 글 = 「판 차이('24 보완 · 파일 2024)」 · 같은 값이 둘 이상 파일에서 같은 배로 어긋나면 「판 차이 강함」 · 「옛판」 값과 같으면 「옛 판 값 일치」
- 크기 어림:
| 대상 | 수 |
| --- | --- |
| 로직 줄 | 1,515 (GC 1,189 · GF 326) |
| 「개정」 자동 채움 | 1,237 (GC 911 · GF 326) · 못 찾음 278(건설 절 머리에 연도 표기 없음 · 원문번호 장 단위 등) |
| 2024~2026 개정 든 GC | 462 — 옛 판 실무와 어긋날 수 있는 줄 |
| 2026 개정 든 GC | 199 |
| 꼴 바꿈 | 로직 줄 칸 둘(`_틀.md` 7 장 열 차례) · 빌더 한 곳 · M01 화면 읽기만 표시 · 엔진 까닭 글(`M02_San_Basis_Check.causes`) 한 곳 · 지킴 시험 하나 |
| 값 바꿈 | 없음 — M01 셈 값 · 사전조합 · 공사 금액 전후 같음 |
## 6. 사용자가 고를 것
1. 9번 기계 채집(8 블록) — 자체 GX 한 줄로 넣을지 · 입력 칸(뒷길이 · 1회 개수 · 굴착기 규격) 그대로 둘지
2. 1번 자재 운반(10 블록) — 하차 품을 옛 판 값(철근 11분/ton · 시멘트 0.18 · 0.13 인)으로 넣을지 · 입력 칸으로 둘지(원문 「인도조건에 따라」)
3. 3번 덤프 조합(14 블록) — GF000284 입력 넓힘 · 자체 조합 새 줄 중 하나
4. 6번 잔토처리 단독 줄(3 블록) — 만들지
5. 2 · 10 · 11 · 15번 현행 판에 없는 품(소형 기계 운반 · 글자새김 · 크레인트럭 컨테이너 · 떼 하차) — 자체로 넣을지 뺄지
6. 판 표지 — 「개정」 칸만(자동) · 「옛판」 칸까지(확정분만) · 근거 제안 까닭 글만 중 고름 · M01 값은 현행 판 유지(제6조②)
7. 판 차이 확정 4 블록의 옛 값(철근 조립 3.5 · 합판거푸집 2017 · 돌붙임 '19 ㎡당 표) — 「옛판」 칸 첫 자료로 넣을지
8. 엔진 몫(4 · 13번 24 블록 · 섞기 오인 4) — 793-3a 후속(같은 말 묶음 · 옛 번호 대조표 · 기계 작업량 길 넓힘)으로 넘길지 — M01 보강과 따로
@@ -67,6 +67,19 @@ def _words(term) -> tuple[list[str], list[str]]:
def search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]:
"""`_search` 를 마스터 색인 하나에 조건마다 한 번만(792-2) —
답은 마스터 · 조건 · 지역 · 단위만 따름."""
place = mm.plain(env[mm.REGION]) if (env or {}).get(mm.REGION) else ""
memo = getattr(master, "_found", None)
if memo is None:
memo = master._found = {}
key = tuple(str(cond.get(k) or "") for k in ("검색어", "구분", "상세구분")) + (place, str(unit))
if key not in memo:
memo[key] = _search(master, cond, env, unit)
return list(memo[key])
def _search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]:
"""검색어 안 자재품목 키 — 낱말이 모두 이름 또는 규격에 든 줄(요소의 구분 · 상세구분 안에서) ·
단위는 같은 성격까지(하나도 없으면 다 — 경고가 붙음) · 고르는 차례:
값 있는 줄 → 줄 단위와 똑같은 단위 → 지역 낱말(입력 `자재지역` 포함)과 맞는 줄 → 키 차례."""
@@ -192,7 +192,9 @@ def test_상세_부품_길_부품상자():
side = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_Side.ts")
modal = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Modal.ts")
assert "export const hasMasterJump" in side and "go.hidden = !hasMasterJump()" in modal
assert len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 627
assert (
len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 637
) # 794-1 시트 이음 10 줄
def test_화면_문서_꼴_서버_저장_셈(world): # noqa: F811 — 가져온 fixture
+1 -1
View File
@@ -56,7 +56,7 @@ def test_상세가_공용_부품을_씀():
assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts")
for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"):
assert len(_text(p).splitlines()) <= (
627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700
637 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700 # 794-1 시트 이음 10 줄
)
+4 -2
View File
@@ -276,26 +276,28 @@ def test_790_3_식_조각_글줄_산출서_열쇠_밂():
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts" # 794-1 식 칸 몸 = 공용(M01 시트와 한 벌)
def test_792_식_칸_정적():
s = _text(FX)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
both = s + _text(CELL)
for call in (
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
"signal,",
'box.input.addEventListener("input"',
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
"if (!got?.되나) return", # 틀린 식은 문서에 안 넣음
"if (!got?.되나 || !got.식) return", # 틀린 식은 문서에 안 넣음
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
"addVar(c.doc as FormulaDoc,",
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
"dropVar(c.doc as FormulaDoc, v.이름)",
):
assert call in s, call
assert call in both, call
sheet = _text(SHEET)
for call in (
"rowFormula(ctx, it.자리,",
+122
View File
@@ -0,0 +1,122 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""792-2 시트 다시 셈 서버 다듬기 — 자재 색인 · 찾기 답을 M01 색인에 한 번만.
- 셈마다 새 `_Over` 덮개를 지어도 자재 색인(`_market` · `_by_class`) · 찾기 답(`_found`)은 속 M01
색인에 붙음 — 두 번째 셈은 다시 안 지음
- M01 파일 자국이 바뀌면 새 색인 = 새로 지음(지문 열쇠 = `M01_MasterData_Store_Cache.loaded`)
- 층 섞임 없음 — 기억은 M01 자재품목 키만 · 개인 자재(PM)는 그때 층에서만 찾음
- 셈 답 같음 — 기억한 찾기 = 날 찾기 · SK 금액 견본(합판거푸집 51,419 · 공구손료 2 % 51,802)
"""
from __future__ import annotations
import json
import os
from decimal import Decimal
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Cache as cache
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Material_Own as own
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
mm = m01.cm.mf.mp.mm
mp = m01.cm.mf.mp
F211 = {
"자재지역": "포항",
"유형": "보통",
"사용횟수": "6회",
"높이감률": 0,
"지붕슬래브": "아님",
"폼타이": "아님",
"폼타이규격": "12.7㎜",
"측압": "3t/㎡",
"구멍땜": 0,
}
def _sk(**more):
doc = {"키": "SK-000001", "이름": "속도", "단위": "식", "바탕": {"로직": "GC000211"}}
return {**doc, "입력값": F211, **more}
def test_색인은_M01_색인에_한_번_두번째_셈은_안_지음(monkeypatch):
first = calc.calc_doc(_sk())
whole = m01.loaded()[1]
held = {n: vars(whole)[n] for n in ("_market", "_found")}
fresh = mp._search
found = []
monkeypatch.setattr(mp, "_search", lambda *a: found.append(a) or fresh(*a))
again = calc.calc_doc(_sk())
assert again["기준"] == first["기준"] and first["기준"]["계"] == 51419
assert all(vars(whole)[n] is v for n, v in held.items())
assert found == [] # 같은 조건 찾기 = 기억한 답(자재품목 3만 줄을 안 훑음)
over = calc._Over(whole, "SK-000001", {})
assert over._market is whole._market and "_market" not in vars(over)
over.mine = 1 # 그 밖 칸은 덮개에만
assert "mine" not in vars(whole)
def test_M01_파일이_바뀌면_새_색인_새로_지음(tmp_path):
def write(rows):
path = tmp_path / "자재품목.json"
path.write_text(json.dumps({"그룹": "재료", "줄": rows}, ensure_ascii=False), "utf-8")
return path
one = {"키": "MT000001", "이름": "합판", "규격": "12mm", "단위": "장", "물가자료": 100}
two = {**one, "키": "MT000002", "이름": "각재"}
try:
write([one])
old = cache.loaded(tmp_path)[1]
assert [k for *_, k in mm._market_index(old)] == ["MT000001"]
path = write([one, two])
os.utime(path, ns=(1, 1)) # 자국(시각 · 크기)이 꼭 바뀌게
new = cache.loaded(tmp_path)[1]
assert new is not old and not hasattr(new, "_market")
assert [k for *_, k in mm._market_index(new)] == ["MT000001", "MT000002"]
assert cache.loaded(tmp_path)[1] is new # 안 바뀌면 그대로
finally:
cache.forget() # 다음 시험은 M01 폴더를 다시 읽음
def test_기억한_찾기_답은_날_찾기와_같고_사본을_줌():
whole = m01.loaded()[1]
conds = [
({"검색어": "합판 12mm", "구분": "목재"}, {}, "㎡"),
({"검색어": "철선"}, {mm.REGION: "포항"}, "kg"),
({"검색어": "레미콘"}, {mm.REGION: "서울"}, None),
]
for cond, env, unit in conds:
got = mp.search(whole, cond, env, unit)
assert got == mp._search(whole, cond, env, unit)
got.append("MT999999")
assert "MT999999" not in mp.search(whole, cond, env, unit)
assert mp.search(whole, conds[1][0], {mm.REGION: "서울"}, "kg") == mp._search(
whole, conds[1][0], {mm.REGION: "서울"}, "kg"
)
def test_층_섞임_없음_개인_자재는_그때_층에서만(world):
calc.calc_doc(_sk()) # M01 색인에 기억이 생긴 뒤
whole = m01.loaded()[1]
assert all(k.startswith(mm.MARKET) for *_, k in whole._market)
assert all(k.startswith(mm.MARKET) for keys in whole._found.values() for k in keys)
with store.at(world["personal"]):
code = own.add(world["personal"], {"이름": "내 자재", "단위": "개", "값": 1234})["키"]
mine = labor.material_price(whole, code, "개")
other = world["personal"].parent.parent / "43" / world["personal"].name
other.mkdir(parents=True)
with store.at(other):
theirs = labor.material_price(whole, code, "개")
assert Decimal(str(mine["기준단가"])) == 1234
assert theirs["기준단가"] is None
assert code not in whole.index.get(code[:2], {})
def test_상수_덮개_셈_답_전후_같음():
got = calc.calc_doc(_sk(상수덮개={"pct:공구손료 및 경장비#0": {"0": 2}}))
assert got["기준"]["계"] == 51802
assert got["원래"]["계"] == 51419
assert got["시트"]["자리"]["pct:공구손료 및 경장비#0"]["덮개"] == ["상수"]
+234
View File
@@ -0,0 +1,234 @@
# ruff: noqa: E501
"""794-1 M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 = M02 단가산출 시트와 한 벌.
- 정적: 시트 화면 = 부품 상자만(날 button · input 0) · 식 칸 몸 = 공용 `sheet_formula_cell`(M02 도 같은 것) ·
고침 = 관리자만 · 줄 사본 → 캐시(touch) → 기존 [저장] 길(시트는 `/save` 안 부름) · 보기 첫 칸 = 시트 ·
자체 로직(GX) 편집기 식 칸 = 읽기만(날 입력칸 갈음)
- 서버(사본 폴더): 이름 꼴 식 글 · 칠 때마다 검사(없는 이름 · 아래 줄 · 괄호 · 빈 식 · 길이 · 없는 자리) ·
검사 = 쓰기 없음 · 공구손료 식 1 → 2 % 저장(개정 확인 409 → 200) 뒤 셈 51,802 · 틀린 식 저장 422 · 시스템 관리자가 아니면 403
- 모델(node strip-types): 줄 갈래 · 줄 금액 = 호표 → 덧줄 차례 · 고친 줄 · 끝수 · 단계 · 식 칸 고침
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
M01 = ROOT / "M01_MasterData"
SHEET = M01 / "M01_MasterData_UI_LogicLab_Sheet.ts"
MODEL = M01 / "logic" / "M01_Logic_Sheet_Model.ts"
LAB = M01 / "M01_MasterData_UI_LogicLab.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts"
REAL = store.FOLDER
KEY = "GC000211" # 합판거푸집 설치 및 해체
INPUTS = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님",
"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} # fmt: skip
TOOL_1 = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
TOOL_2 = TOOL_1.replace("× 1 / 100", "× 2 / 100")
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
def _text(p: Path) -> str:
return p.read_text("utf-8")
def _hashes(folder: Path) -> dict[str, str]:
return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))}
# ── 정적 ─────────────────────────────────────────────────────────────
def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
for p in (SHEET, MODEL, CELL):
s = _text(p)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
sheet = _text(SHEET)
for part in (
"createCalcSheetRow",
"createFormulaCell",
'"/logic/sheet"',
'"/logic/sheet/check"',
):
assert part in sheet, part
assert "/save" not in sheet # 저장 = 상세 [저장](기존 길)
assert sheet.count("opt.admin &&") >= 2 # [식 고침] · 식 칸 = 관리자만
lab = _text(LAB)
assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n")
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
assert len(lab.splitlines()) <= 637
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
assert (
'host.dataset.view = "sheet";' in view
and '{ value: "sheet", label: tl("View_Sheet") }' in view
)
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css
edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts")
assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts")
assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸
main = _text(ROOT / "main.py")
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
# ── 서버 ─────────────────────────────────────────────────────────────
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
master = tmp_path / "master"
master.mkdir()
for path in REAL.glob("*.json"):
shutil.copy(path, master / path.name)
monkeypatch.setattr(store, "FOLDER", master)
return master
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(m01_router.router)
return TestClient(app)
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def test_이름_꼴_검사_쓰기_없음(client: TestClient, folder: Path) -> None:
before, real = _hashes(folder), _hashes(REAL)
one = client.get("/api/m01/logic", params={"key": KEY}).json()
row = one["logic"]
names = _post(client, "/api/m01/logic/sheet", {"row": row})
assert names["덧줄"][0] == TOOL_1
assert names["중간"][3] == "[합판거푸집설치및해체일당].형틀목공 / 시공량"
assert (
len(names["호표"]) == len(row["호표"])
and {"글": "시공량", "갈래": "중간"} in names["이름표"]
)
ask = lambda part, at, text: _post( # noqa: E731
client, "/api/m01/logic/sheet/check", {"row": row, "갈래": part, "차례": at, "식": text}
)
good = ask("덧줄", 0, TOOL_2)
assert good["되나"] and good["식"] == "(형틀목공 * LB000007 + 보통인부 * LB000002) * 2 / 100"
assert good["글"] == TOOL_2
assert ask("덧줄", 0, "형틀목공 × [노임 없음]")["까닭"] == ["없는 이름 「[노임 없음]」"]
below = ask("중간", 0, "시공량 × 2") # 시공량 = 아래 줄
assert not below["되나"] and "시공량" in below["까닭"][0]
assert not ask("중간", 2, "(1 +")["되나"]
assert ask("호표", 0, " ")["까닭"] == ["식이 빔"]
assert not ask("호표", 0, "1" + " + 1" * 200)["되나"] # 길이 상한
assert ask("덧줄", 9, "1")["까닭"] == ["없는 자리 「덧줄 9」"]
assert ask("호표", 0, "형틀목공 × 2")["되나"]
assert row == client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] # 줄 안 바뀜
assert _hashes(folder) == before and _hashes(REAL) == real
def test_저장_길_개정_확인_다시_셈_틀린_식_422(client: TestClient, folder: Path) -> None:
real = _hashes(REAL)
one = client.get("/api/m01/logic", params={"key": KEY}).json()
row = copy.deepcopy(one["logic"])
got = line_sheet.check(row, "덧줄", 0, TOOL_2)
row["덧줄"][0]["식"] = got["식"]
body = {"files": [{"file": one["file"], "version": one["version"],
"changes": [{"op": "edit", "key": KEY, "row": row}]}]} # fmt: skip
assert client.post("/api/m01/save", json=body).status_code == 409 # 정본 = 개정 확인
_post(client, "/api/m01/save", {**body, "개정확인": True})
saved = client.get("/api/m01/logic", params={"key": KEY}).json()
assert saved["logic"]["덧줄"][0]["식"] == got["식"]
sums = _post(client, "/api/m01/calc", {"key": KEY, "inputs": INPUTS})["sums"]
assert sums["계"] == 51802 and sums["경비"] == 765 # _분석_650-H2 4 장 공구손료 2 %
bad = copy.deepcopy(saved["logic"])
bad["덧줄"][0]["식"] = "형틀목공 * 없는이름"
body = {"files": [{"file": saved["file"], "version": saved["version"],
"changes": [{"op": "edit", "key": KEY, "row": bad}]}], "개정확인": True} # fmt: skip
before = _hashes(folder)
assert client.post("/api/m01/save", json=body).status_code == 422
assert _hashes(folder) == before and _hashes(REAL) == real
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
for url, body in (("/api/m01/logic/sheet", {"row": {}}),
("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip
assert client.post(url, json=body).status_code == 403
# ── 모델 ─────────────────────────────────────────────────────────────
ROW = {
"키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄",
"입력": [{"이름": "폭", "단위": "m"}],
"중간": [{"이름": "넓이", "식": "폭 * 2"}],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}],
"덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}],
"단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}],
"끝수": {"대상": "비목", "방법": "버림", "자리": 0},
} # fmt: skip
def _run() -> dict:
src = IMPORTS.sub("", _text(MODEL))
body = (
f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};"
"\nconst now = structuredClone(R);"
"\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');"
"\nconst miss = setLabFormula(now, '덧줄', 5, '1');"
"\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 },"
" lines: [{ 금액: 1000 }, { 금액: 10.5 }] };"
"\nconst items = labItems({ row: now, original: R, values: { 폭: '3' },"
" names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });"
"\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });"
"\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_모델_줄_갈래_값_고친_줄() -> None:
got = _run()
assert got["put"] is True and got["miss"] is False
kinds = [(x["kind"], x["이름"]) for x in got["items"]]
assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"),
("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"),
("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip
by = {(x["kind"], x["이름"]): x for x in got["items"]}
assert by[("옵션", "폭")]["값"] == "3"
assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"]
res = by[("자원", "보통인부 (인)")]
assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"]
assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0
assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄"
assert by[("단계", "노무 소계")]["식"] == "A01"
assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리"
assert by[("합계", "계")]["값"] == 1010
bare = {(x["kind"], x["이름"]): x for x in got["bare"]}
assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」
assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare
assert got["cut"] == "글자 끝수"