diff --git a/A00_Common/sheet_formula_cell.ts b/A00_Common/sheet_formula_cell.ts new file mode 100644 index 000000000..1a36ab683 --- /dev/null +++ b/A00_Common/sheet_formula_cell.ts @@ -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; +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; + check: (text: string, signal: AbortSignal) => Promise; + 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 => { + 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], + }); +} diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index eafc850b1..ea70058c7 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -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: diff --git a/M01_MasterData/M01_MasterData_Sheet.py b/M01_MasterData/M01_MasterData_Sheet.py new file mode 100644 index 000000000..60169d6ba --- /dev/null +++ b/M01_MasterData/M01_MasterData_Sheet.py @@ -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} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index aee6cc173..fc06fced4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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("[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(); }; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index c2c91174a..7d4a659c2 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -39,6 +39,8 @@ export interface DetailContext extends HoContext { onTrial?: () => void; /** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType }; + /** 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"), [ diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts new file mode 100644 index 000000000..3c099811d --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts @@ -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; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +const SECTION: Record = { + 근거: "근거", + 옵션: "옵션", + 계산: "계산", + 자원: "자원", + 율: "율", + 단계: "단계", + 소계: "소계", + 합계: "소계", +}; +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; + answer: CalcAnswer | null; +} + +export interface LabSheetOptions { + /** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */ + admin: boolean; + onInput: (name: string, value: string) => void; + /** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */ + edited: () => void; + /** 시험 계산 [계산] 누름 */ + run: () => void; +} + +async function post(path: string, body: unknown, signal?: AbortSignal): Promise { + 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(); + const drafts = new Map(); + + /** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */ + const askNames = (row: LogicRow): void => { + const text = JSON.stringify(row); + if (text === asked) return; + asked = text; + ctl?.abort(); + const mine = (ctl = new AbortController()); + post("/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 => + post("/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(`[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 | 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) => { + 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 }); + }, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index e1902999b..cdf9ea8ed 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -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"]), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index cbfee37de..50f9d3a74 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -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"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts index 577eb6d07..549778772 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -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 = (node: T, view: LabView): T => { @@ -22,15 +22,16 @@ export const block = (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), diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 5014c2dfa..81d441dcf 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -70,6 +70,8 @@ export interface CalcContext { /** 로직마다 넣은 값 — 다시 그려도 남음 */ values: Record; 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)); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts index 70a84fe90..a24eb679f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts @@ -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( diff --git a/M01_MasterData/logic/M01_Logic_Sheet_Model.ts b/M01_MasterData/logic/M01_Logic_Sheet_Model.ts new file mode 100644 index 000000000..0e569cf0f --- /dev/null +++ b/M01_MasterData/logic/M01_Logic_Sheet_Model.ts @@ -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 = { 중간: "식", 호표: "수량", 덧줄: "식" }; + +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 = { 노무비: "노", 재료비: "재", 경비: "경" }; +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[]; + 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[] | 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; + 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; + names: LabNames | null; + answer: CalcAnswer | null; +}): LabItem[] { + const { row, original } = o; + const out: LabItem[] = []; + const item = (x: Partial & { 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; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts index d95517573..1118cf5ea 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts @@ -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; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; @@ -97,7 +93,7 @@ export function clearNums(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 | null = null; - const run = (text: string): Promise => { - 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, }); } diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index eaf7b1cbf..50aa950ab 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -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 diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py index c57fa313f..f8c3cd1be 100644 --- a/resources/tester/test_722_ho_detail.py +++ b/resources/tester/test_722_ho_detail.py @@ -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 줄 ) diff --git a/resources/tester/test_790_1_sangeun_sheet.py b/resources/tester/test_790_1_sangeun_sheet.py index be9d5658b..4ceb58f3c 100644 --- a/resources/tester/test_790_1_sangeun_sheet.py +++ b/resources/tester/test_790_1_sangeun_sheet.py @@ -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 " 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 "= 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"] == "글자 끝수"