feat(794-1): M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 칠 때마다 검사 · 기존 [저장] 길
- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전 - 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음) - 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로] - 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로) - 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음) - 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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