Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 줄
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.자리,",
|
||||
|
||||
@@ -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"]["덮개"] == ["상수"]
|
||||
@@ -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"] == "글자 끝수"
|
||||
Reference in New Issue
Block a user