From 43a59a68932d1d80e4ae7779d2e9bf44c107ab0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:27:30 +0900 Subject: [PATCH] =?UTF-8?q?refactor(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EB=84=A3=EC=9D=84=20=EA=B0=92=20=EC=B9=B8=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=EC=98=AE=EA=B9=80(39-3=20C2)=20=E2=80=94=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=9E=85=EB=A0=A5=EC=97=90=EC=84=9C=20M02=5FLabor?= =?UTF-8?q?=5FInputs.py=20=C2=B7=20M02=5FLabor=5FValues(=5FModel).ts=20?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=98=9B=20=EC=9E=90=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=82=B4=EB=B3=B4=EB=83=84=20=C2=B7=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20=EA=B8=B8=20logic-lib?= =?UTF-8?q?/inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: inputs · combo_logics · _num 몸을 M02_Labor_Inputs.py 로(logic_inputs = 로직 키들 입력 + 자동값) · M02_MasterTemplete_Inputs.py 는 그것을 부름 - 길: GET /api/m02/logic-lib/inputs?keys= (로그인 · 일위대가 입력 모달용) - 화면: 값 카드(createInputsValueCard) · 값 함수 → M02_Labor_Values.ts · M02_Labor_Values_Model.ts · 값 칸 하나(createValueControl)를 떼어 값 카드와 입력 모달이 같이 씀 · CSS 도 옮김 - 옛 자리(M02_MasterTemplete_Inputs.ts · _Inputs_Model.ts)는 새 파일을 다시 내보냄 — B05 고르기 모달 그대로 - 시험: 새 test_m02_labor_values.py · 옮긴 자리 따라 test_item_picker_detail · test_m02_card_shell · helper_m02_inputs 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Inputs.py | 94 ++++++ M02_MasterTemplete/M02_Labor_Values.css | 99 ++++++ M02_MasterTemplete/M02_Labor_Values.ts | 316 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Values_Model.ts | 116 +++++++ .../M02_MasterTemplete_Inputs.css | 98 ------ .../M02_MasterTemplete_Inputs.py | 65 +--- .../M02_MasterTemplete_Inputs.ts | 299 +---------------- .../M02_MasterTemplete_Inputs_Model.ts | 58 +--- .../M02_MasterTemplete_Router_Combos.py | 7 + resources/tester/helper_m02_inputs.cjs | 19 +- resources/tester/test_item_picker_detail.py | 9 +- resources/tester/test_m02_card_shell.py | 4 +- resources/tester/test_m02_labor_values.py | 101 ++++++ 13 files changed, 770 insertions(+), 515 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Inputs.py create mode 100644 M02_MasterTemplete/M02_Labor_Values.css create mode 100644 M02_MasterTemplete/M02_Labor_Values.ts create mode 100644 M02_MasterTemplete/M02_Labor_Values_Model.ts create mode 100644 resources/tester/test_m02_labor_values.py diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py new file mode 100644 index 000000000..068c64b33 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -0,0 +1,94 @@ +"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김). + +담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은 +그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 = +`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과 +옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + +_SPEC = ("이름", "설명", "단위", "고르기", "범위") + + +def _num(value: Any) -> Any: + """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_num(v) for v in value] + if isinstance(value, dict): + return {k: _num(v) for k, v in value.items()} + return value + + +def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: + """([로직 줄 …], 빠진 로직 키) — 키 차례 그대로.""" + whole = m01.loaded()[1] + found, missing = [], [] + for code in codes: + try: + found.append(whole.logic(code)) + except combo_store.cm.mf.FormulaError: + missing.append(code) + return found, missing + + +def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: + """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" + if not ua: + return None, [], [] + row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c) + if row is None: + return None, [], [] + found, missing = _logics(combo_store.mcb.logic_keys(row)) + return row, found, missing + + +def _listed(logics: list[dict]) -> list[dict[str, Any]]: + """로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`.""" + whole = m01.loaded()[1] + out = [] + for logic in logics: + auto = m01.mau.values(whole, logic) + specs = [] + for spec in logic.get("입력") or []: + one = {k: spec[k] for k in _SPEC if k in spec} + one.setdefault("설명", None) + one.setdefault("단위", None) + specs.append({**one, "자동값": auto.get(spec["이름"])}) + out.append( + { + "로직": logic.get("키"), + "이름": logic.get("이름"), + "결과단위": logic.get("결과단위"), + "입력": specs, + } + ) + return out + + +def logic_inputs(codes: list[str]) -> dict[str, Any]: + """일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)""" + found, missing = _logics(list(dict.fromkeys(c for c in codes if c))) + return _num({"로직": _listed(found), "빠진로직": missing}) + + +def inputs_of(ua: str | None) -> dict[str, Any]: + """옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음.""" + row, logics, missing = combo_logics(ua) + return _num( + { + "일위대가": ua or None, + "이름": row.get("이름") if row else None, + "로직": _listed(logics), + "빠진로직": missing, + } + ) diff --git a/M02_MasterTemplete/M02_Labor_Values.css b/M02_MasterTemplete/M02_Labor_Values.css new file mode 100644 index 000000000..dca291790 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.css @@ -0,0 +1,99 @@ +/* 넣을 값 칸(39-3 · 옛 선택 입력 CSS 에서 옮김) — 프로젝트 값 카드 · 일위대가 입력 모달이 같이 씀. 색은 테마 토큰. */ + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ +.m02-inputval__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputval { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputval__places, +.m02-inputval__missing { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-inputval__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputval__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__control { + flex: 1 1 6rem; + min-width: 0; + max-width: 24rem; +} + +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ +.m02-inputval__value.is-auto, +.m02-inputval__control.is-auto, +.m02-inputval__control::placeholder { + color: var(--color-text-secondary); + font-style: italic; + opacity: 1; +} + +.m02-inputval__state { + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputval__state.is-confirmed { + color: var(--color-text); + font-weight: 600; +} + +.m02-inputval__error { + flex: 1 0 100%; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-inputval__control[aria-invalid="true"] { + border-color: var(--color-danger); +} diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts new file mode 100644 index 000000000..a7453b93a --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -0,0 +1,316 @@ +/* ============================================================================= + * M02_Labor_Values.ts + * 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌: + * 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder). + * · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀) + * · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch"; +import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; +import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model"; +import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; + +const fill = (text: string, values: Record): string => + text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); + +const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; + +const autoValue = (input: ItemInput): string | number | null => { + const auto = input.자동값; + return typeof auto === "string" || typeof auto === "number" ? auto : null; +}; + +export interface ValueControl { + control: HTMLInputElement | HTMLSelectElement; + /** 범위 밖 글(빈 글이면 비움) */ + error: HTMLElement; + /** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */ + wasBad: boolean; +} + +/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */ +export function createValueControl( + input: ItemInput, + value: string | number | null, + onChange: (value: string | number | null, bad: boolean) => void, +): ValueControl { + const auto = autoValue(input); + const range = rangeOf(input); + const choices = choicesOf(input); + const control = ( + choices.length + ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) + : el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: range ? "number" : "text", + ...(range?.min !== undefined ? { min: String(range.min) } : {}), + ...(range?.max !== undefined ? { max: String(range.max) } : {}), + ...(range ? { step: "any" } : {}), + placeholder: auto === null ? "" : String(auto), + }, + }) + ) as HTMLInputElement | HTMLSelectElement; + if (control instanceof HTMLSelectElement) { + control.append( + el("option", { + attrs: { value: "" }, + text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, + }), + ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ); + } + const empty = value === null || value === ""; + control.value = empty ? "" : String(value); + control.classList.toggle("is-auto", empty); + const error = el("span", { className: "m02-inputval__error" }); + const out: ValueControl = { control, error, wasBad: false }; + control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => { + const raw = control.value; + const bad = isOutside(input, raw); + control.setAttribute("aria-invalid", bad ? "true" : "false"); + error.textContent = bad + ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) + : ""; + control.classList.toggle("is-auto", raw === ""); + onChange(storedOf(input, raw), bad); + }); + return out; +} + +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── + * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 + * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 + * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. + * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ + +export interface InputsValueData { + list: ItemInputs; + link: InputLinks | null; + values: ValueDoc; +} + +export interface InputsValueOptions { + mode: "view" | "value"; + /** mode "view" — [선택 입력 수정] */ + onEdit?: () => void; + /** mode "value" — 값이 바뀔 때마다 */ + onChange?: (values: ValueDoc) => void; +} + +export interface InputsValueHandle { + root: HTMLElement; + /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ + set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; + /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ + loading: () => () => void; + /** 지금 값(확인한 것 · 값이 든 것만) */ + getValues: () => ValueDoc; + /** 범위 밖 값이 없나 */ + isValid: () => boolean; + /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ + setEditLabel: (label: string) => void; +} + +const VALUE_TEXT = { + title: "선택 입력", + missing: "빠진 로직 {value}", + edit: "선택 입력 수정", + none: "선택으로 이은 입력이 없음", + auto: "자동값", + unconfirmed: "확인 안 함", + confirmed: "✓ 확인함", + confirmBox: "확인", + summary: "선택 {value}", + places: "면적산출 알약 {area} · 재료산출 알약 {material}", + noList: "입력 목록을 못 읽음 — {value}", +}; + +type ValueEntry = ValueDoc[string][string]; + +export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { + let data: InputsValueData | null = null; + let values: ValueDoc = {}; + let invalid = 0; + + const summary = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); + edit.hidden = opts.mode !== "view"; + if (opts.onEdit) edit.addEventListener("click", opts.onEdit); + const places = el("p", { className: "m02-inputval__places" }); + const body = el("div", { className: "m02-struct__sheet m02-inputval" }); + // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 + const shell = cardShell({ + title: VALUE_TEXT.title, + sub: [summary], + body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), + stateHost: body, + }); + shell.tools.append(edit); + const root = shell.root; + + const groups = (): ReturnType => + data ? pickedGroups(data.list, data.link) : []; + const one = (logic: string, name: string): ValueEntry => + values[logic]?.[name] ?? { 값: null, 확인: false }; + const isEmpty = (value: string | number | null): boolean => value === null || value === ""; + const put = (logic: string, name: string, next: ValueEntry): void => { + (values[logic] ??= {})[name] = next; + }; + const autoOf = autoValue; + + const paintSummary = (): void => { + let total = 0; + let confirmed = 0; + for (const { logic, inputs } of groups()) + for (const input of inputs) { + total += 1; + if (one(logic.로직, input.이름).확인) confirmed += 1; + } + summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; + }; + + /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ + const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { + const stored = one(logic.로직, input.이름); + const auto = autoOf(input); + const state = el("span", { className: "m02-inputval__state" }); + const paintState = (): void => { + const now = one(logic.로직, input.이름); + state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; + state.classList.toggle("is-confirmed", now.확인); + }; + if (opts.mode === "view") { + const text = isEmpty(stored.값) ? auto : stored.값; + const shown = el("span", { + className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, + text: text === null ? "—" : String(text), + attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [shown, state] }); + } + + const emit = (): void => { + paintState(); + paintSummary(); + opts.onChange?.(values); + }; + const range = rangeOf(input); + const field = createValueControl(input, stored.값, (value, bad) => { + if (bad !== field.wasBad) invalid += bad ? 1 : -1; + field.wasBad = bad; + // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) + put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad }); + box.checked = value !== null && !bad; + emit(); + }); + const { control, error } = field; + const box = el("input", { + attrs: { + type: "checkbox", + title: VALUE_TEXT.confirmBox, + "aria-label": VALUE_TEXT.confirmBox, + }, + }) as HTMLInputElement; + box.checked = stored.확인; + // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 + box.addEventListener("change", () => { + const now = one(logic.로직, input.이름); + if (box.checked && isEmpty(now.값) && auto !== null) { + control.value = String(auto); + control.classList.remove("is-auto"); + put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); + } else if (box.checked && isEmpty(now.값)) { + box.checked = false; // 받아들일 값이 없음 + } else { + put(logic.로직, input.이름, { ...now, 확인: box.checked }); + } + emit(); + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); + }; + + const paint = (note?: string, retry?: () => void): void => { + invalid = 0; + paintSummary(); + if (!data) { + places.textContent = ""; + if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); + return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); + } + const seats = [...seatsOf(data.link).values()]; + const area = seats.filter((seat) => seat === "면적").length; + const material = seats.filter((seat) => seat === "재료").length; + places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; + const shown = groups(); + const missing = data.list.빠진로직.length + ? [ + el("p", { + className: "m02-inputval__missing", + text: fill(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }), + }), + ] + : []; + if (!shown.length) { + body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); + return; + } + body.replaceChildren( + ...shown.map(({ logic, inputs }) => + el("section", { + className: "m02-inputval__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputval__logic-name", + text: logic.이름 || logic.로직, + attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, + }), + ...inputs.map((input) => + el("div", { + className: "m02-inputval__row", + attrs: { "data-input": inputKey(logic.로직, input.이름) }, + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: input.설명 ? { title: input.설명 } : {}, + }), + valueCell(logic, input), + ], + }), + ), + ], + }), + ), + ...missing, + ); + }; + + return { + root, + set: (next, note, retry) => { + data = next; + values = next ? structuredClone(next.values) : {}; + paint(note, retry); + }, + loading: () => shell.loading(), + getValues: () => { + // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) + const out: ValueDoc = {}; + for (const [logic, byName] of Object.entries(values)) + for (const [name, entry] of Object.entries(byName)) + if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; + return out; + }, + isValid: () => invalid === 0, + setEditLabel: (label) => { + (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; + }, + }; +} diff --git a/M02_MasterTemplete/M02_Labor_Values_Model.ts b/M02_MasterTemplete/M02_Labor_Values_Model.ts new file mode 100644 index 000000000..046ee7cc8 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values_Model.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * M02_Labor_Values_Model.ts + * 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김. + * 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 · + * 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림). + * ========================================================================== */ + +import type { ItemInput } from "./M02_MasterTemplete_Api_Fetch"; + +const text = (value: unknown): string => + value === null || value === undefined + ? "" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + +/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ +export function valueHint(input: ItemInput): string { + if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + const range = input.범위; + if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; + if (range && typeof range === "object") { + const r = range as Record; + const lo = r.최소 ?? r.min; + const hi = r.최대 ?? r.max; + if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; + } + return text(range); +} + +/** 자동값 글 — 없으면 빈 글 */ +export const autoText = (input: ItemInput): string => text(input.자동값); + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ +export type ValueDoc = Record< + string, + Record +>; + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ +export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; + if (raw && typeof raw === "object") { + const r = raw as Record; + return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; + } + return null; +} + +/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const object = + typeof one === "object" && one !== null ? (one as Record) : null; + const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; + return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + }); +} + +/** 범위 밖 글인가 — 빈 글 · 범위 없는 입력은 아님 */ +export function isOutside(input: ItemInput, raw: string): boolean { + const range = rangeOf(input); + if (!range || raw.trim() === "") return false; + const value = Number(raw); + return ( + !Number.isFinite(value) || + (range.min !== undefined && value < range.min) || + (range.max !== undefined && value > range.max) + ); +} + +/** 칸 글 → 저장 값 — 빈 글 = null(자동값) · 범위 = 수 · 고르기 = 원래 모양 · 그 밖 = 글 */ +export function storedOf(input: ItemInput, raw: string): string | number | null { + if (raw === "") return null; + if (rangeOf(input)) return Number(raw); + return choicesOf(input).find((c) => c.값 === raw)?.raw ?? raw; +} + +/* ── 일위대가 입력값(39-3) — 로직 한 줄의 `{입력 이름: 값}` ───────────────────────────── */ + +export type LaborValues = Record; + +/** 로직 입력 목록에 있는 이름 · 빈 값 아닌 것만 남김 + 사라진 이름(로직 입력이 바뀌어 버려질 값) */ +export function keepKnown( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { kept: LaborValues; lost: string[] } { + const names = new Set(inputs.map((i) => i.이름)); + const kept: LaborValues = {}; + const lost: string[] = []; + for (const [name, value] of Object.entries(values ?? {})) { + if (!names.has(name)) lost.push(name); + else if (value !== "" && value !== null && value !== undefined) kept[name] = value; + } + return { kept, lost }; +} + +/** 줄 아래 작은 글 — 「지역 서울 · 규격 25mm」(넣은 값만 · 입력 차례) + 빈 값 수(자동값도 없는 입력) */ +export function valueLine( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { text: string; empty: number } { + const parts: string[] = []; + let empty = 0; + for (const input of inputs) { + const value = values?.[input.이름]; + if (value !== undefined && value !== "") { + parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`); + } else if (autoText(input) === "") empty += 1; + } + return { text: parts.join(" · "), empty }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css index d90721bea..fff107c95 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -103,101 +103,3 @@ .m02-inputs__seat:disabled { opacity: 0.7; } - -/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ -.m02-inputval__wrap { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: var(--m02-struct-body-min, 16rem); - min-width: 0; - gap: var(--spacing-8); -} - -.m02-inputval { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - overflow: auto; - min-height: 0; - min-width: 0; -} - -.m02-inputval__places, -.m02-inputval__missing { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-inputval__logic { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-inputval__logic-name { - margin: 0; - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__row { - display: grid; - grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); - gap: var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__name { - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__cell { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__control { - flex: 1 1 6rem; - min-width: 0; - max-width: 24rem; -} - -/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ -.m02-inputval__value.is-auto, -.m02-inputval__control.is-auto, -.m02-inputval__control::placeholder { - color: var(--color-text-secondary); - font-style: italic; - opacity: 1; -} - -.m02-inputval__state { - color: var(--color-text-secondary); - font-size: var(--text-caption); - white-space: nowrap; -} - -.m02-inputval__state.is-confirmed { - color: var(--color-text); - font-weight: 600; -} - -.m02-inputval__error { - flex: 1 0 100%; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m02-inputval__control[aria-invalid="true"] { - border-color: var(--color-danger); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py index 91bc32d87..d6c130984 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -14,30 +14,18 @@ M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · from __future__ import annotations import json -from decimal import Decimal from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json -from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics PLACES = ("면적", "재료", "선택", None) PILL_HEAD = "입력" -_SPEC = ("이름", "설명", "단위", "고르기", "범위") - - -def _num(value: Any) -> Any: - """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" - if isinstance(value, Decimal): - return int(value) if value == value.to_integral_value() else float(value) - if isinstance(value, list): - return [_num(v) for v in value] - if isinstance(value, dict): - return {k: _num(v) for k, v in value.items()} - return value def pill_key(logic: str, name: str) -> str: @@ -56,55 +44,10 @@ def _combo_row(ua: str) -> dict[str, Any] | None: return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c) -def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: - """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" - if not ua: - return None, [], [] - row = _combo_row(str(ua)) - if row is None: - return None, [], [] - whole = m01.loaded()[1] - found, missing = [], [] - for one in combo_store.mcb.items(row): - code = str(one.get("로직") or "") if isinstance(one, dict) else "" - try: - found.append(whole.logic(code)) - except combo_store.cm.mf.FormulaError: - missing.append(code) - return row, found, missing - - def inputs(key: str) -> dict[str, Any]: """25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`. - 일위대가 없는 항목은 로직 빈 목록.""" - ua = _item(key).get("일위대가") - row, logics, missing = combo_logics(ua) - whole = m01.loaded()[1] - out = [] - for logic in logics: - auto = m01.mau.values(whole, logic) - specs = [] - for spec in logic.get("입력") or []: - one = {k: spec[k] for k in _SPEC if k in spec} - one.setdefault("설명", None) - one.setdefault("단위", None) - specs.append({**one, "자동값": auto.get(spec["이름"])}) - out.append( - { - "로직": logic.get("키"), - "이름": logic.get("이름"), - "결과단위": logic.get("결과단위"), - "입력": specs, - } - ) - return _num( - { - "일위대가": ua or None, - "이름": row.get("이름") if row else None, - "로직": out, - "빠진로직": missing, - } - ) + 일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌).""" + return labor_inputs.inputs_of(_item(key).get("일위대가")) # ── 항목 연결 파일 ───────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 8b31ae48f..2b9b8a143 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -10,7 +10,7 @@ * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, @@ -18,28 +18,21 @@ import { getActiveLayer, readInputLinks, saveInputLinks, - type InputLinks, type InputSeat, - type ItemInput, type ItemInputs, - type ItemLogic, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, - choicesOf, countOf, inputKey, linesOf, - pickedGroups, pillsOf, - rangeOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, - type ValueDoc, } from "./M02_MasterTemplete_Inputs_Model"; import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; import "./M02_MasterTemplete_Inputs.css"; @@ -288,286 +281,10 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro } } -/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── - * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 - * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 - * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. - * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ - -export interface InputsValueData { - list: ItemInputs; - link: InputLinks | null; - values: ValueDoc; -} - -export interface InputsValueOptions { - mode: "view" | "value"; - /** mode "view" — [선택 입력 수정] */ - onEdit?: () => void; - /** mode "value" — 값이 바뀔 때마다 */ - onChange?: (values: ValueDoc) => void; -} - -export interface InputsValueHandle { - root: HTMLElement; - /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ - set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; - /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ - loading: () => () => void; - /** 지금 값(확인한 것 · 값이 든 것만) */ - getValues: () => ValueDoc; - /** 범위 밖 값이 없나 */ - isValid: () => boolean; - /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ - setEditLabel: (label: string) => void; -} - -const VALUE_TEXT = { - edit: "선택 입력 수정", - none: "선택으로 이은 입력이 없음", - auto: "자동값", - unconfirmed: "확인 안 함", - confirmed: "✓ 확인함", - confirmBox: "확인", - outOfRange: "범위 {value} 밖", - summary: "선택 {value}", - places: "면적산출 알약 {area} · 재료산출 알약 {material}", - noList: "입력 목록을 못 읽음 — {value}", -}; - -type ValueEntry = ValueDoc[string][string]; - -export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { - let data: InputsValueData | null = null; - let values: ValueDoc = {}; - let invalid = 0; - - const summary = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); - edit.hidden = opts.mode !== "view"; - if (opts.onEdit) edit.addEventListener("click", opts.onEdit); - const places = el("p", { className: "m02-inputval__places" }); - const body = el("div", { className: "m02-struct__sheet m02-inputval" }); - // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 - const shell = cardShell({ - title: TEXT.title, - sub: [summary], - body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), - stateHost: body, - }); - shell.tools.append(edit); - const root = shell.root; - - const groups = (): ReturnType => - data ? pickedGroups(data.list, data.link) : []; - const one = (logic: string, name: string): ValueEntry => - values[logic]?.[name] ?? { 값: null, 확인: false }; - const isEmpty = (value: string | number | null): boolean => value === null || value === ""; - const put = (logic: string, name: string, next: ValueEntry): void => { - (values[logic] ??= {})[name] = next; - }; - const autoOf = (input: ItemInput): string | number | null => { - const auto = input.자동값; - return typeof auto === "string" || typeof auto === "number" ? auto : null; - }; - - const paintSummary = (): void => { - let total = 0; - let confirmed = 0; - for (const { logic, inputs } of groups()) - for (const input of inputs) { - total += 1; - if (one(logic.로직, input.이름).확인) confirmed += 1; - } - summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; - }; - - /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ - const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { - const stored = one(logic.로직, input.이름); - const auto = autoOf(input); - const state = el("span", { className: "m02-inputval__state" }); - const paintState = (): void => { - const now = one(logic.로직, input.이름); - state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; - state.classList.toggle("is-confirmed", now.확인); - }; - if (opts.mode === "view") { - const text = isEmpty(stored.값) ? auto : stored.값; - const shown = el("span", { - className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [shown, state] }); - } - - const emit = (): void => { - paintState(); - paintSummary(); - opts.onChange?.(values); - }; - const range = rangeOf(input); - const choices = choicesOf(input); - const control = ( - choices.length - ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) - : el("input", { - className: "ui-input m02-inputval__control", - attrs: { - type: range ? "number" : "text", - ...(range?.min !== undefined ? { min: String(range.min) } : {}), - ...(range?.max !== undefined ? { max: String(range.max) } : {}), - ...(range ? { step: "any" } : {}), - placeholder: auto === null ? "" : String(auto), - }, - }) - ) as HTMLInputElement | HTMLSelectElement; - const error = el("span", { className: "m02-inputval__error" }); - const box = el("input", { - attrs: { - type: "checkbox", - title: VALUE_TEXT.confirmBox, - "aria-label": VALUE_TEXT.confirmBox, - }, - }) as HTMLInputElement; - - if (control instanceof HTMLSelectElement) { - control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`, - }), - ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), - ); - } - control.value = isEmpty(stored.값) ? "" : String(stored.값); - control.classList.toggle("is-auto", isEmpty(stored.값)); - box.checked = stored.확인; - - const outside = (raw: string): boolean => { - if (!range || raw.trim() === "") return false; - const value = Number(raw); - return ( - !Number.isFinite(value) || - (range.min !== undefined && value < range.min) || - (range.max !== undefined && value > range.max) - ); - }; - let wasBad = false; - const changed = (): void => { - const raw = control.value; - const bad = outside(raw); - if (bad !== wasBad) invalid += bad ? 1 : -1; - wasBad = bad; - control.setAttribute("aria-invalid", bad ? "true" : "false"); - error.textContent = bad - ? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) - : ""; - control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = - raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw); - // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) - put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); - box.checked = raw !== "" && !bad; - emit(); - }; - control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); - // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 - box.addEventListener("change", () => { - const now = one(logic.로직, input.이름); - if (box.checked && isEmpty(now.값) && auto !== null) { - control.value = String(auto); - control.classList.remove("is-auto"); - put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); - } else if (box.checked && isEmpty(now.값)) { - box.checked = false; // 받아들일 값이 없음 - } else { - put(logic.로직, input.이름, { ...now, 확인: box.checked }); - } - emit(); - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); - }; - - const paint = (note?: string, retry?: () => void): void => { - invalid = 0; - paintSummary(); - if (!data) { - places.textContent = ""; - if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); - return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); - } - const seats = [...seatsOf(data.link).values()]; - const area = seats.filter((seat) => seat === "면적").length; - const material = seats.filter((seat) => seat === "재료").length; - places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; - const shown = groups(); - const missing = data.list.빠진로직.length - ? [ - el("p", { - className: "m02-inputval__missing", - text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }), - }), - ] - : []; - if (!shown.length) { - body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); - return; - } - body.replaceChildren( - ...shown.map(({ logic, inputs }) => - el("section", { - className: "m02-inputval__logic", - attrs: { "data-logic": logic.로직 }, - children: [ - el("h4", { - className: "m02-inputval__logic-name", - text: logic.이름 || logic.로직, - attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, - }), - ...inputs.map((input) => - el("div", { - className: "m02-inputval__row", - attrs: { "data-input": inputKey(logic.로직, input.이름) }, - children: [ - el("span", { - className: "m02-inputval__name", - text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, - attrs: input.설명 ? { title: input.설명 } : {}, - }), - valueCell(logic, input), - ], - }), - ), - ], - }), - ), - ...missing, - ); - }; - - return { - root, - set: (next, note, retry) => { - data = next; - values = next ? structuredClone(next.values) : {}; - paint(note, retry); - }, - loading: () => shell.loading(), - getValues: () => { - // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) - const out: ValueDoc = {}; - for (const [logic, byName] of Object.entries(values)) - for (const [name, entry] of Object.entries(byName)) - if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; - return out; - }, - isValid: () => invalid === 0, - setEditLabel: (label) => { - (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; - }, - }; -} +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */ +export { + createInputsValueCard, + type InputsValueData, + type InputsValueHandle, + type InputsValueOptions, +} from "./M02_Labor_Values"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts index a6ab719f2..bbdb8fc53 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Inputs_Model.ts - * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글. + * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록. + * 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄). * 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` · * 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음. * ========================================================================== */ @@ -78,62 +79,11 @@ export function pillsOf( return out; } -const text = (value: unknown): string => - value === null || value === undefined - ? "" - : typeof value === "object" - ? JSON.stringify(value) - : String(value); - -/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ -export function valueHint(input: ItemInput): string { - if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); - const range = input.범위; - if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; - if (range && typeof range === "object") { - const r = range as Record; - const lo = r.최소 ?? r.min; - const hi = r.최대 ?? r.max; - if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; - } - return text(range); -} - -/** 자동값 글 — 없으면 빈 글 */ -export const autoText = (input: ItemInput): string => text(input.자동값); +/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */ +export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model"; /* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */ -/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ -export type ValueDoc = Record< - string, - Record ->; - -const number = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ -export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { - const raw = input.범위; - if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; - if (raw && typeof raw === "object") { - const r = raw as Record; - return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; - } - return null; -} - -/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ -export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { - return (input.고르기 ?? []).map((one) => { - const object = - typeof one === "object" && one !== null ? (one as Record) : null; - const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; - return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; - }); -} - /** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */ export function pickedGroups( list: ItemInputs, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..0944f8d6d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos @@ -230,3 +231,9 @@ def get_logic(key: str) -> dict: def get_logic_auto(key: str) -> dict: """견본 입력 — 미리 보기용 자동값.""" return _read(m01.auto, key) + + +@router.get("/logic-lib/inputs", dependencies=[Depends(verify_session)]) +def get_logic_inputs(keys: str = "") -> dict: + """일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`).""" + return _read(labor_inputs.logic_inputs, keys.split(",")) diff --git a/resources/tester/helper_m02_inputs.cjs b/resources/tester/helper_m02_inputs.cjs index 6f014a6a1..1e14e5c27 100644 --- a/resources/tester/helper_m02_inputs.cjs +++ b/resources/tester/helper_m02_inputs.cjs @@ -4,13 +4,18 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; +// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일 +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const local = (request) => + request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request); + new Function("exports", "module", "require", js)(box.exports, box, local); + return box.exports; +} +const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts")); // 같은 이름(높이)의 입력이 두 로직에 있음 const list = { diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index f5a58dfe4..e0b785b12 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -137,11 +137,14 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): def test_선택_입력_카드는_sub2_파일_한_벌(out): - """두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일).""" + """두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은 + 값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄.""" assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists() inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8") + values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8") assert "export function createInputsCard(" in inputs # M02 자리 고르기(link) - assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value) + assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value) + assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): text = (UI / name).read_text(encoding="utf-8") assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name @@ -183,6 +186,8 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_ItemSource.ts", M02 / "M02_MasterTemplete_Inputs.ts", M02 / "M02_MasterTemplete_Inputs_Model.ts", + M02 / "M02_Labor_Values.ts", + M02 / "M02_Labor_Values_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 8501c9945..f0070d654 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -44,8 +44,8 @@ def test_네_카드가_틀을_씀_수정_단추_한_모양(): assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards - inputs = _text("M02_MasterTemplete_Inputs.ts") - assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드 + inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts") + assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로) assert "shell.fail(" in inputs and "shell.loading()" in inputs assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음 drawing = _text("M02_MasterTemplete_DrawingCard.ts") diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py new file mode 100644 index 000000000..1757129cb --- /dev/null +++ b/resources/tester/test_m02_labor_values.py @@ -0,0 +1,101 @@ +"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델). + +못박는 것 + - 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」. + - 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`). + - 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 · + 모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만). +화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router + +ROOT = Path(__file__).resolve().parents[2] +A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비 + + +@pytest.fixture() +def client() -> TestClient: + app = FastAPI() + app.include_router(combos_router.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"} + return TestClient(app) + + +def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None: + res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"}) + assert res.status_code == 200, res.text + got = res.json() + assert [x["로직"] for x in got["로직"]] == [B, A] + assert got["빠진로직"] == ["GF999999"] + one = got["로직"][1] + assert one["이름"] and one["결과단위"] + assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"]) + # 옛 선택 입력 목록과 같은 몸 + assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"] + + +def test_빈_키는_빈_목록(client: TestClient) -> None: + got = client.get("/api/m02/logic-lib/inputs").json() + assert got == {"로직": [], "빠진로직": []} + + +SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; +const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 }; +const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] }; +const size = { 이름: "규격", 고르기: [25, 40] }; +const area = { 이름: "지역" }; +const inputs = [height, kind, size, area]; +console.log(JSON.stringify({ + stored: [ + m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"), + m.storedOf(size, "40"), m.storedOf(area, "서울"), + ], + outside: [ + m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""), + m.isOutside(height, "abc"), m.isOutside(area, "9"), + ], + known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }), + line: m.valueLine(inputs, { 높이: 2, 규격: 40 }), + lineEmpty: m.valueLine(inputs, {}), +})); +""" + + +def test_값_셈() -> None: + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + out = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + assert got["stored"] == [None, 2.5, "막돌", 40, "서울"] + assert got["outside"] == [True, False, False, True, False] + assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]} + assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음 + assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님