refactor(M02): 선택 입력 지움(39-7b G3) — 카드 · 자리 고르기 · inputs/ 연결 · 입력 목록 · 연결 · 알약 길 · 프로젝트 복사본 「입력연결」 · 시스템 층 inputs · 시험

- 넣을 값 카드(M02_Labor_Values)는 담은 로직 입력 전부를 보임(자리 「선택」 감싸기 없앰) · 이름 「넣을 값」
- 고르기 모달 면적산출 알약 = 복사본 일위대가 알약(가져온 때 그대로)
- 입력값.json · 고른자재.json · /calc 는 그대로 · storage 층 inputs/ 파일은 안 건드림(읽기만 끊음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-04 19:36:46 +09:00
co-authored by Claude Opus 5.5
parent 79e4bba1bb
commit c704f18982
37 changed files with 112 additions and 1549 deletions
+1 -17
View File
@@ -39,7 +39,6 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
)
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
LINK_FILE,
PRICE_FILE,
build_source,
read_json,
@@ -50,14 +49,12 @@ from M02_MasterTemplete.M02_Item_Source import (
KINDS = {
"도면": "도면.json",
"면적": "면적.json",
"입력연결": LINK_FILE,
"입력값": VALUES_FILE,
"고른자재": PICKS_FILE,
"일위대가": LABOR_FILE, # 읽기만 — 일위대가는 원본 층에서 고침(39-1c)
}
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출)
SKELETON = {"도면": "structure", "면적": "area"}
SEATS = ("면적", "재료", "선택", None)
_MT = re.compile(r"^MT\d{6}$")
@@ -158,17 +155,6 @@ def _check_picks(content: Any) -> None:
raise ValueError(f"고른자재 「{logic}」 {at} → 「{key}」 — 차례는 수 · 값은 MT 키")
def _check_links(content: Any) -> None:
rows = content.get("줄") if isinstance(content, dict) else None
if not isinstance(rows, list):
raise ValueError("입력연결은 {판, 줄: [...]} 이어야 합니다.")
for row in rows:
if not isinstance(row, dict) or not row.get("로직") or not row.get("입력"):
raise ValueError("입력연결 줄은 {로직, 입력, 자리, 알약?} 이어야 합니다.")
if row.get("자리") not in SEATS:
raise ValueError(f"입력연결 자리 「{row.get('자리')}」 — 면적 · 재료 · 선택 · 빈 값")
def check(kind: str, content: Any) -> None:
if kind == "일위대가":
raise ValueError("일위대가는 원본(마스터 · 회사 · 개인) 층에서 고침 — 프로젝트는 넣을 값만")
@@ -176,8 +162,6 @@ def check(kind: str, content: Any) -> None:
_check_values(content)
elif kind == "고른자재":
_check_picks(content)
elif kind == "입력연결":
_check_links(content)
elif kind in SKELETON:
layers.check_skeleton(SKELETON[kind], content)
elif not isinstance(content, dict):
@@ -246,7 +230,7 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
"""재적용 — 원본(층 · 마스터)을 다시 가져옴 · 입력값 · 고른자재는 같은 키면 살림.
원본과 달라 덮인 파일(도면 · 면적 · 입력연결)은 `덮임` 에 알림."""
원본과 달라 덮인 파일(도면 · 면적)은 `덮임` 에 알림."""
target = folder_of(project_root, folder)
manifest = read_json(target / MANIFEST_NAME) or {}
values = read_json(target / VALUES_FILE)
+3 -7
View File
@@ -1,9 +1,9 @@
"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b).
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출(번호 · SS 키는 저장소가
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 입력연결을 담음 —
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적을 담음 —
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
`store.write` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
`store.write`) — 나무 고침(`M02_MasterTemplete_Tree`)과
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
"""
@@ -15,12 +15,11 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import LINK_FILE, read_json
from M02_MasterTemplete.M02_Item_Source import read_json
#: 복사본 파일 → 층 양식 종류
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"))
@@ -93,9 +92,6 @@ def create_item(
body = store._server_values(body)
with store._LOCK:
atomic_write_json(store._path(kind, new_id), body)
links = read_json(copy / LINK_FILE) if copy else None
if isinstance(links, dict) and links.get("줄"):
inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]})
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)}
+6 -11
View File
@@ -1,7 +1,7 @@
"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b).
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 ·
일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
일위대가 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`.
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
@@ -32,7 +32,6 @@ from common_util.common_util_summary_item import snapshot_folder_name
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
LINK_FILE,
PRICE_FILE,
build_source,
item_path,
@@ -236,28 +235,24 @@ def safe_sync(project_root: str | Path) -> None:
def complete_old(folder: Path) -> bool:
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 단가는 지금 원본에서 · 값은 빈 것.
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날)."""
manifest = read_json(folder / MANIFEST_NAME)
if not isinstance(manifest, dict):
return False
lacking = [
name
for name in (LINK_FILE, PRICE_FILE, VALUES_FILE, PICKS_FILE)
if not (folder / name).is_file()
name for name in (PRICE_FILE, VALUES_FILE, PICKS_FILE) if not (folder / name).is_file()
]
if not lacking and "원본판" in manifest:
return False
need_source = LINK_FILE in lacking or PRICE_FILE in lacking
need_source = PRICE_FILE in lacking
try:
source = build_source(manifest["원본"]) if need_source else {}
except LookupError:
source = {} # 원본이 지워짐 — 빈 연결 · 빈 단가로(계산은 멈춤으로 알림)
source = {} # 원본이 지워짐 — 빈 단가로(계산은 멈춤으로 알림)
for name in lacking:
atomic_write_json(
folder / name, source.get(name, {"판": None, "줄": []} if name == LINK_FILE else {})
)
atomic_write_json(folder / name, source.get(name, {}))
kept = {
name: read_json(folder / name)
for name in sorted(p.name for p in folder.glob("*.json"))
-17
View File
@@ -4,7 +4,6 @@
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
`입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) ·
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
@@ -24,7 +23,6 @@ TABLE_NAME = "구조물집계표"
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출)
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"))
LABOR_KIND = "labor" # 열 id → 일위대가 문서(39-1c) — 스냅샷은 `LABOR_FILE` 에 감싸서
LINK_FILE = "입력연결.json"
PRICE_FILE = "단가.json"
LABOR_FILE = "일위대가.json"
@@ -121,20 +119,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
}
def input_links(root: Path, column_id: str) -> dict[str, Any]:
"""층의 항목 연결 `inputs/<열id>.json`(sub4 `_Inputs.read_file`) · 없으면 빈 연결."""
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
if not column_id:
return {"판": None, "줄": []}
with store.at(root):
found = inputs.read_file(column_id)
if isinstance(found, dict) and isinstance(found.get("줄"), list):
return found
return {"판": None, "줄": []}
def build_source(item: dict[str, Any]) -> dict[str, Any]:
"""원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError."""
from M02_MasterTemplete import M02_Item_Prices as prices
@@ -158,7 +142,6 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
if labor is not None:
files[LABOR_FILE] = labor
files[LINK_FILE] = input_links(root, column_id)
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
wanted = logic_keys + [code for code in materials if code]
+3 -28
View File
@@ -1,9 +1,9 @@
"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김).
"""M02 일위대가 입력 목록(39-3).
담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은
그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 =
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과
옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)이
씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
"""
from __future__ import annotations
@@ -13,7 +13,6 @@ 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 = ("이름", "설명", "단위", "고르기", "범위")
@@ -41,17 +40,6 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
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]
@@ -79,16 +67,3 @@ 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,
}
)
-1
View File
@@ -19,7 +19,6 @@
min-width: 0;
}
.m02-inputval__places,
.m02-inputval__missing {
margin: 0;
color: var(--color-text-secondary);
+26 -25
View File
@@ -1,16 +1,22 @@
/* =============================================================================
* M02_Labor_Values.ts
* 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌:
* 넣을 값 칸(39-3) — 값 칸 규칙 한 벌:
* 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder).
* · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀)
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄)
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(담은 로직 입력 전부)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch";
import type { ItemInput, ItemInputs, ItemLogic } 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 {
choicesOf,
inputKey,
isOutside,
rangeOf,
storedOf,
type ValueDoc,
} from "./M02_Labor_Values_Model";
import "./M02_Labor_Values.css";
const fill = (text: string, values: Record<string, string | number>): string =>
@@ -82,20 +88,19 @@ export function createValueControl(
}
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
* 같은 카드 틀 · 같은 입력 목록에서 **값**을 받음: 담은 로직 입력 전부를
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
* mode "view" 보기만 + [넣을 값 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
export interface InputsValueData {
list: ItemInputs;
link: InputLinks | null;
values: ValueDoc;
}
export interface InputsValueOptions {
mode: "view" | "value";
/** mode "view" — [선택 입력 수정] */
/** mode "view" — [넣을 값 수정] */
onEdit?: () => void;
/** mode "value" — 값이 바뀔 때마다 */
onChange?: (values: ValueDoc) => void;
@@ -111,21 +116,20 @@ export interface InputsValueHandle {
getValues: () => ValueDoc;
/** 범위 밖 값이 없나 */
isValid: () => boolean;
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
/** [넣을 값 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
setEditLabel: (label: string) => void;
}
const VALUE_TEXT = {
title: "선택 입력",
title: "넣을 값",
missing: "빠진 로직 {value}",
edit: "선택 입력 수정",
none: "선택으로 이은 입력이 없음",
edit: "넣을 값 수정",
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
auto: "자동값",
unconfirmed: "확인 안 함",
confirmed: "✓ 확인함",
confirmBox: "확인",
summary: "선택 {value}",
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
summary: "넣을 값 {value}",
noList: "입력 목록을 못 읽음 — {value}",
};
@@ -140,20 +144,22 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
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 · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [넣을 값 수정] · 상태 글은 목록 칸에
const shell = cardShell({
title: VALUE_TEXT.title,
sub: [summary],
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
body: el("div", { className: "m02-inputval__wrap", children: [body] }),
stateHost: body,
});
shell.tools.append(edit);
const root = shell.root;
const groups = (): ReturnType<typeof pickedGroups> =>
data ? pickedGroups(data.list, data.link) : [];
/** 로직마다 묶은 입력 — 입력이 없는 로직은 뺌 */
const groups = (): { logic: ItemLogic; inputs: ItemInput[] }[] =>
(data?.list.로직 ?? [])
.map((logic) => ({ logic, inputs: logic.입력 }))
.filter((group) => group.inputs.length > 0);
const one = (logic: string, name: string): ValueEntry =>
values[logic]?.[name] ?? { 값: null, 확인: false };
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
@@ -239,14 +245,9 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
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
? [
+4 -1
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_Labor_Values_Model.ts
* 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김.
* 넣을 값 셈(화면 없음 · 39-3).
* 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 ·
* 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림).
* ========================================================================== */
@@ -32,6 +32,9 @@ export function valueHint(input: ItemInput): string {
export const autoText = (input: ItemInput): string => text(input.자동값);
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
/** 입력 키 `{로직키}|{입력이름}` — 같은 이름의 입력도 로직마다 따로 */
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
export type ValueDoc = Record<
string,
Record<string, { 값: string | number | null; 확인: boolean }>
@@ -298,15 +298,8 @@ export const fetchSources = (id: string): Promise<unknown> => call(`/projects/${
export const readFilled = (id: string, name: string): Promise<TemplateDoc> =>
call(`/projects/${enc(id)}/tables/${enc(name)}/filled`);
/* --- 선택 입력 (PLAN 25-1b) — 항목(집계표 열 하나) 일위대가의 로직별 입력 목록 · 입력 자리 연결 ---
* 길은 층 항목 길(`/layers/{층}/items/{SS 키}/…`) · 층 폴더 저장은 연결만(값은 안 받음). */
/** 층 항목 목록 한 줄 — 열 id ↔ SS 키 */
export interface ItemRow {
key: string;
열: string;
일위대가: string | null;
}
/* --- 넣을 값 입력 목록(39-3) — 일위대가 담은 로직마다 입력(고르기 · 범위 · 자동값) ---
* 길은 `M02_MasterTemplete_Labor_Api` 의 `fetchLogicInputs` · 모양만 여기 */
/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */
export interface ItemInput {
@@ -332,42 +325,6 @@ export interface ItemInputs {
빠진로직: string[];
}
/** 입력 자리 — 면적산출 알약 · 재료산출 알약 · 선택 컨테이너 · 안 씀(null) */
export type InputSeat = "면적" | "재료" | "선택";
export interface InputLine {
로직: string;
입력: string;
자리: InputSeat | null;
알약?: string;
}
export interface InputLinks {
판: string | null;
줄: InputLine[];
}
const itemPath = (layer: Layer, key: string, tail: string): string =>
owned(layer, `/layers/${layer}/items/${enc(key)}/${tail}`);
export const fetchItemRows = (layer: Layer): Promise<ItemRow[]> =>
call<ItemRow[]>(owned(layer, `/items?layer=${layer}`));
export const fetchItemInputs = (layer: Layer, key: string): Promise<ItemInputs> =>
call(itemPath(layer, key, "inputs"));
export const readInputLinks = (layer: Layer, key: string): Promise<InputLinks> =>
call(itemPath(layer, key, "input-links"));
/** 판이 다르면 409(`StaleError`) · 없는 키는 422 */
export const saveInputLinks = (
layer: Layer,
key: string,
판: string | null,
줄: InputLine[],
): Promise<{ 판: string }> =>
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
/* --- 구조물 라이브러리(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
export interface TreeItem {
key: string;
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_CardShell.ts
* 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 선택 입력(관리 화면 · 고르기 모달)이
* 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 넣을 값(관리 화면 · 고르기 모달)이
* 내용만 넣음. 모양 · 높이 · 스크롤은 `M02_MasterTemplete_Structure.css`.
* 머리 = 제목 줄(h3 · 제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 넘치면 말줄임)
* 본문 = 카드 높이를 채움 · 넘치면 카드 안 스크롤 · 상태 = 불러오는 중 · 실패 + [다시 불러오기] · 비었음
@@ -1,105 +0,0 @@
/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */
.m02-inputs__summary {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-12);
align-items: baseline;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-inputs__count {
color: var(--color-text);
font-weight: 600;
}
/* 카드 본문이 남는 높이를 채우고 넘치면 안에서 스크롤 */
.m02-inputs__body {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: var(--spacing-12);
min-height: var(--m02-struct-body-min);
min-width: 0;
overflow: auto;
padding: var(--spacing-8) 0;
}
.m02-inputs__logic {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-inputs__logic-title {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-8);
align-items: baseline;
margin: 0;
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
font-size: var(--text-body-sm);
overflow-wrap: anywhere;
}
.m02-inputs__logic-title small,
.m02-inputs__row small {
color: var(--color-text-muted);
font-size: var(--text-caption);
font-weight: 400;
}
.m02-inputs__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-8);
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) 0;
}
.m02-inputs__info {
display: flex;
flex: 1 1 12rem;
flex-wrap: wrap;
gap: 2px var(--spacing-8);
align-items: baseline;
min-width: 0;
overflow-wrap: anywhere;
}
.m02-inputs__name {
color: var(--color-text);
font-size: var(--text-body-sm);
}
.m02-inputs__desc,
.m02-inputs__hint {
flex-basis: 100%;
}
/* 자동값 — 흐리게(입력 값이 아니라 보기용 첫 선택지) */
.m02-inputs__auto {
flex-basis: 100%;
opacity: 0.6;
font-style: italic;
}
.m02-inputs__seat {
flex: none;
max-width: 100%;
padding: 4px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: var(--text-body-sm);
}
.m02-inputs__seat:disabled {
opacity: 0.7;
}
@@ -1,172 +0,0 @@
"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약.
항목 = 구조물집계표조합(SS) 한 줄 → 그 열의 일위대가(UA) 조합 → 담은 단가산출 로직마다 입력 전부.
로직마다 갈라 보이고 이름이 같아도 합치지 않음(로직마다 값이 다를 수 있음) · 입력 목록은 그 로직
줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만 · M01 조합 미리 보기와
같음) · 자동값 = `master_auto.values`(보기용 · 저장 안 함).
항목 연결 파일 = 층 폴더 `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` —
판은 저장마다 1 씩 늘고, 연결만 적음(값은 프로젝트 몫). 자리 면적 · 재료 = 그 시트의 알약
(키 `입력|{로직}|{입력}`) · 선택 = 「선택 입력」 카드 · null = 안 씀.
M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · 권한은 부르는 쪽 몫.
"""
from __future__ import annotations
import json
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_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 = "입력"
def pill_key(logic: str, name: str) -> str:
return f"{PILL_HEAD}|{logic}|{name}"
def _item(key: str) -> dict[str, Any]:
"""지금 층(`store.at`)의 항목 줄 — 없으면 404."""
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
if row is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
return row
def _combo_row(ua: str) -> dict[str, Any] | None:
return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
def inputs(key: str) -> dict[str, Any]:
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌)."""
return labor_inputs.inputs_of(_item(key).get("일위대가"))
# ── 항목 연결 파일 ─────────────────────────────────────
def read_file(column: str) -> dict[str, Any] | None:
"""지금 층의 `inputs/<열id>.json` 그대로 — 없으면 None(열 지우기 · 복사 · 스냅샷이 씀)."""
path = store.inputs_path(column)
return json.loads(path.read_bytes()) if path.is_file() else None
def write_file(column: str, doc: dict[str, Any]) -> None:
"""지금 층에 연결 파일을 그대로 씀(복사용 — 검사 · 판 비교 없음)."""
path = store.inputs_path(column)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, doc)
def _known(key: str) -> dict[str, set[str]]:
"""{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것."""
_, logics, _ = combo_logics(_item(key).get("일위대가"))
return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics}
def read_links(key: str) -> dict[str, Any]:
"""항목 연결 — 없으면 `{판: null, 줄: []}` · 조합이 바뀌어 사라진 로직 · 입력 줄은
`사라짐: true`."""
doc = read_file(_item(key)["열"]) or {"판": None, "줄": []}
known = _known(key)
rows = []
for row in doc.get("줄") or []:
gone = str(row.get("입력")) not in known.get(str(row.get("로직")), set())
rows.append({**row, "사라짐": True} if gone else row)
return {"판": doc.get("판"), "줄": rows}
def _clean_rows(key: str, rows: Any) -> list[dict[str, Any]]:
"""없는 로직 · 입력 · 모르는 자리 · 같은 입력 두 번은 422 · 알약 키는 서버가 붙임."""
if not isinstance(rows, list):
raise store.StoreError(422, "「줄」 이 목록 아님")
known = _known(key)
out, seen, errors = [], set(), []
for at, row in enumerate(rows, start=1):
if not isinstance(row, dict):
errors.append(f"줄{at} · 묶음 아님")
continue
logic, name, place = str(row.get("로직") or ""), str(row.get("입력") or ""), row.get("자리")
if logic not in known:
errors.append(f"줄{at} · 이 항목 일위대가에 없는 로직 「{logic}」")
elif name not in known[logic]:
errors.append(f"줄{at} · 로직 {logic} 에 없는 입력 「{name}」")
elif place not in PLACES:
errors.append(f"줄{at} · 모르는 자리 「{place}」 — 면적 · 재료 · 선택 · null")
elif (logic, name) in seen:
errors.append(f"줄{at} · 같은 입력 두 번 「{logic}|{name}」")
else:
seen.add((logic, name))
one: dict[str, Any] = {"로직": logic, "입력": name, "자리": place}
if place in ("면적", "재료"):
one["알약"] = pill_key(logic, name)
out.append(one)
if errors:
raise store.StoreError(422, {"errors": errors})
return out
def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]:
"""항목 연결 저장 — 받은 `판` 이 지금 판(없으면 null)과 다르면 409 · 저장마다 판 + 1."""
column = _item(key)["열"]
clean = _clean_rows(key, rows)
with store._LOCK:
have = (read_file(column) or {}).get("판")
if version != have:
raise store.StoreError(409, {"stale": [f"inputs/{column}"], "판": have})
doc = {"판": int(have or 0) + 1, "줄": clean}
write_file(column, doc)
return doc
# ── 알약 — 면적산출 · 재료산출 시트 ───────────────────────
def pills(key: str, place: str) -> dict[str, Any]:
"""그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_pills` · 권한 무관 읽기) + 자리를
그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` —
이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침)."""
if place not in ("면적", "재료"):
raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료")
ua = _item(key).get("일위대가")
quantity: list[dict[str, Any]] = []
if place == "면적" and ua and (combo := _combo_row(str(ua))):
quantity = combo_store.combo_pills(combo)["물량"]
_, logics, _ = combo_logics(ua)
by_key = {str(lg.get("키")): lg for lg in logics}
linked = []
for row in read_links(key)["줄"]:
if row.get("자리") != place or row.get("사라짐"):
continue
logic = by_key[str(row["로직"])]
spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"])
short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"]
linked.append(
{
"키": pill_key(row["로직"], row["입력"]),
"이름": f"{row['입력']} · {short}",
"단위": spec.get("단위"),
"로직": row["로직"],
"입력": row["입력"],
}
)
return _num({"물량": quantity, "입력": linked})
def copy_between(source: Path, target: Path, source_column: str, target_column: str) -> bool:
"""다른 층(또는 같은 층) 열의 연결 파일을 새 열로 — 판은 1 부터 · 없으면 아무것도 안 함."""
with store.at(source):
doc = read_file(source_column)
if doc is None:
return False
with store.at(target):
write_file(target_column, {"판": 1, "줄": doc.get("줄") or []})
return True
@@ -1,290 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Inputs.ts
* 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어
* 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름.
*
* · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로.
* · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b).
* · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄
* (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음).
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
import {
fetchItemInputs,
fetchItemRows,
getActiveLayer,
readInputLinks,
saveInputLinks,
type InputSeat,
type ItemInputs,
type ItemRow,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import {
autoText,
countOf,
inputKey,
linesOf,
pillsOf,
seatsOf,
SEAT_OPTIONS,
valueHint,
type SeatMap,
} from "./M02_MasterTemplete_Inputs_Model";
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
import "./M02_MasterTemplete_Inputs.css";
const TEXT = {
title: "선택 입력",
count: "로직 {logics} · 입력 {inputs}",
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
auto: "자동값",
noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴",
noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름",
unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴",
noLogic: "이 일위대가에 담긴 로직이 없음",
missing: "빠진 로직 {value}",
failed: "입력 목록을 못 받음 — {value}",
seat: "자리",
};
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const fill = (text: string, values: Record<string, string | number>): string =>
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
/** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */
interface Draft {
key: string;
판: string | null;
list: ItemInputs;
seats: SeatMap;
dirty: boolean;
}
export interface InputsCardOptions {
/** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */
isAdmin: boolean;
/** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */
onDirty: () => void;
}
export interface InputsCardHandle {
root: HTMLElement;
/** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */
show: (colId: string | null, combo: string | null) => Promise<void>;
/** 고른 게 저장 안 됐나 */
dirty: () => boolean;
/** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */
save: () => Promise<void>;
/** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */
invalidate: () => void;
destroy: () => void;
}
export function createInputsCard(opts: InputsCardOptions): InputsCardHandle {
const drafts = new Map<string, Draft>();
let rows: Promise<ItemRow[]> | null = null;
let seq = 0;
const summary = el("div", { className: "m02-inputs__summary" });
const body = el("div", { className: "m02-inputs__body" });
// 공용 카드 틀(31-7) — 부제 = 요약 · [저장]은 없음(집계표 [저장]과 함께) · 점(●) = 고른 것 저장 전
const shell = cardShell({ title: TEXT.title, className: "m02-inputs", sub: [summary], body });
const root = shell.root;
const clear = (): void => {
summary.replaceChildren();
body.replaceChildren();
};
const itemRows = (): Promise<ItemRow[]> =>
(rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => {
rows = null;
throw error;
}));
function paintSummary(draft: Draft): void {
const count = countOf(draft.list);
// 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐)
const listed = new Set(
draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))),
);
const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat);
const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length;
summary.replaceChildren(
el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }),
el("span", {
className: "m02-inputs__chosen",
text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }),
}),
);
}
function paint(draft: Draft): void {
paintSummary(draft);
const groups = draft.list.로직.map((logic) =>
el("div", {
className: "m02-inputs__logic",
attrs: { "data-logic": logic.로직 },
children: [
el("h4", {
className: "m02-inputs__logic-title",
children: [
el("span", { text: logic.이름 }),
...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []),
el("small", { className: "m02-inputs__logic-key", text: logic.로직 }),
],
}),
...logic.입력.map((input) => {
const key = inputKey(logic.로직, input.이름);
const select = el("select", {
className: "m02-inputs__seat",
attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` },
children: SEAT_OPTIONS.map((o) =>
el("option", { text: o.label, attrs: { value: o.value } }),
),
}) as HTMLSelectElement;
select.value = draft.seats.get(key) ?? "";
select.disabled = !opts.isAdmin;
select.addEventListener("change", () => {
if (select.value) draft.seats.set(key, select.value as InputSeat);
else draft.seats.delete(key);
draft.dirty = true;
paintSummary(draft);
shell.setDirty(true);
opts.onDirty();
});
const hint = valueHint(input);
const auto = autoText(input);
return el("div", {
className: "m02-inputs__row",
attrs: { "data-input": input.이름 },
children: [
el("div", {
className: "m02-inputs__info",
children: [
el("span", { className: "m02-inputs__name", text: input.이름 }),
...(input.단위 ? [el("small", { text: input.단위 })] : []),
...(input.설명
? [el("small", { className: "m02-inputs__desc", text: input.설명 })]
: []),
...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []),
...(auto
? [
el("small", {
className: "m02-inputs__auto",
text: `${TEXT.auto} ${auto}`,
}),
]
: []),
],
}),
select,
],
});
}),
],
}),
);
const missing = draft.list.빠진로직;
body.replaceChildren(
...(groups.length ? groups : [cardNote(TEXT.noLogic)]),
...(missing.length ? [cardNote(fill(TEXT.missing, { value: missing.join(", ") }))] : []),
);
}
async function show(colId: string | null, combo: string | null): Promise<void> {
const mine = ++seq;
clear();
shell.setDirty(!!colId && !!drafts.get(colId)?.dirty);
if (!colId) return;
const kept = drafts.get(colId);
if (kept) return paint(kept);
const stop = shell.loading(); // 0.3초 넘으면 「불러오는 중」(31-7 · 네 카드 같게)
try {
const layer: Layer = getActiveLayer();
const row = (await itemRows()).find((r) => r.열 === colId);
if (mine !== seq) return;
stop();
if (!row) return shell.note(combo ? TEXT.unsavedCombo : TEXT.noItem);
if (!row.일위대가) return shell.note(combo ? TEXT.unsavedCombo : TEXT.noCombo);
const [list, links] = await Promise.all([
fetchItemInputs(layer, row.key),
readInputLinks(layer, row.key),
]);
if (mine !== seq) return;
if (!list.일위대가) return shell.note(TEXT.noCombo);
const draft: Draft = {
key: row.key,
판: links.판,
list,
seats: seatsOf(links),
dirty: false,
};
drafts.set(colId, draft);
paint(draft);
} catch (error) {
// 실패 = 카드 안 까닭 + [다시 불러오기](31-7)
if (mine === seq)
shell.fail(fill(TEXT.failed, { value: why(error) }), () => void show(colId, combo));
} finally {
stop();
}
}
return {
root,
show,
dirty: () => [...drafts.values()].some((d) => d.dirty),
save: async () => {
const layer = getActiveLayer();
for (const draft of drafts.values()) {
if (!draft.dirty) continue;
const got = await saveInputLinks(
layer,
draft.key,
draft.판,
linesOf(draft.list, draft.seats),
);
draft.판 = got.판;
draft.dirty = false;
}
shell.setDirty(false);
},
invalidate: () => {
rows = null;
for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음
},
destroy: () => {
seq += 1;
root.remove();
},
};
}
/** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */
export async function inputPills(column: string, seat: "면적" | "재료"): Promise<LinkItem[]> {
const layer = getActiveLayer();
if (layer === "project") return [];
try {
const row = (await fetchItemRows(layer)).find((r) => r.열 === column);
if (!row?.일위대가) return [];
const [list, links] = await Promise.all([
fetchItemInputs(layer, row.key),
readInputLinks(layer, row.key),
]);
return pillsOf(list, links, seat);
} catch {
return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로
}
}
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */
export {
createInputsValueCard,
type InputsValueData,
type InputsValueHandle,
type InputsValueOptions,
} from "./M02_Labor_Values";
@@ -1,101 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Inputs_Model.ts
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
* 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄).
* 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` ·
* 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음.
* ========================================================================== */
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
import type {
InputLine,
InputLinks,
InputSeat,
ItemInput,
ItemInputs,
} from "./M02_MasterTemplete_Api_Fetch";
/** 화면 글 ↔ 자리 — 「안 씀」 = 빈 글 */
export const SEAT_OPTIONS: { value: "" | InputSeat; label: string }[] = [
{ value: "", label: "안 씀" },
{ value: "면적", label: "면적산출 알약" },
{ value: "재료", label: "재료산출 알약" },
{ value: "선택", label: "선택" },
];
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`;
/** 자리 고름 표 — 키 = 입력 키 · 값 = 자리(안 쓰면 키 없음) */
export type SeatMap = Map<string, InputSeat>;
export function seatsOf(links: InputLinks | null): SeatMap {
const out: SeatMap = new Map();
for (const line of links?.줄 ?? [])
if (line.자리) out.set(inputKey(line.로직, line.입력), line.자리);
return out;
}
/** 저장할 줄 — 자리를 고른 것만(없는 것 = 안 씀) · 면적 · 재료는 알약 키를 같이 */
export function linesOf(list: ItemInputs, seats: SeatMap): InputLine[] {
const out: InputLine[] = [];
for (const logic of list.로직)
for (const input of logic.입력) {
const seat = seats.get(inputKey(logic.로직, input.이름));
if (!seat) continue;
out.push({
로직: logic.로직,
입력: input.이름,
자리: seat,
...(seat === "선택" ? {} : { 알약: pillKey(logic.로직, input.이름) }),
});
}
return out;
}
export const countOf = (list: ItemInputs): { logics: number; inputs: number } => ({
logics: list.로직.length,
inputs: list.로직.reduce((sum, logic) => sum + logic.입력.length, 0),
});
/** 그 시트(면적산출 · 재료산출)의 알약 — 고른 자리가 그 시트인 입력만 · 「로직 이름 · 입력 이름」 */
export function pillsOf(
list: ItemInputs,
links: InputLinks | null,
seat: "면적" | "재료",
): LinkItem[] {
const seats = seatsOf(links);
const out: LinkItem[] = [];
for (const logic of list.로직)
for (const input of logic.입력) {
if (seats.get(inputKey(logic.로직, input.이름)) !== seat) continue;
out.push({
키: pillKey(logic.로직, input.이름),
이름: `${logic.이름} · ${input.이름}`,
...(input.단위 ? { 단위: input.단위 } : {}),
고정: true,
});
}
return out;
}
/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */
export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model";
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */
/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */
export function pickedGroups(
list: ItemInputs,
links: InputLinks | null,
): { logic: ItemInputs["로직"][number]; inputs: ItemInput[] }[] {
const picked = new Set(
[...seatsOf(links)].filter(([, seat]) => seat === "선택").map(([key]) => key),
);
return list.로직
.map((logic) => ({
logic,
inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))),
}))
.filter((group) => group.inputs.length > 0);
}
@@ -1,12 +1,12 @@
/* =============================================================================
* M02_MasterTemplete_ItemCards.ts
* 항목 카드 — 도면 정보 · 면적산출(보기만 + [수정]) + 덧붙는 카드(선택 입력 …).
* 항목 카드 — 도면 정보 · 면적산출(보기만 + [수정]) + 덧붙는 카드(넣을 값 …).
* M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`)에서 떼어 냄(계획서 25-2d) — 항목 고르기
* 모달(`ui_template_item_picker_detail.ts`)이 **같은 카드**를 쓰려고. 문서를 어디서 읽는지는
* 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨.
* 카드 겉 틀 · 제목 줄 · 부제 · [저장] · ? · 점(●) · 상태 글은 공용 틀(`M02_MasterTemplete_CardShell` · 31-7).
* 면적산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임).
* 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_Inputs.ts`)가 그 자리.
* 덧붙는 카드는 `extraCards` 로 끼움 — 고르기 모달 넣을 값 카드(`M02_Labor_Values.ts`)가 그 자리.
* `sheetEdit` 가 있으면 면적산출 카드를 미리보기에서 고침(31-3) — 스프레드시트 `preview`(메뉴 없이
* 수식 입력 줄 · 칸 고침 · 단축키) + 아래 알약 줄 + 카드 [저장] · 없으면 보기만(고르기 모달).
* ========================================================================== */
@@ -104,7 +104,7 @@ export interface ItemCardsOptions {
numberOf?: (kind: EditKind, column: string) => string | undefined;
/** 못 읽었을 때 카드에 까닭을 보일까 — 기본 보임(M02 는 번호가 있는 열만 · 없는 열은 「없음」) */
warnOnFail?: (column: string) => boolean;
/** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */
/** 카드 뒤에 덧붙는 카드(넣을 값 …) */
extraCards?: HTMLElement[];
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
onDirty?: (dirty: boolean) => void;
@@ -15,7 +15,6 @@ from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
@@ -132,8 +131,7 @@ def copy_item(
path: list[str] | None = None,
) -> dict[str, Any]:
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 ·
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 내용(알약 연결 포함) ·
항목 연결 복사 ·
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 내용(알약 연결 포함) 복사 ·
새 번호 · 새 SS 키 ·
열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 ·
붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`."""
@@ -175,5 +173,4 @@ def copy_item(
atomic_write_json(store._path(kind, new_id), body)
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
return {"key": ss["키"], "열": new_id, "이름": ss["이름"]}
@@ -27,7 +27,6 @@ from config.config_db import get_db_pool
from M02_MasterTemplete import M02_Item_Paste as paste_store
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
@@ -69,11 +68,6 @@ Session = dict[str, Any]
NO_COMPANY = access.NO_COMPANY
class InputLinksBody(BaseModel):
판: int | None = None
줄: list[Any] = []
class TreeBody(BaseModel):
"""26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`)."""
@@ -214,50 +208,6 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) ->
raise HTTPException(status_code=e.status, detail=e.detail) from e
# ── 항목 선택 입력(PLAN 25-1b · 25-2a) — 읽기 = 층 읽기 권한(시스템 층 = 로그인 누구나) ──
@router.get("/layers/{layer}/items/{key}/inputs")
def get_item_inputs(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""그 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값 — M01 길은 관리자 전용이라
일반 사용자는 이 길로 읽음."""
return _call(_root(session, layer, owner), inputs_store.inputs, key)
@router.get("/layers/{layer}/items/{key}/input-links")
def get_input_links(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return _call(_root(session, layer, owner), inputs_store.read_links, key)
@router.put("/layers/{layer}/items/{key}/input-links")
def put_input_links(
layer: Layer,
key: str,
body: InputLinksBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""본문 `{판, 줄}` — 판 다르면 409 · 없는 로직 · 입력 키 422 · 층 쓰기 권한."""
folder = _root(session, layer, owner, write=True)
return _call(folder, inputs_store.write_links, key, body.판, body.줄)
@router.get("/layers/{layer}/items/{key}/pills")
def get_item_pills(
layer: Layer,
key: str,
place: Literal["면적", "재료"],
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""면적산출 · 재료산출 시트 알약 — `{물량: [조합 물량], 입력: [그 시트로 이은 입력]}`."""
return _call(_root(session, layer, owner), inputs_store.pills, key, place)
# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ──
+2 -26
View File
@@ -104,16 +104,6 @@ def _path(kind: str, name: str) -> Path:
return root() / kind / f"{name}.json"
# 항목 연결(PLAN 25-1b) — 열 id 마다 `inputs/<열id>.json` · 종류 폴더(KINDS) 밖 · 읽기 · 쓰기는
# `M02_MasterTemplete_Inputs` 몫 · 열 지우기 · 분류 더하기 복사는 여기서 같이
INPUTS = "inputs"
def inputs_path(column: str) -> Path:
"""그 열의 항목 연결 파일 자리 — 이름 규칙은 종류 파일과 같음."""
return _path("structure", column).parent.parent / INPUTS / f"{column}.json"
def _info(kind: str, path: Path) -> dict[str, Any]:
raw = path.read_bytes()
stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
@@ -185,7 +175,6 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
for part in _KEY:
_ensure(part, column, source)
_refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
_copy_inputs(source, column)
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
prune_removed_structures()
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 칸
@@ -390,26 +379,13 @@ def copy_from(
return info
def _copy_inputs(source: str, column: str) -> None:
"""분류 더하기로 복사한 새 열 — 원본 열의 항목 연결을 판 1 로(이미 있으면 그대로)."""
have, got = inputs_path(column), inputs_path(source)
if have.is_file() or not got.is_file():
return
have.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(have, {"판": 1, "줄": json.loads(got.read_bytes()).get("줄") or []})
def prune_removed_structures() -> list[str]:
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 —
번호는 비워 둠(다른 열 번호를 안 당김)."""
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 —
번호는 비워 둠(다른 열 번호를 안 당김) · 옛 `inputs/` 는 안 건드림."""
keep = set(_column_ids())
gone: list[str] = []
codes: set[str] = set()
with _LOCK:
for path in sorted((root() / INPUTS).glob("[!.]*.json")):
if path.stem not in keep:
path.unlink()
gone.append(f"{INPUTS}/{path.stem}")
for kind, key, _, _ in PARTS:
for path in sorted((root() / kind).glob("[!.]*.json")):
if path.stem not in keep:
@@ -46,7 +46,6 @@ import {
} from "./M02_MasterTemplete_ItemCards";
import { layerSource } from "./M02_MasterTemplete_ItemSource";
import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick";
import { createInputsCard, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
import "./M02_MasterTemplete_Structure.css";
export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards";
@@ -311,8 +310,6 @@ export interface StructurePanelOptions {
onEdit: (column: string, label: string, kind: EditKind) => void;
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */
onDirty?: () => void;
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
keepColumn?: (colId: string) => void;
}
@@ -323,8 +320,6 @@ export interface StructurePanelHandle {
numbers: () => ColumnNumbers;
/** 번호 목록 다시 받기 */
reload: () => Promise<void>;
/** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */
saveInputs: () => Promise<void>;
/** 카드에 저장 안 한 고침(도면 31-4 · 면적산출 31-3) — 페이지가 떠날 때 함께 물음 */
dirty: () => boolean;
/** 고친 카드 저장(도면 · 엑셀) — 집계표 [저장] 이 카드를 다시 읽기 전에 부름(실패하면 던짐) */
@@ -345,10 +340,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
let column: string | null = null;
let columnLabel = "";
const inputs: InputsCardHandle = createInputsCard({
isAdmin: opts.isAdmin,
onDirty: () => opts.onDirty?.(),
});
// 카드 셋은 항목 카드 한 벌(`ItemCards`) — 여기서는 지금 층에서 열 하나를 읽어 넘기기만.
const cards = createItemCards({
source: layerSource,
@@ -357,7 +348,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
warnOnFail: (id) => !!numbers[id],
extraCards: [inputs.root],
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
onImport: (kind) => void pickCopy(kind, shown ?? undefined),
// 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
@@ -374,9 +364,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
shown = col;
column = col?.id ?? null;
columnLabel = col ? columnHead(col) : "";
if (!col) void inputs.show(null, null);
// 집계표에 지금 고른 일위대가(저장 전일 수 있음) — 카드가 「먼저 저장」 안내를 고름
else void inputs.show(col.id, (col as { 일위대가?: string | null }).일위대가 ?? null);
return cards.load(col ? { id: col.id, label: columnLabel } : null);
}
@@ -461,9 +448,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
numbers: () => numbers,
reload: async () => {
numbers = await fetchStructureNumbers().catch(() => ({}));
inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게
},
saveInputs: () => inputs.save(),
dirty: () => cards.dirty(),
saveCards: () => cards.saveAll(),
show,
@@ -474,7 +459,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
},
pick,
destroy: () => {
inputs.destroy();
cards.destroy();
},
};
@@ -238,7 +238,6 @@ export function createMain(
const made = createStructurePanel({
isAdmin,
confirmLeave,
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
onEdit: (column, label, kind) => {
@@ -485,7 +484,6 @@ export function createMain(
await panel.reload();
sheet.recalc();
panel.refresh(sheet.getDoc()?.열 ?? []);
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
}
showNotice([]);
showToast(L("M02_Saved"), "success");
@@ -10,7 +10,7 @@
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
종류 = 도면 · 면적 · 입력연결 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
`refresh` = 옛 이름(재적용).
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
"""
@@ -33,7 +33,7 @@ from M02_MasterTemplete.M02_Item_Source import item_name
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
Kind = Literal["도면", "면적", "입력연결", "입력값", "고른자재", "일위대가"]
Kind = Literal["도면", "면적", "입력값", "고른자재", "일위대가"]
class WriteBody(BaseModel):
@@ -1,95 +0,0 @@
{
"판": 3,
"줄": [
{
"로직": "GC000297",
"입력": "형식",
"자리": "선택"
},
{
"로직": "GC000297",
"입력": "두께",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "구분",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "믹서",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "지역",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "재료배합",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "골재최대치수",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "수중",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "모래상태",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자갈상태",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "모래단위중량",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자갈단위중량",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자재지역",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "포장두께",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "운반",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "운반감률",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "공급감률",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "종단경사",
"자리": "선택"
}
]
}
@@ -1,9 +1,9 @@
/* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와
* 선택 입력 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다.
* 넣을 값 셈(`M02_Labor_Values_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다.
* 서버 모양은 sub6 복사본 길(`M02_Project_Items_Router.py`) · sub4 입력 길 그대로. 결과는 JSON 한 벌.
* ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403)
* ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음)
* ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음
* ② 새로 가져오기 · 계산 한 줄
* ③ 값 셈: 범위 · 고르기 원래 모양
* ④ 일위대가(39-1c): 복사본 요약 · 원본 층 계산 요약 · 넣을 값 = 담은 로직 입력 전부
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
@@ -42,7 +42,7 @@ const reply = (status, body) => ({
});
/** 가짜 서버 — 복사본 폴더마다 부분 파일 · 판(글자) */
const state = { readOnly: false, calls: [], folders: {}, inputsHits: 0 };
const state = { readOnly: false, calls: [], folders: {} };
const versionOf = (content) => `v${JSON.stringify(content ?? null).length}`;
const INPUTS = {
@@ -82,10 +82,6 @@ globalThis.fetch = async (url, init = {}) => {
반영합: { 계: 1500 },
});
}
if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) {
state.inputsHits += 1;
return reply(200, INPUTS);
}
const m = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url);
if (!m) return reply(404, { detail: "Not Found" });
const [, , folderEnc, partEnc] = m;
@@ -173,7 +169,7 @@ globalThis.fetch = async (url, init = {}) => {
};
const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts"));
const model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
const model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_Labor_Values_Model.ts"));
const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner: null, ...over });
@@ -257,18 +253,12 @@ async function main() {
const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} });
out.laborValues = {
keys: state.logicKeys,
link: valueData.link.줄.map((l) => `${l.로직}|${l.입력}|${l.자리}`),
shown: model.pickedGroups(valueData.list, valueData.link).length,
logics: valueData.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]),
hasLink: "link" in valueData,
values: valueData.values,
};
// 입력 목록 — 같은 항목은 한 번만 받음
const first = await project.loadInputs(row());
await project.loadInputs(row());
out.inputsHits = state.inputsHits;
out.inputsLogics = first.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]);
// 카드 셈
// 값 셈
out.model = {
range: [
model.rangeOf({ 이름: "a", 범위: [0, 50] }),
@@ -276,22 +266,7 @@ async function main() {
model.rangeOf({ 이름: "c" }),
],
choices: model.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }),
groups: model
.pickedGroups(first, {
판: "x",
줄: [
{ 로직: "GA1", 입력: "지역", 자리: "선택" },
{ 로직: "GA1", 입력: "소량감률", 자리: "면적" },
{ 로직: "GA2", 입력: "지역", 자리: "선택" },
],
})
.map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]),
noLink: model.pickedGroups(first, null).length,
pills: model.pillsOf(
first,
{ 판: "x", 줄: [{ 로직: "GA1", 입력: "소량감률", 자리: "면적" }] },
"면적",
),
key: model.inputKey("GA1", "지역"),
};
return out;
}
-61
View File
@@ -1,61 +0,0 @@
/* M02 「선택 입력」 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`) 단위검증 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
// 상대 경로 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 = {
일위대가: "UA000001",
로직: [
{
로직: "GC1",
이름: "돌쌓기",
입력: [
{ 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 },
{ 이름: "종류", 고르기: ["막돌", "견치돌"] },
],
},
{ 로직: "GC2", 이름: "타설", 입력: [{ 이름: "높이", 범위: { 최소: 0, 최대: 3 } }, { 이름: "배합" }] },
],
빠진로직: [],
};
const seats = m.seatsOf({
판: "v1",
줄: [
{ 로직: "GC1", 입력: "높이", 자리: "면적", 알약: "입력|GC1|높이" },
{ 로직: "GC2", 입력: "높이", 자리: "면적" },
{ 로직: "GC2", 입력: "배합", 자리: "재료" },
{ 로직: "GC1", 입력: "종류", 자리: null },
{ 로직: "GC9", 입력: "없는것", 자리: "선택" },
],
});
const links = { 판: "v1", 줄: [...[...seats].map(([k, 자리]) => ({ 로직: k.split("|")[0], 입력: k.split("|")[1], 자리 }))] };
const pick = new Map(seats);
pick.set(m.inputKey("GC1", "종류"), "선택");
process.stdout.write(
JSON.stringify({
keys: [m.inputKey("GC1", "높이"), m.pillKey("GC2", "높이")],
seatKeys: [...seats.keys()],
count: m.countOf(list),
area: m.pillsOf(list, links, "면적").map((p) => [p.키, p.이름, p.단위 ?? null, p.고정]),
material: m.pillsOf(list, links, "재료").map((p) => [p.키, p.이름]),
lines: m.linesOf(list, pick),
none: m.linesOf(list, new Map()),
hints: list.로직.flatMap((l) => l.입력).map((i) => [m.valueHint(i), m.autoText(i)]),
seatOptions: m.SEAT_OPTIONS.map((o) => o.label),
}),
);
+4 -20
View File
@@ -2,7 +2,7 @@
복사본은 구조물마다(`templates/structures/{구조물 id}/` · 관은 `pipe@측점`).
- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
- 지정 → [저장] → 폴더에 새 파일(입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값
- 마스터 단가 한 줄을 바꿔도 프로젝트 계산 그대로 · 목록에 「마스터 바뀜」 · 새로 가져오기 뒤 바뀜
(입력값 · 고른자재는 같은 키면 살림)
@@ -109,7 +109,7 @@ def test_지정하면_새_파일까지_복사(world):
root = world["root"]
assert _save(root, [_pipe()])["만듦"] == [FOLDER]
folder = root / "templates/structures" / FOLDER
assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음
assert not (folder / "입력연결.json").exists() # 선택 입력 연결은 없앰
assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {}
price = _read(folder / "단가.json")
rows = {k: len(v.get("줄") or v.get("표")) for k, v in price.items()}
@@ -134,18 +134,6 @@ def test_지정하면_새_파일까지_복사(world):
]
def test_층_입력연결을_복사(world):
root, system = world["root"], world["system"]
(system / "inputs").mkdir()
link = {"판": "a1", "줄": [{"로직": "GF000176", "입력": "관경", "자리": "면적", "알약": "x"}]}
column = next(
r["열"] for r in _read(system / layers.COMBO_FILE)["줄"] if r["키"] == PIPE["key"]
)
(system / "inputs" / f"{column}.json").write_text(json.dumps(link, ensure_ascii=False), "utf-8")
_save(root, [_pipe()])
assert copies.read_file(root, FOLDER, "입력연결")["내용"] == link
def test_단가_묶음은_닫혀_있음(world):
"""조합마다 복사본 단가 묶음만으로 돌린 값 = 마스터로 돌린 값(자동값 · 고르기 끝값)."""
files, whole = prices.master()
@@ -263,7 +251,6 @@ def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world):
("입력값", {"GF000176": {"관경": 600}}),
("입력값", {"GF000176": {"관경": {"값": 1, "확인": "네"}}}),
("고른자재", {"GF000176": {"첫": "MT000001"}}),
("입력연결", {"줄": [{"로직": "GF000176", "입력": "관경", "자리": "어딘가"}]}),
("도면", {}),
):
with pytest.raises(ValueError):
@@ -303,7 +290,7 @@ def test_옛_복사본은_읽을_때_채움(world):
root = world["root"]
_save(root, [_pipe()])
folder = root / "templates/structures" / FOLDER
for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"):
for name in ("입력값.json", "고른자재.json", "단가.json"):
(folder / name).unlink()
manifest = _read(folder / "manifest.json")
for key in ("원본판", "가져온날"):
@@ -437,9 +424,6 @@ def test_재적용은_시트_고침을_덮고_알림_입력값은_살림(world):
def test_개인에_저장하면_개인_층에_항목(world):
root = world["root"]
_save(root, [_pipe()])
rows = [{"로직": "GF000176", "입력": "관경", "자리": "선택"}]
current = copies.read_file(root, FOLDER, "입력연결")
copies.write_file(root, FOLDER, "입력연결", current["판"], {"판": 3, "줄": rows})
made = copies.save_as(root, FOLDER, 3, 12, ["관공", "우리 현장"], "파형강관 매설")
assert made["summary_item"] == {
"layer": "personal",
@@ -454,7 +438,7 @@ def test_개인에_저장하면_개인_층에_항목(world):
copy = root / "templates/structures" / FOLDER
drawing = _read(folder / "structure" / f"{made['열']}.json")
assert drawing["도면"] == _read(copy / "도면.json")["도면"]
assert _read(folder / "inputs" / f"{made['열']}.json") == {"판": 1, "줄": rows}
assert not (folder / "inputs").exists() # 선택 입력 연결은 안 옮김
assert not list(folder.rglob("단가.json")) # 단가 묶음은 안 가져감
# 개인 층 항목을 다른 구조물에 지정하면 그 내용으로 복사본이 섬
assert _save_all(root, [_wall("b1", made["summary_item"])], [])["만듦"] == ["b1"]
+12 -24
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 선택 입력 카드 셈 · 한 벌 지킴.
"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 넣을 값 셈 · 한 벌 지킴.
① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(sub6 길 모양의 가짜 서버 fetch 대역)
본다: 목록 `{항목}` · 가져오기 POST · 읽기 `{종류, 판, 내용}` · 고침 PUT `{판, 내용}`(판 · 409 · 403 까닭 그대로) ·
새로 가져오기 · 계산 한 줄 · 입력 목록 한 번만 받음 · 카드 셈(범위 · 고르기 원래 모양 · 자리 「선택」).
새로 가져오기 · 계산 한 줄 · 값 셈(범위 · 고르기 원래 모양).
② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다.
"""
@@ -103,21 +103,11 @@ def test_회사_없음_409_는_서버_까닭_그대로(out):
assert out[key].startswith("회사부터 등록"), key
def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out):
assert out["inputsHits"] == 1
assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
def test_선택_입력_카드_셈(out):
def test_넣을_값_셈(out):
model = out["model"]
assert model["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None]
assert [c["raw"] for c in model["choices"]] == [15, "16", 18] # 저장은 원래 모양
assert model["groups"] == [
["GA1", ["지역"]],
["GA2", ["지역"]],
] # 같은 이름도 로직마다 · 「선택」 만
assert model["noLink"] == 0 # 연결이 없으면 아무것도 안 보임
assert model["pills"][0]["키"] == "입력|GA1|소량감률" # 25-1b 알약 키
assert model["key"] == "GA1|지역" # 같은 이름도 로직마다 따로
def test_모달은_M02_와_같은_카드_한_벌을_씀():
@@ -136,15 +126,15 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀():
assert drawing_card.count("mountDrawingTemplate(") == 1
def test_선택_입력_카드는_sub2_파일_한_벌(out):
def test_넣을_값_카드는_sub2_파일_한_벌(out):
"""두 벌 금지 — 모달의 값 받기 카드는 `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")
값 칸) · 옛 선택 입력(자리 고르기)은 없앰(39-7b)."""
for gone in ("M02_MasterTemplete_InputsCard.ts", "M02_MasterTemplete_Inputs.ts",
"M02_MasterTemplete_Inputs_Model.ts", "M02_MasterTemplete_Inputs.py"): # fmt: skip
assert not (M02 / gone).exists(), gone
values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8")
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value)
assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs
assert "seatsOf" not in values and "pickedGroups" not in values
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
text = (UI / name).read_text(encoding="utf-8") # 모달은 값 카드를 바로 받음(39-1c)
assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name
@@ -174,8 +164,8 @@ def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404)
values = out["laborValues"]
assert values["keys"] == ["GA1", "GA2"]
assert values["link"] == ["GA1|지역|선택", "GA1|소량감률|선택", "GA2|지역|선택"]
assert values["shown"] == 2 and values["values"] == {"GA1": {}}
assert values["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
assert values["hasLink"] is False and values["values"] == {"GA1": {}}
def test_모달_카드_차례_도면_일위대가_면적():
@@ -218,8 +208,6 @@ def test_소스가_700줄을_넘지_않음():
*sorted(UI.glob("ui_template_item_picker*.ts")),
M02 / "M02_MasterTemplete_ItemCards.ts",
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",
):
+2 -2
View File
@@ -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") + _text("M02_Labor_Values.ts")
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로)
inputs = _text("M02_Labor_Values.ts")
assert inputs.count("cardShell({") == 1 # 넣을 값 카드(선택 입력 카드는 없앰 · 39-7b)
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")
+1 -13
View File
@@ -227,19 +227,7 @@ def test_면적_알약이_개인_조합을_읽음(world):
got = me.get(f"{URL.format('personal')}/{up}/inputs").json()
assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"]
assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got
tree = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)},
).json()
column = tree["알림"]["열"]
me.post(
f"/api/m02/layers/personal/structures/{column}/use-combo",
json={"종류": "일위대가", "키": up},
)
key = next(i["key"] for i in tree["items"] if i["열"] == column)
pills = me.get(f"/api/m02/layers/personal/items/{key}/pills", params={"place": "면적"})
assert pills.status_code == 200, pills.text
assert [p["키"] for p in pills.json()["물량"]] == ["돌쌓기 찰쌓기|㎡"]
# 항목 알약 길(`/items/{key}/pills`)은 선택 입력과 같이 없앰(39-7b)
def test_담을_로직_읽기는_로그인이면_됨(world):
+1 -1
View File
@@ -80,7 +80,7 @@ def test_지운_뒤_항목_0_틀은_남음(empty):
combos = client.get("/api/m02/structure-combos").json()
assert combos["줄"] == [] and combos["다음"] == 52
assert len(combos["지운번호"]) == 4 # 견본에 세 파일이 있던 넷(rv · c4 · pp_len · ms)
for kind in ("structure", "area", "inputs"):
for kind in ("structure", "area"):
assert not list((system / kind).glob("*.json")), kind
-80
View File
@@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
"""M02 「선택 입력」 카드(PLAN 25-2c) 셈 회귀검증 — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
같은 이름의 입력도 로직마다 따로(키 `{로직}|{이름}`) · 처음은 모두 「안 씀」(저장 줄 없음) ·
면적 · 재료로 고른 입력만 그 시트 알약(`입력|{로직}|{이름}` · 「로직 이름 · 입력 이름」).
Node 헬퍼(`helper_m02_inputs.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..", "..")
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_inputs.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_키_모양(results):
assert results["keys"] == ["GC1|높이", "입력|GC2|높이"]
def test_자리는_고른_것만_안_쓰면_없음(results):
assert results["seatKeys"] == ["GC1|높이", "GC2|높이", "GC2|배합", "GC9|없는것"]
assert results["none"] == [] # 처음은 모두 안 씀 — 저장할 줄 없음
def test_로직_수_입력_수(results):
assert results["count"] == {"logics": 2, "inputs": 4}
def test_같은_이름_입력도_로직마다_따로_알약(results):
# 높이가 두 로직에 있어도 알약 둘 · 이름은 「로직 이름 · 입력 이름」 · 목록에서 못 뺌(고정)
assert results["area"] == [
["입력|GC1|높이", "돌쌓기 · 높이", "m", True],
["입력|GC2|높이", "타설 · 높이", None, True],
]
assert results["material"] == [["입력|GC2|배합", "타설 · 배합"]]
def test_저장_줄은_고른_것만_알약_키_함께(results):
assert results["lines"] == [
{"로직": "GC1", "입력": "높이", "자리": "면적", "알약": "입력|GC1|높이"},
{"로직": "GC1", "입력": "종류", "자리": "선택"}, # 선택은 알약 없음
{"로직": "GC2", "입력": "높이", "자리": "면적", "알약": "입력|GC2|높이"},
{"로직": "GC2", "입력": "배합", "자리": "재료", "알약": "입력|GC2|배합"},
]
def test_고르기_범위_자동값_글(results):
assert results["hints"] == [
["0.5 ~ 5", "1.2"],
["막돌 / 견치돌", ""],
["0 ~ 3", ""],
["", ""],
]
assert results["seatOptions"] == ["안 씀", "면적산출 알약", "재료산출 알약", "선택"]
def test_카드는_집계표_카드_옆_넷째_그리고_700줄_안():
page = os.path.join(ROOT, "M02_MasterTemplete")
src = open(os.path.join(page, "M02_MasterTemplete_Structure.ts"), encoding="utf-8").read()
assert "inputs.root" in src and "createInputsCard" in src
for name in os.listdir(page):
if name.endswith((".ts", ".py")):
n = len(open(os.path.join(page, name), encoding="utf-8").read().splitlines())
assert n <= 700, (name, n)
-240
View File
@@ -1,240 +0,0 @@
"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약 · 권한.
① 입력 목록은 시험 안 마스터 견본(`fixtures/m02_master_sample` 사본 · PLAN 26-5a)으로 —
일위대가 있는 항목 셋에서 로직 수 · 입력 수 · 자동값이 M01 조합 미리 보기가 쓰는 것(로직 줄
`입력` · `master_auto`)과 같은지 · 로직마다 갈라 합치지 않는지 · 일위대가 없는 항목은 빈 목록.
② 연결 파일 쓰기는 임시 storage(회사 · 개인 층) + 견본 시스템 집계표로만 — 판 409 · 없는 키 422 ·
사라짐 표시 · 알약 · 층 권한 · 열 지우기 · 항목 복사 · 분류 더하기 복사가 파일을 같이 다룸.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
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_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import copy_master_sample
from resources.tester.test_m02_structure import write_sample_table
TABLE = "table/구조물집계표"
UA = "UA000004" # 견본 항목 셋(rv · c4 · c1)이 쓰는 조합 — 담은 로직 여럿
OTHER_UA = "UA000007"
def _app(session: dict[str, Any]) -> TestClient:
app = FastAPI()
app.include_router(layers_router.router)
app.include_router(items_router.router)
app.dependency_overrides[verify_session] = lambda: session
return TestClient(app)
def _m01_view(ua: str) -> list[tuple[str, list[str], dict]]:
"""M01 조합 미리 보기가 그리는 것 — 담은 로직마다 (키, 입력 이름들, 자동값)."""
row = next(r for r in m01.read(combo_store.FILE)[0]["줄"] if r["키"] == ua)
whole = m01.loaded()[1]
out = []
for one in row["담은로직"]:
try:
logic = whole.logic(one["로직"])
except combo_store.cm.mf.FormulaError:
continue # 화면도 못 읽은 로직은 건너뜀
auto = inputs_store._num(m01.auto(one["로직"])["값"])
out.append((one["로직"], [s["이름"] for s in logic.get("입력") or []], auto))
return out
# ── ① 입력 목록 — 마스터 견본 사본 ─────────────────────
def test_입력_목록은_M01_조합_미리_보기와_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(store, "FOLDER", copy_master_sample(tmp_path / "master"))
client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False})
rows = [r for r in json.loads((store.FOLDER / store.COMBO_FILE).read_bytes())["줄"]]
with_ua = [r for r in rows if r.get("일위대가")]
picked, seen = [], set()
for r in with_ua: # 서로 다른 조합 셋
if r["일위대가"] not in seen:
picked.append(r)
seen.add(r["일위대가"])
if len(picked) == 3:
break
assert len(picked) == 3
for r in picked:
res = client.get(f"/api/m02/layers/system/items/{r['키']}/inputs")
assert res.status_code == 200, res.text # 일반 사용자 읽기
got = res.json()
want = _m01_view(r["일위대가"])
assert got["일위대가"] == r["일위대가"] and got["이름"]
assert [lg["로직"] for lg in got["로직"]] == [k for k, _, _ in want]
for logic, (_, names, auto) in zip(got["로직"], want):
assert [i["이름"] for i in logic["입력"]] == names # 로직마다 · 합치지 않음
assert {i["이름"]: i["자동값"] for i in logic["입력"]} == auto
assert all({"이름", "설명", "단위", "자동값"} <= set(i) for i in logic["입력"])
# 이름이 같은 입력이 두 로직에 있어도 두 번 나옴
names = [
i["이름"]
for r in with_ua
for lg in client.get(f"/api/m02/layers/system/items/{r['키']}/inputs").json()["로직"]
for i in lg["입력"]
]
assert len(names) > len(set(names))
no_ua = next(r for r in rows if not r.get("일위대가"))
empty = client.get(f"/api/m02/layers/system/items/{no_ua['키']}/inputs").json()
assert empty == {"일위대가": None, "이름": None, "로직": [], "빠진로직": []}
assert client.get("/api/m02/layers/system/items/SS999999/inputs").status_code == 404
# ── ② 연결 파일 — 임시 storage ─────────────────────────
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
store.renumber()
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
return {"client": _app(session), "session": session, "storage": storage}
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
world["session"].update(role=role, user_id=user)
return world["client"]
def _put_table(client: TestClient, layer: str, edit) -> None:
url = f"/api/m02/layers/{layer}/templates/{TABLE}"
got = client.get(url).json()
edit(got["문서"]["열"])
res = client.put(url, json={"판": got["판"], "문서": got["문서"]})
assert res.status_code == 200, res.text
def _col(cid: str, ua: str | None = UA) -> dict[str, Any]:
return {
"id": cid,
"머리": ["sub4 돌쌓기", cid, None, None],
"단위": "㎡",
"꼴": "수",
"일위대가": ua,
}
def _key(client: TestClient, layer: str, cid: str) -> str:
items = client.get(f"/api/m02/items?layer={layer}").json()
return next(r["key"] for r in items if r["열"] == cid)
def test_연결_저장_다시_읽기_판_422_사라짐_알약(world):
client = _as(world)
_put_table(client, "personal", lambda cols: cols.append(_col("sub4_a")))
key = _key(client, "personal", "sub4_a")
base = f"/api/m02/layers/personal/items/{key}"
listing = client.get(f"{base}/inputs").json()
first, second = listing["로직"][0], listing["로직"][1]
assert client.get(f"{base}/input-links").json() == {"판": None, "줄": []}
rows = [
{"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "면적"},
{"로직": second["로직"], "입력": second["입력"][0]["이름"], "자리": "재료"},
{"로직": first["로직"], "입력": first["입력"][1]["이름"], "자리": "선택"},
]
saved = client.put(f"{base}/input-links", json={"판": None, "줄": rows})
assert saved.status_code == 200, saved.text
assert saved.json()["판"] == 1
again = client.get(f"{base}/input-links").json()
assert again == saved.json()
assert again["줄"][0]["알약"] == f"입력|{first['로직']}|{first['입력'][0]['이름']}"
assert "알약" not in again["줄"][2] # 선택은 시트 알약이 아님
file = world["storage"] / "7" / "42" / "templates" / "inputs" / "sub4_a.json"
assert json.loads(file.read_text("utf-8")) == saved.json()
# 판 다르면 409 · 없는 키 · 모르는 자리 · 두 번은 422
assert client.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 409
for bad in (
[{"로직": "GF999999", "입력": "높이", "자리": "면적"}],
[{"로직": first["로직"], "입력": "없는입력", "자리": "면적"}],
[{"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "옆"}],
[rows[0], rows[0]],
):
res = client.put(f"{base}/input-links", json={"판": 1, "줄": bad})
assert res.status_code == 422, res.text
# 알약 — 면적 = 조합 물량 + 면적으로 이은 입력 · 재료 = 재료로 이은 입력만
area = client.get(f"{base}/pills?place=면적").json()
assert area["물량"] and [p["키"] for p in area["입력"]] == [again["줄"][0]["알약"]]
mat = client.get(f"{base}/pills?place=재료").json()
assert mat["물량"] == [] and [p["입력"] for p in mat["입력"]] == [second["입력"][0]["이름"]]
assert " · " in mat["입력"][0]["이름"] # 입력 · 로직 이름(합치지 않음)
# 일위대가가 바뀌면 옛 로직 연결은 읽을 때 사라짐 · 알약에서 빠짐
_put_table(client, "personal", lambda cols: cols[-1].update(일위대가=OTHER_UA))
gone = client.get(f"{base}/input-links").json()["줄"]
assert all(r.get("사라짐") for r in gone)
assert client.get(f"{base}/pills?place=면적").json()["입력"] == []
def test_권한_일반_사용자_읽기_쓰기(world):
admin = _as(world, "SYSTEM_ADMIN")
_put_table(admin, "system", lambda cols: cols.append(_col("sub4_sys")))
key = _key(admin, "system", "sub4_sys")
base = f"/api/m02/layers/system/items/{key}"
user = _as(world)
assert user.get(f"{base}/inputs").status_code == 200
assert user.get(f"{base}/input-links").status_code == 200
assert user.put(f"{base}/input-links", json={"판": None, "줄": []}).status_code == 403
assert _as(world, "SYSTEM_ADMIN").put(f"{base}/input-links", json={"줄": []}).status_code == 200
# 회사 층 — 같은 회사 일반 사용자 읽기 · 쓰기 403 · 회사 관리자 쓰기
_put_table(_as(world, "ADMIN"), "company", lambda cols: cols.append(_col("sub4_c")))
ckey = _key(_as(world), "company", "sub4_c")
cbase = f"/api/m02/layers/company/items/{ckey}"
assert _as(world).get(f"{cbase}/inputs").status_code == 200
assert _as(world).put(f"{cbase}/input-links", json={"줄": []}).status_code == 403
assert _as(world, "ADMIN").put(f"{cbase}/input-links", json={"줄": []}).status_code == 200
# 남의 개인 층은 못 읽음
assert _as(world).get(f"{cbase}/inputs?owner=9").status_code == 403
def test_열_지우기_항목_복사_분류_복사가_연결을_같이(world):
admin = _as(world, "ADMIN")
_put_table(admin, "company", lambda cols: cols.append(_col("sub4_c")))
key = _key(admin, "company", "sub4_c")
base = f"/api/m02/layers/company/items/{key}"
logic = admin.get(f"{base}/inputs").json()["로직"][0]
rows = [{"로직": logic["로직"], "입력": logic["입력"][0]["이름"], "자리": "면적"}]
assert admin.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 200
# 항목 개인 복사 — 새 열에 연결이 판 1 로
user = _as(world)
made = user.post(
"/api/m02/layers/personal/items/copy", json={"from_layer": "company", "key": key}
).json()
mine = user.get(f"/api/m02/layers/personal/items/{made['key']}/input-links").json()
assert mine["판"] == 1 and [r["입력"] for r in mine["줄"]] == [rows[0]["입력"]]
# 분류 더하기(본뜬열) — 새 열이 원본 열 연결을 받음
admin = _as(world, "ADMIN")
twin = {**_col("sub4_c2", None), "본뜬열": "sub4_c"}
_put_table(admin, "company", lambda cols: cols.append(twin))
folder = world["storage"] / "7" / "templates"
with store.at(folder):
assert inputs_store.read_file("sub4_c2")["줄"] == inputs_store.read_file("sub4_c")["줄"]
# 열 지우기 — 연결 파일도 같이 지움
_put_table(
admin, "company", lambda cols: cols.remove(next(c for c in cols if c["id"] == "sub4_c"))
)
assert not (folder / "inputs" / "sub4_c.json").is_file()
assert (folder / "inputs" / "sub4_c2.json").is_file()
+1 -2
View File
@@ -109,8 +109,7 @@ def test_옛_SS_키는_새_모양으로_옮기고_옛_키로도_찾음(world):
migrated = json.loads((folder / store.COMBO_FILE).read_bytes())
assert [r["옛키"] for r in migrated["줄"]] == [["SS000001"], ["SS000003"]]
assert migrated["다음"] == 5
# 옛 키로도 찾음 — 입력 목록 · 나무 고침 · 복사본 원본 찾기(M02_Item_Source)
assert client.get("/api/m02/layers/personal/items/SS000003/inputs").status_code == 200
# 옛 키로도 찾음 — 나무 고침 · 복사본 원본 찾기(M02_Item_Source)
tree_ = Tree(client, "personal")
tree_.do(op="rename", 키="SS000001", 이름="a2")
assert tree_.item(["a2"])["key"] == "SP42-000001"
+2 -2
View File
@@ -2,7 +2,7 @@
못박는 것
- 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」.
- 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`).
- 입력 목록 몸 한 벌(`M02_Labor_Inputs`) · 옛 선택 입력은 없앰(39-7b).
- 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 ·
모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만).
화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만.
@@ -44,7 +44,7 @@ def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(clie
one = got["로직"][1]
assert one["이름"] and one["결과단위"]
assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"])
# 옛 선택 입력 목록과 같은 몸
# 입력 목록 몸 한 벌(M02_Labor_Inputs)
assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"]
+2 -4
View File
@@ -182,13 +182,11 @@ def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(wor
t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"])
assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]]
t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"])
# 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지
# 지우기 — 항목의 파일 · 마지막 항목까지
gone = t.item(["돌쌓기", "소단"])
(root / "inputs").mkdir(exist_ok=True)
(root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8")
note = t.do(op="delete", 키=gone["key"])["알림"]
assert note["지운항목"] == [gone["열"]]
for part in ("structure", "area", "inputs"):
for part in ("structure", "area"):
assert not (root / part / f"{gone['열']}.json").is_file()
t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌
assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"-000004"}
@@ -41,7 +41,7 @@ import {
type ProjectItems,
} from "./ui_template_item_picker_project";
/** 편집 화면을 여는 데 필요한 것 — 「선택 입력」 도 한 종류 */
/** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */
export interface EditRequest {
kind: EditKind | "inputs";
row: PickerRow;
@@ -85,10 +85,9 @@ const TEXT = {
changed: "마스터가 바뀜 — 복사본은 가져온 때 그대로",
gone: "원본이 없어짐 — 복사본은 그대로 씀",
reapply: "재적용",
reapplyTip:
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트 · 선택 입력 자리)이 원본 것으로 덮임",
reapplyTip: "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트)이 원본 것으로 덮임",
reapplyAsk:
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?",
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출이 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?",
reapplied: "재적용했음 — 덮인 것: {value}",
reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)",
reapplyFailed: "재적용하지 못함 — {value}",
@@ -113,7 +112,6 @@ const TEXT = {
parts: {
도면: "도면",
면적: "면적산출",
입력연결: "선택 입력 자리",
일위대가: "일위대가",
} as Record<string, string>,
};
@@ -367,7 +365,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
if (mine !== seq || !cards || !inputs || !labor) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "선택 입력 수정");
inputs.setEditLabel(label ?? "넣을 값 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
+15 -13
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* ui_template_item_picker_edit.ts
* 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜.
* 도면 = M02 CAD 부품 · 면적산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드.
* 도면 = M02 CAD 부품 · 면적산출 = M02 스프레드시트 · 넣을 값 = 값 받기 카드.
* 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜.
* [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음.
* 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임).
@@ -12,11 +12,11 @@ import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spre
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing";
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values";
import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { LaborPill, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { EditRequest } from "./ui_template_item_picker_detail";
import { itemName } from "./ui_template_item_picker_data";
import { laborValueData, loadInputs, PART_OF } from "./ui_template_item_picker_project";
import { laborValueData, PART_OF } from "./ui_template_item_picker_project";
export interface EditView {
root: HTMLElement;
@@ -36,11 +36,10 @@ const TEXT = {
loadFailed: "복사본을 못 읽음 — {value}",
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
outOfRange: "범위 밖 값이 있어 저장하지 못함",
noAreaPill:
"면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)",
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음",
drawing: "도면",
area: "면적산출",
inputs: "선택 입력",
inputs: "넣을 값",
suffix: "{value} 수정",
};
@@ -94,13 +93,16 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
body.replaceChildren(el("p", { className: "ui-item-picker__hint", text }));
};
/** 복사본의 항목 연결 — 자리가 면적인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */
const linkOf = async (): Promise<InputLinks | null> =>
(await items.read(info.폴더, "입력연결")) as InputLinks | null;
/** 복사본 일위대가의 알약(가져온 때 그대로) — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 */
async function sheetLinks(): Promise<LinkOptions> {
const [list, link] = await Promise.all([loadInputs(row), linkOf()]);
const 항목 = pillsOf(list, link, "면적");
const labor = (await items.read(info.폴더, "일위대가")) as { 알약?: LaborPill[] } | null;
const 항목 = (labor?.알약 ?? []).map((p) => ({
키: p.키,
이름: p.이름,
단위: p.단위,
...(p.자재 ? { 자재: p.자재 } : {}),
고정: true,
}));
if (!항목.length) note.textContent = TEXT.noAreaPill;
return { 항목, 모드: "입력" };
}
+5 -28
View File
@@ -10,22 +10,17 @@
* POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]}
* POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item}
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합)
* 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks`
* 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
fetchItemInputs,
type InputLinks,
type ItemInputs,
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
export type ProjectPart = "도면" | "면적" | "입력연결" | "입력값" | "고른자재" | "일위대가";
export type ProjectPart = "도면" | "면적" | "입력값" | "고른자재" | "일위대가";
/** 원본 종류 → 복사본 부분 */
export const PART_OF: Record<"structure" | "area", ProjectPart> = {
@@ -185,7 +180,6 @@ export function projectItems(projectId: string): ProjectItems {
const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({}));
if (reply.status >= 300) throw new Error(reasonOf(reply));
forget(info.폴더);
inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음
const got = reply.body as {
가져온날?: string | null;
원본판?: string | null;
@@ -241,20 +235,6 @@ export function projectItems(projectId: string): ProjectItems {
return client;
}
/* ── 입력 목록(원본 층 기준) — 같은 항목은 한 번만 받음 ───────────────────────── */
const inputsCache = new Map<string, Promise<ItemInputs>>();
/** 원본 항목의 입력 목록(로직마다 갈라 · 합치지 않음) — 못 받으면 다음에 다시 */
export function loadInputs(row: PickerRow): Promise<ItemInputs> {
const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
const kept = inputsCache.get(id);
if (kept) return kept;
const made = fetchItemInputs(layerOf(row), row.key);
inputsCache.set(id, made);
made.catch(() => inputsCache.delete(id));
return made;
}
/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */
/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */
@@ -306,15 +286,12 @@ export async function laborOf(
};
}
/** 넣을 값 카드 자료 — 담은 로직 입력 전부(자리 「선택」 으로 감싸 값 카드가 다 보임) */
/** 넣을 값 카드 자료 — 담은 로직 입력 전부 */
export async function laborValueData(
logics: string[],
values: ValueDoc,
): Promise<{ list: ItemInputs; link: InputLinks; values: ValueDoc }> {
): Promise<{ list: ItemInputs; values: ValueDoc }> {
const got = await fetchLogicInputs(logics);
const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 };
const 줄 = list.로직.flatMap((logic) =>
logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })),
);
return { list, link: { 판: null, 줄 }, values };
return { list, values };
}