Merge remote-tracking branch 'origin/dev' into sub_laptop_3

# Conflicts:
#	resources/master_data/_화면_계약.md
This commit is contained in:
2026-09-21 09:36:36 +09:00
22 changed files with 1997 additions and 897 deletions
+64 -3
View File
@@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Make as make
router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
@@ -39,6 +40,29 @@ class SaveBody(BaseModel):
files: list[FileChanges]
class NewLogic(BaseModel):
logic: dict[str, Any]
owner: str = make.OWNERS[0]
class CopyLogic(BaseModel):
key: str
이름: str = ""
owner: str = make.OWNERS[0]
class EditLogic(BaseModel):
key: str
version: str
logic: dict[str, Any]
owner: str = ""
class DeleteLogic(BaseModel):
key: str
version: str
def _num(v):
"""Decimal → JSON 수(그냥 두면 글자로 나감)."""
if isinstance(v, Decimal):
@@ -96,8 +120,9 @@ def get_tables(
page: int = 1,
size: int = 30,
usage: str = "",
section: str = "",
) -> dict:
return _call(store.tables, file, group, sub, detail, q, page, size, usage)
return _call(store.tables, file, group, sub, detail, q, page, size, usage, section)
@router.get("/table")
@@ -105,9 +130,25 @@ def get_table(file: str, key: str) -> dict:
return _call(store.table, file, key)
@router.get("/table/options")
def get_table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 줄을 통째로 받지 않음."""
return _call(make.table_options, file, key)
@router.get("/sections")
def get_sections(
book: str, division: str = "", chapter: str = "", q: str = "", limit: int = 200
) -> dict:
"""원문 절 목록 — 만들기 첫 걸음."""
return _call(make.sections, book, division, chapter, q, limit)
@router.get("/logics")
def get_logics(sub: str = "", detail: str = "", q: str = "", blocked: int | None = None) -> dict:
return {"logics": _call(store.logics, sub, detail, q, blocked)}
def get_logics(
sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = ""
) -> dict:
return {"logics": _call(store.logics, sub, detail, q, blocked, owner)}
@router.get("/logic")
@@ -147,3 +188,23 @@ def post_calc(body: CalcBody) -> dict:
@router.post("/save")
def post_save(body: SaveBody) -> dict:
return {"files": _call(store.save, [f.model_dump() for f in body.files])}
@router.post("/logic/new")
def post_logic_new(body: NewLogic) -> dict:
return _call(make.logic_new, body.logic, body.owner)
@router.post("/logic/copy")
def post_logic_copy(body: CopyLogic) -> dict:
return _call(make.logic_copy, body.key, body.이름, body.owner)
@router.post("/logic/edit")
def post_logic_edit(body: EditLogic) -> dict:
return _call(make.logic_edit, body.key, body.version, body.logic, body.owner)
@router.post("/logic/delete")
def post_logic_delete(body: DeleteLogic) -> dict:
return _call(make.logic_delete, body.key, body.version)
+47 -99
View File
@@ -21,7 +21,10 @@ sys.path.insert(0, str(SCRIPTS))
import check_master as cm # noqa: E402
import master_keys as mk # noqa: E402
from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402
FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈
DRAFT = "_초안.json" # 저장 안 한 새 로직을 얹는 메모리 자리 — 파일로 쓰지 않음
_CHAPTER = re.compile(r"_(\d\d)장_(.+)\.json$") # 「_03장_토공사.json」
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
@@ -235,6 +238,12 @@ _TABLE_HEADS = (
)
def in_section(number, section: str) -> bool:
"""원문번호가 그 절(또는 그 아래 절)인지 — 「13-4」 는 「13-4-1」 을 품고 「13-40」 은 아님."""
number = str(number or "")
return not section or number == section or number.startswith(section + "-")
def tables(
file: str = "",
group: str = "",
@@ -244,9 +253,10 @@ def tables(
page: int = 1,
size: int = 30,
usage: str = "",
section: str = "",
) -> dict:
"""표 목록 — 파일 하나 또는 그룹 전체(구분 · 상세구분 · 용도 대상 · 찾기로 거름 · 장 차례대로 · 쪽 나눔).
`usage` = 용도.대상(인력 · 기계 · 재료 · 할증 · 환산 · 작업량 조건) 하나."""
"""표 목록 — 파일 하나 또는 그룹 전체(장 차례대로 · 쪽 나눔).
거름 = 구분 · 상세구분 · 찾기 · `usage`(용도.대상 하나) · `section`(절 번호 · 아래 절 포함)."""
if file:
names = [file]
elif group in cm.mf.TABLE_GROUPS:
@@ -265,6 +275,8 @@ def tables(
continue
if usage and usage not in ((t.get("용도") or {}).get("대상") or []):
continue
if not in_section(t.get("원문번호"), section):
continue
if _hit(t, q):
hits.append(
{
@@ -302,8 +314,8 @@ def _logic_files(files: dict[str, dict]):
yield name, data
def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]:
"""로직 목록 — 구분 · 상세구분 · 찾기로 거름(장 차례대로 · 파일을 가로지름)."""
def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") -> list[dict]:
"""로직 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름(장 차례대로 · 파일을 가로지름)."""
files = cm.load(folder=FOLDER)
whole = cm.mf.Master(files)
out = []
@@ -311,6 +323,8 @@ def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]:
for row in data.get("줄") or []:
if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail):
continue
if owner and row.get("소유") != owner:
continue
if not _hit(row, q):
continue
reasons = cm.mf.check_logic(whole, row)[0]
@@ -321,7 +335,16 @@ def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]:
"file": name,
**{
k: row.get(k)
for k in ("키", "원문번호", "구분", "상세구분", "이름", "결과단위", "출처")
for k in (
"키",
"원문번호",
"구분",
"상세구분",
"이름",
"결과단위",
"출처",
"소유",
)
},
"blocked": bool(reasons),
"reasons": reasons,
@@ -488,6 +511,9 @@ def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
if str(old.get("키")) == key:
items[i] = {**row, "키": key}
return key
if file is None: # 저장 안 한 새 초안 — 메모리에만 얹고 셈(만들기 모달의 시험 계산)
file = DRAFT
files[file] = {"그룹": "로직", "원문": "자체", "줄": []}
if file not in files or files[file].get("그룹") != "로직":
raise StoreError(404, f"없는 로직 파일 「{file}」")
new = mk.table_id("로직", files[file].get("원문")) + "999999" # 아직 번호 안 받은 새 로직
@@ -549,6 +575,18 @@ def _follow(row: dict, items: list[dict], file: str) -> dict:
return {**row, "값": src["값"]}
def ledger_label(book: dict, tid: str, number: str, row: dict) -> str:
"""대장 열쇠 — 보통은 원문번호 · 자체 로직(GX)은 한 절에 여럿이라 「번호 이름」 으로 하나씩."""
if tid != "GX" or not number: # 원문번호 없는 줄은 빈 열쇠 — 대장이 늘 새 번호를 줌
return number
base = f"{number} {row.get('이름') or ''}".strip()
index, label, at = mk.lookup(book), base, 1
while (tid, label) in index:
at += 1
label = f"{base} ({at})"
return label
def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
items = data.setdefault(items_key(data), [])
@@ -570,7 +608,7 @@ def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
cols = _chapter_cols(file, data)
drop = ("키", "원문번호", *cols)
rest = {k: v for k, v in row.items() if k not in drop}
key = mk.issue(book, tid, number, file)
key = mk.issue(book, tid, ledger_label(book, tid, number, row), file)
# 자재품목처럼 줄에 원문번호를 두지 않는 테이블은 키만 — 번호는 대장에만
head = {"키": key} if tid in mk.NO_NUMBER else {"키": key, "원문번호": number}
items.append({**head, **cols, **rest})
@@ -604,101 +642,11 @@ def save(batch: list[dict]) -> list[dict]:
out = []
for name in dict.fromkeys(names):
old_text = (FOLDER / name).read_bytes().decode("utf-8")
text = keep_shape(old_text, before[name], after[name]) or dump(after[name])
text = keep_shape(old_text, before[name], after[name], items_key(after[name])) or dump(
after[name]
)
tmp = FOLDER / f".{name}.tmp"
tmp.write_text(text, encoding="utf-8", newline="\n")
os.replace(tmp, FOLDER / name)
out.append({"file": name, "version": version_of(text.encode("utf-8"))})
return out
# ── 쓰기 모양 ─────────────────────────────────────────────────────────
def _atom(v) -> str:
if isinstance(v, Decimal):
return str(v)
return json.dumps(v, ensure_ascii=False)
def _flat(v, tight: bool = False, inner: bool | None = None) -> str:
"""한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음)."""
inner = tight if inner is None else inner
if isinstance(v, dict):
if not v:
return "{}"
body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items())
return "{" + body + "}" if tight else "{ " + body + " }"
if isinstance(v, list):
return "[" + ", ".join(_flat(x, inner) for x in v) + "]"
return _atom(v)
def _skip(text: str, pos: int) -> int:
while text[pos] in " \t\r\n":
pos += 1
return pos
def keep_shape(old_text: str, before: dict, after: dict) -> str | None:
"""고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈).
머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump)."""
key = items_key(after)
head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731
old_items, new_items = before.get(key) or [], after.get(key) or []
if head(before) != head(after) or not old_items or not new_items:
return None
for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀
spans, pos, dec = [], m.end(), json.JSONDecoder()
try:
while old_text[pos := _skip(old_text, pos)] != "]":
_, end = dec.raw_decode(old_text, pos)
spans.append((pos, end))
pos = _skip(old_text, end)
pos += old_text[pos] == ","
except (IndexError, ValueError):
return None
if len(spans) == len(old_items):
break
else:
return None
nl = "\r\n" if "\r\n" in old_text else "\n"
lead = old_text[m.end() : spans[0][0]]
indent = len(lead) - len(lead.rstrip(" "))
olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)}
shape, parts = old_text[spans[0][0] : spans[0][1]], []
for row in new_items:
was, text = olds.get(str(row.get("키")), (None, None))
shape = text or shape
if was == row:
parts.append(text)
elif "\n" not in shape:
tight = shape.startswith('{"')
parts.append(_flat(row, tight, tight or ': {"' in shape))
else:
parts.append(dump(row, indent, expand=True).replace("\n", nl))
out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :]
same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after
return out if same else None
def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str:
"""UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침)."""
flat = _flat(v)
if (
not isinstance(v, (dict, list))
or not v
or (not expand and indent and len(flat) + indent <= width)
):
return flat + ("" if indent else "\n")
pad = " " * (indent + 2)
if isinstance(v, dict):
body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()]
text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}"
else:
text = (
"[\n"
+ ",\n".join(pad + dump(x, indent + 2, width) for x in v)
+ "\n"
+ " " * indent
+ "]"
)
return text + ("" if indent else "\n")
+239
View File
@@ -0,0 +1,239 @@
"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기.
계약: `resources/master_data/_화면_계약.md` 2장·5장 · 파일 모양: `_틀.md` 7장.
자체 로직은 `로직_자체.json` 한 파일 · 키는 `_키대장.json` 의 `GX` 다음 번호(서버가 줌).
"""
from __future__ import annotations
import re
from datetime import date
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
cm, mk = store.cm, store.mk
OWN = "로직_자체.json" # 자체 로직 한 자리 — 이 파일 줄만 고치고 지울 수 있음
OWNERS = ("현장", "공용")
BODY = mk._BODY # resources/knowledge/original/원가계산
BOOKS = {"산림품셈": "산림_표준품셈", "건설품셈": "건설공사_표준품셈"}
_HEAD = re.compile(r"^#{2,6}\s*(\d+(?:-\d+)*)\.?\s+(\S.*?)\s*$")
_CHAPTER_DIR = re.compile(r"^제(\d\d)장_(.+)$")
# ── 원문 절 목록 ───────────────────────────────────────────────────────
def _chapters(book: str):
"""(부문, 장 번호, 장 제목, 폴더) — 건설품셈은 부문 폴더 아래."""
root = BODY / BOOKS[book] / "본문"
tops = sorted(root.glob("*부문")) if book == "건설품셈" else [root]
for top in tops:
division = top.name.split("_", 1)[1].removesuffix("부문") if book == "건설품셈" else ""
for folder in sorted(top.glob("제*장_*")):
m = _CHAPTER_DIR.match(folder.name)
if m:
yield division, m.group(1), m.group(2).replace("_", " "), folder
_CACHE: dict[str, list[dict]] = {}
def _all_sections(book: str) -> list[dict]:
"""원문 본문 제목 줄에서 모은 절 — 한 번 세워 두고 다시 씀(원문은 안 바뀜)."""
if book in _CACHE:
return _CACHE[book]
out, seen = [], set()
for division, number, title, folder in _chapters(book):
for md in sorted(folder.glob("*.md")):
for line in md.read_text(encoding="utf-8").split("\n"):
m = _HEAD.match(line)
if not m or m.group(1).split("-")[0] != str(int(number)):
continue
if (division, m.group(1)) in seen:
continue
seen.add((division, m.group(1)))
out.append(
{
"book": book,
"division": division,
"chapter": f"{division} {number}장 {title}".strip(),
"chapterNo": number,
"section": m.group(1),
"title": m.group(2),
}
)
_CACHE[book] = out
return out
def _counts() -> dict[tuple[str, str], list[int]]:
"""(구분, 절 번호) → [표 수, 로직 수] — 아래 절까지 세도록 절 번호의 모든 앞마디에 얹음."""
out: dict[tuple[str, str], list[int]] = {}
for data in cm.load(folder=store.FOLDER).values():
group = data.get("그룹")
at = 0 if group in cm.mf.TABLE_GROUPS else 1 if group == "로직" else None
if at is None:
continue
for row in data.get(store.items_key(data)) or []:
number, sub = str(row.get("원문번호") or ""), str(row.get("구분") or "")
parts = number.split("-")
for upto in range(1, len(parts) + 1):
out.setdefault((sub, "-".join(parts[:upto])), [0, 0])[at] += 1
return out
def sections(book: str, division: str = "", chapter: str = "", q: str = "", limit: int = 200):
"""원문 절 목록 — 만들기 첫 걸음(절을 고르면 그 절의 표만 보임)."""
if book not in BOOKS:
raise store.StoreError(404, f"없는 원문 「{book}」")
if division and division not in cm.DIVISIONS:
raise store.StoreError(404, f"없는 부문 「{division}」")
counts, hits = _counts(), []
for one in _all_sections(book):
if division and one["division"] != division:
continue
if chapter and one["chapterNo"] != str(chapter).zfill(2):
continue
if q and q.lower() not in f"{one['section']} {one['title']}".lower():
continue
sub = " ".join(x for x in (book, one["division"]) if x)
tables, logics = counts.get((sub, one["section"]), [0, 0])
hits.append({**one, "tables": tables, "logics": logics})
return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]}
# ── 표 조건 값 ─────────────────────────────────────────────────────────
def table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안)."""
table = store.table(file, key)["table"]
conds = table.get("조건") or {}
values: dict[str, list] = {name: [] for name in conds}
for row in table.get("줄") or []:
for name in conds:
value = row.get(name)
if value is None or value in values[name]:
continue
values[name].append(value)
return {
"file": file,
"key": key,
"이름": table.get("이름"),
"조건": conds,
"범위규칙": table.get("범위규칙"),
"값칸": table.get("값칸") or {},
"count": len(table.get("줄") or []),
"값": values,
}
# ── 자체 로직 ──────────────────────────────────────────────────────────
def _own_path() -> Path:
return store.FOLDER / OWN
def _make_file() -> None:
"""자체 로직 파일이 없으면 머리만 두고 만듦."""
path = _own_path()
if path.is_file():
return
head = {"그룹": "로직", "원문": "자체", "판": str(date.today().year), "줄": []}
path.write_text(store.dump(head), encoding="utf-8", newline="\n")
def _find(key: str) -> tuple[str, dict]:
got = store.logic(key)
return got["file"], got["logic"]
def _own_only(file: str, key: str) -> None:
if file != OWN:
raise store.StoreError(403, f"정본 로직 「{key}」 은 못 고침 — 본뜬 뒤 고칠 것")
def _clean(logic: dict, owner: str, key: str | None = None) -> dict:
"""서버가 잡는 칸 — 키·소유·구분·상세구분. 화면이 보낸 것은 안 씀."""
if not isinstance(logic, dict) or not str(logic.get("이름") or "").strip():
raise store.StoreError(400, "로직 줄에 「이름」 이 없음")
if owner not in OWNERS:
raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}")
number = str(logic.get("원문번호") or "")
rest = {
k: v for k, v in logic.items() if k not in ("키", "원문번호", "구분", "상세구분", "소유")
}
head = {"키": key, "원문번호": number} if key else {"원문번호": number}
one = {**head, "구분": "자체", "상세구분": _chapter_of(logic), "소유": owner, **rest}
order = mk.ROW_ORDER["로직"] # 열 차례는 `_틀.md` 2장
return {
**{k: one[k] for k in order if k in one},
**{k: v for k, v in one.items() if k not in order},
}
def _chapter_of(logic: dict) -> str:
"""상세구분 — 원문 절이 속한 장(「13장 구조물」) · 원문 절이 없으면 빈 글."""
number = str(logic.get("원문번호") or "")
source = str(logic.get("출처") or "")
book = next((b for b in BOOKS if source.startswith(b)), "")
if not number or not book:
return ""
at = int(number.split("-")[0])
division = next((d for d in cm.DIVISIONS if source.startswith(f"{book} {d} ")), "")
for one_division, chapter, title, _ in _chapters(book):
if one_division == division and int(chapter) == at:
return f"{chapter}장 {title}"
return ""
def _write(changes: list[dict]) -> dict:
"""자체 파일 한 벌 저장 — 검사는 `/save` 와 같음(422 면 아무것도 안 씀)."""
fresh = not _own_path().is_file()
_make_file()
try:
out = store.save([{"file": OWN, "version": store.read(OWN)[1], "changes": changes}])
except store.StoreError:
if fresh:
_own_path().unlink(missing_ok=True)
raise
return out[0]
def _keys() -> list[str]:
if not _own_path().is_file():
return []
return [str(r.get("키")) for r in store.read(OWN)[0].get("줄") or []]
def logic_new(logic: dict, owner: str = OWNERS[0]) -> dict:
"""새 자체 로직 — 키는 서버가 `GX` 다음 번호로 줌."""
before = set(_keys())
done = _write([{"op": "add", "row": _clean(logic, owner)}])
key = next((k for k in reversed(_keys()) if k not in before), "")
return {**done, "key": key, "logic": store.logic(key)["logic"] if key else {}}
def logic_copy(key: str, 이름: str = "", owner: str = OWNERS[0]) -> dict:
"""본떠 만들기 — 정본도 본뜰 수 있음 · 비고 끝에 본뜬 키를 적음."""
_, row = _find(key)
note = " · ".join(x for x in (str(row.get("비고") or ""), f"{key} 를 본뜸") if x)
copy = {**row, "비고": note}
if 이름:
copy["이름"] = 이름
return logic_new(copy, owner)
def logic_edit(key: str, version: str, logic: dict, owner: str = "") -> dict:
file, row = _find(key)
_own_only(file, key)
if store.read(OWN)[1] != version:
raise store.StoreError(409, {"stale": [OWN]})
fixed = _clean(logic, owner or str(row.get("소유") or OWNERS[0]), key)
done = _write([{"op": "edit", "key": key, "row": fixed}])
return {**done, "logic": store.logic(key)["logic"]}
def logic_delete(key: str, version: str) -> dict:
file, _ = _find(key)
_own_only(file, key)
if store.read(OWN)[1] != version:
raise store.StoreError(409, {"stale": [OWN]})
return _write([{"op": "delete", "key": key}])
@@ -0,0 +1,100 @@
"""M01 저장 글 모양 — 고친 줄만 새로 쓰고 나머지는 옛 글 그대로(`_틀.md` 2장 · 들여쓰기 2칸).
`M01_MasterData_Store.save` 가 씀 — 파일을 통째로 다시 쓰면 손으로 맞춘 줄 모양이 사라짐.
"""
from __future__ import annotations
import json
import re
from decimal import Decimal
def _atom(v) -> str:
if isinstance(v, Decimal):
return str(v)
return json.dumps(v, ensure_ascii=False)
def _flat(v, tight: bool = False, inner: bool | None = None) -> str:
"""한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음)."""
inner = tight if inner is None else inner
if isinstance(v, dict):
if not v:
return "{}"
body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items())
return "{" + body + "}" if tight else "{ " + body + " }"
if isinstance(v, list):
return "[" + ", ".join(_flat(x, inner) for x in v) + "]"
return _atom(v)
def _skip(text: str, pos: int) -> int:
while text[pos] in " \t\r\n":
pos += 1
return pos
def keep_shape(old_text: str, before: dict, after: dict, key: str) -> str | None:
"""고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈).
머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump)."""
head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731
old_items, new_items = before.get(key) or [], after.get(key) or []
if head(before) != head(after) or not old_items or not new_items:
return None
for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀
spans, pos, dec = [], m.end(), json.JSONDecoder()
try:
while old_text[pos := _skip(old_text, pos)] != "]":
_, end = dec.raw_decode(old_text, pos)
spans.append((pos, end))
pos = _skip(old_text, end)
pos += old_text[pos] == ","
except (IndexError, ValueError):
return None
if len(spans) == len(old_items):
break
else:
return None
nl = "\r\n" if "\r\n" in old_text else "\n"
lead = old_text[m.end() : spans[0][0]]
indent = len(lead) - len(lead.rstrip(" "))
olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)}
shape, parts = old_text[spans[0][0] : spans[0][1]], []
for row in new_items:
was, text = olds.get(str(row.get("키")), (None, None))
shape = text or shape
if was == row:
parts.append(text)
elif "\n" not in shape:
tight = shape.startswith('{"')
parts.append(_flat(row, tight, tight or ': {"' in shape))
else:
parts.append(dump(row, indent, expand=True).replace("\n", nl))
out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :]
same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after
return out if same else None
def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str:
"""UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침)."""
flat = _flat(v)
if (
not isinstance(v, (dict, list))
or not v
or (not expand and indent and len(flat) + indent <= width)
):
return flat + ("" if indent else "\n")
pad = " " * (indent + 2)
if isinstance(v, dict):
body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()]
text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}"
else:
text = (
"[\n"
+ ",\n".join(pad + dump(x, indent + 2, width) for x in v)
+ "\n"
+ " " * indent
+ "]"
)
return text + ("" if indent else "\n")
@@ -0,0 +1,533 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Flow.ts
* 일위대가 로직 기본 보기 — 흐름 그림. 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 상자를 누르면 값·출처 · 원문 표 미리보기 · 쉬운 말 풀이(`…_Logic_Note.ts`) · 부르는 로직 펼치기.
* [고치기] 를 켜면 상자 안에서 바로 고치고 줄을 더하거나 지움 — 자체 로직만(정본은 「본떠 만들기」 뒤).
* 값은 모두 시험 계산(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음.
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_Flow_Model.ts`.
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type HoLine,
type LogicOne,
type LogicRow,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import {
buildFlow,
isMoney,
type FlowBox,
type FlowColumn,
type FlowKind,
} from "./M01_MasterData_UI_Logic_Flow_Model";
import { buildNote } from "./M01_MasterData_UI_Logic_Note";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tx, type TextKey } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Flow_Style.css";
const title = (kind: FlowKind): string => tx(`Col_${kind}` as TextKey);
/** 자체 로직인지 — 자체 파일이거나 키가 `GX…` · 아직 저장 안 한 새 줄(키 "")도 고칠 수 있음 */
export const isOwnLogic = (file: string, row: LogicRow): boolean =>
file.startsWith("로직_자체") || (row.키 ?? "").startsWith("GX") || (row.키 ?? "") === "";
export interface FlowContext {
one: LogicOne;
/** 지금 보는 줄 — 고친 것이 있으면 그 줄(`one.logic` 과 다른 객체일 수 있음) */
row: LogicRow;
/** 상자에서 고칠 수 있는지 — 자체 로직만 */
editable: boolean;
/** 고친 뒤 — 화면이 캐시에 담음 */
onChange?: () => void;
/** 「본떠 만들기」 — 정본 로직일 때만 */
onCopy?: () => void;
}
/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎 · 읽기 전용) */
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01c__muted", text: tx("Load_Failed") }));
void fetchLogic(logicKey)
.then((one) => mountFlow(host, { one, row: one.logic, editable: false }))
.catch((error: unknown) => {
host.replaceChildren(
el("p", {
className: "m01c__bad",
text: error instanceof Error ? error.message : tx("Load_Failed"),
}),
);
});
}
export function mountFlow(host: HTMLElement, ctx: FlowContext): void {
const one = ctx.one;
const row = ctx.row;
const values: Record<string, string> = {};
// 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠)
for (const spec of row.입력 ?? []) {
if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]);
}
const swaps: Swaps = new Map();
const picker = pickPanel({ ...one, logic: row }, swaps, () => run());
const open = new Set<string>();
const subs = new Map<string, LogicRow>();
let answer: CalcAnswer | null = null;
let editing = false;
let timer: number | undefined;
const rest = el("div", { className: "m01c__rest" });
const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } });
/** 고친 줄 — 화면에 알리고 계산을 다시(값 칸은 글쇠 자리를 지켜 늦춰 그림) */
const touched = (): void => {
ctx.onChange?.();
later();
};
/* ── 상자 ─────────────────────────────────────────────────────────── */
const boxView = (box: FlowBox): HTMLElement => {
const node = el("details", {
className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`,
children: [
el("summary", {
children: [
el("span", { className: "m01c__label", text: box.label }),
el("span", { className: "m01c__value", text: box.value }),
...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []),
],
}),
el("dl", {
className: "m01c__detail",
children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]),
}),
],
});
if (open.has(box.id)) node.open = true;
node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id)));
if (editing) node.append(...editView(box.id));
const expr = formulaOf(box);
if (expr) node.append(noteView(expr));
if (box.logic) node.append(subView(box.logic));
return node;
};
/** 이 상자가 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */
const formulaOf = (box: FlowBox): string => {
const [kind, at] = box.id.split(":");
const i = Number(at);
if (kind === "수량") return (row.호표 ?? [])[i]?.수량 ?? "";
if (kind === "중간") return (row.중간 ?? [])[i]?.식 ?? "";
if (kind === "덧줄") return (row.덧줄 ?? [])[i]?.식 ?? "";
return "";
};
/** 쉬운 말 풀이 + 원문 표 미리보기 — 펼칠 때 한 번만 채움(표는 서버에서 읽음) */
const noteView = (expr: string): HTMLElement => {
const body = el("div", { className: "m01c__note-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: tx("Note_Plain") }), body],
});
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
buildNote(body, {
expr,
middles: row.중간 ?? [],
ctx: { values, middle: answer?.ok ? (answer.middle ?? {}) : {} },
});
});
return node;
};
/** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */
const subView = (key: string): HTMLElement => {
const body = el("div", { className: "m01c__sub-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: `${tx("Flow_Sub")} · ${key}` }), body],
});
const fill = (sub: LogicRow): void => {
body.replaceChildren(
el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }),
el("p", { className: "m01c__muted", text: tx("Flow_SubLines") }),
el("ul", {
children: (sub.호표 ?? []).map((item) =>
el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }),
),
}),
);
};
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
const had = subs.get(key);
if (had) {
fill(had);
return;
}
body.replaceChildren(el("p", { className: "m01c__muted", text: tx("Note_Searching") }));
void fetchLogic(key)
.then((deep) => {
subs.set(key, deep.logic);
fill(deep.logic);
})
.catch(() =>
body.replaceChildren(el("p", { className: "m01c__bad", text: tx("Load_Failed") })),
);
});
return node;
};
/* ── 상자에서 고치기 ──────────────────────────────────────────────── */
/** 글 칸 하나 — 고치면 그 자리에서 줄을 바꾸고 계산만 다시(다시 그려도 글쇠 자리가 남게 `data-at`) */
const editBox = (at: string, label: string, value: string, set: (v: string) => void) => {
const input = el("input", {
className: "m01c__input",
attrs: { type: "text", "data-at": at },
});
input.value = value;
input.addEventListener("input", () => {
set(input.value);
touched();
});
return el("label", {
className: "m01c__edit",
children: [el("span", { className: "m01c__muted", text: label }), input],
});
};
const dropButton = (onClick: () => void): HTMLElement =>
createButton({
label: tx("DeleteRow"),
variant: "ghost",
onClick: () => {
onClick();
ctx.onChange?.();
draw();
},
});
const editView = (id: string): HTMLElement[] => {
const [kind, at] = id.split(":");
const i = Number(at);
if (kind === "입력") {
const spec = (row.입력 ?? [])[i];
if (!spec) return [];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Head_Name"), spec.이름, (v) => (spec.이름 = v)),
editBox(id + ":2", tx("Inputs_Unit"), spec.단위 ?? "", (v) => {
if (v.trim()) spec.단위 = v;
else delete spec.단위;
}),
editBox(id + ":3", tx("Inputs_Choices"), (spec.고르기 ?? []).join(", "), (v) => {
const items = v
.split(",")
.map((x) => x.trim())
.filter(Boolean);
// 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음
if (items.length)
spec.고르기 = items.every((x) => !Number.isNaN(Number(x)))
? items.map(Number)
: items;
else delete spec.고르기;
}),
dropButton(() => (row.입력 ?? []).splice(i, 1)),
],
}),
];
}
if (kind === "수량") {
const line = (row.호표 ?? [])[i];
if (!line) return [];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Ho_Element"), line.요소, (v) => (line.요소 = v)),
editBox(id + ":2", tx("Ho_Name"), line.이름 ?? "", (v) => (line.이름 = v)),
editBox(id + ":3", tx("Ho_Unit"), line.단위 ?? "", (v) => (line.단위 = v)),
editBox(id + ":4", tx("Edit_Qty"), line.수량, (v) => (line.수량 = v)),
dropButton(() => (row.호표 ?? []).splice(i, 1)),
],
}),
];
}
const list: NamedFormula[] | undefined =
kind === "중간" ? row.중간 : kind === "덧줄" ? row.덧줄 : undefined;
if (!list?.[i]) return [];
const item = list[i];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Head_Name"), item.이름, (v) => (item.이름 = v)),
editBox(id + ":2", tx("Formula"), item.식, (v) => (item.식 = v)),
dropButton(() => list.splice(i, 1)),
],
}),
];
};
/** 칸 아래 「줄 더하기」 — 고치기를 켰을 때만 */
const addButton = (kind: FlowKind): HTMLElement | null => {
if (!editing) return null;
const add = (label: string, push: () => void): HTMLElement =>
createButton({
label,
variant: "ghost",
onClick: () => {
push();
ctx.onChange?.();
draw();
},
});
if (kind === "입력")
return add(tx("Edit_AddInput"), () => void (row.입력 ??= []).push({ 이름: "" }));
if (kind === "중간")
return add(tx("Edit_AddMiddle"), () => void (row.중간 ??= []).push({ 이름: "", 식: "" }));
if (kind === "수량")
return add(
tx("Edit_AddHo"),
() =>
void (row.호표 ??= []).push({
종류: "인력",
요소: "",
이름: "",
단위: "인",
수량: "",
비목: "노무비",
} as HoLine),
);
if (kind === "덧줄")
return add(
tx("Edit_AddExtra"),
() => void (row.덧줄 ??= []).push({ 이름: "", 식: "", 비목: "재료비", 출처: "" }),
);
return null;
};
/* ── 칸·다시 그리기 ───────────────────────────────────────────────── */
const columnView = (column: FlowColumn): HTMLElement => {
const head = el("h4", { className: "m01c__col-head", text: title(column.kind) });
// 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」)
const costOf = (box: FlowBox): string => box.cost ?? tx("Flow_Other");
const costs = [...new Set(column.boxes.map(costOf))];
const body =
column.boxes.length > 6 && costs.length > 1 && !editing
? costs.map((cost) =>
el("details", {
className: "m01c__group",
attrs: { open: "" },
children: [
el("summary", { text: cost }),
...column.boxes.filter((b) => costOf(b) === cost).map(boxView),
],
}),
)
: column.boxes.map(boxView);
const add = addButton(column.kind);
return el("section", {
className: "m01c__col",
children: [head, ...body, ...(add ? [add] : [])],
});
};
/** 다시 그려도 적던 칸으로 돌아감 — `data-at` 이 같은 칸에 글쇠와 자리를 되돌림 */
const keepFocus = (paint: () => void): void => {
const was = document.activeElement as HTMLInputElement | null;
const at = was?.dataset?.at;
const start = at ? was?.selectionStart : null;
paint();
if (!at) return;
const next = host.querySelector<HTMLInputElement>(`[data-at="${CSS.escape(at)}"]`);
if (!next) return;
next.focus();
if (start !== null && start !== undefined) next.setSelectionRange(start, start);
};
/** 고치기를 켜면 비어 있어 빠진 칸(표 찾기·덧줄)도 세움 — 거기에 줄을 더할 수 있게 */
const withEmpty = (columns: FlowColumn[]): FlowColumn[] => {
if (!editing || !isMoney(row)) return columns;
const out = [...columns];
const put = (kind: FlowKind, before: FlowKind): void => {
if (out.some((c) => c.kind === kind)) return;
const at = out.findIndex((c) => c.kind === before);
out.splice(at < 0 ? out.length : at, 0, { kind, boxes: [] });
};
put("중간", "수량");
put("덧줄", "비목");
return out;
};
const redraw = (): void => {
keepFocus(() => {
const columns = withEmpty(buildFlow(row, answer, one.prices, values));
const nodes: HTMLElement[] = [];
for (const column of columns.slice(1)) {
nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column));
}
rest.replaceChildren(...nodes);
});
const reason = answer && !answer.ok ? answer.reason : "";
stopped.textContent = reason ? `${tx("Calc_Stopped")} — ${plainReason(reason)}` : "";
stopped.hidden = !reason;
};
const run = (): void => {
const inputs: Record<string, unknown> = {};
for (const spec of row.입력 ?? []) {
const raw = (values[spec.이름] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
const option = spec.고르기?.find((o) => String(o) === raw);
inputs[spec.이름] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
// 고친 줄·바꿔 본 재료는 그 줄을 같이 보냄 — 파일에 안 씀
const swapped = swappedRow(row, swaps);
const draft = swapped ?? (ctx.editable ? row : undefined);
void runCalc({ key: row.키, inputs, ...(draft ? { row: draft, file: one.file } : {}) })
.then((got) => {
answer = got;
redraw();
})
.catch((error: unknown) => {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
});
};
const later = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(run, 250);
};
/* ── 틀 ───────────────────────────────────────────────────────────── */
const bar = el("div", { className: "m01c__edit-bar" });
const fillBar = (): void => {
if (ctx.editable) {
bar.replaceChildren(
createButton({
label: editing ? tx("Edit_Off") : tx("Edit_On"),
variant: editing ? "filled" : "ghost",
onClick: () => {
editing = !editing;
draw();
},
}),
);
return;
}
bar.replaceChildren(
el("span", { className: "m01c__muted", text: tx("Edit_OwnOnly") }),
createButton({
label: tx("Edit_Copy"),
variant: "ghost",
onClick: () => (ctx.onCopy ? ctx.onCopy() : showToast(tx("Edit_CopySoon"), "info")),
}),
);
};
const draw = (): void => {
fillBar();
host.replaceChildren(
el("div", {
className: "m01c",
children: [
el("div", {
className: "m01c__head",
children: [
el("h3", { text: `${row.원문번호} ${row.이름}` }),
el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }),
bar,
],
}),
...(one.reasons.length
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
: []),
el("ul", {
className: "m01c__muted",
children: guideLines().map((t) => el("li", { text: t })),
}),
stopped,
...(hasPickable(row)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tx("Mat_Title") }), picker],
}),
]
: []),
el("div", { className: "m01c__flow", children: [inputColumn(), rest] }),
],
}),
);
redraw();
};
/**
* 설계 값 칸 — `draw()` 에서만 다시 세움(계산 때마다 다시 그리는 오른쪽과 달리
* 적던 값과 글쇠 자리가 안 날아감). 고치기를 켜면 칸 이름·단위·고르기도 여기서 고침.
*/
const inputColumn = (): HTMLElement => {
const fields = (row.입력 ?? []).map((spec, i) => {
let control: HTMLElement;
if (spec.고르기?.length) {
control = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value: values[spec.이름] ?? "",
compact: true,
onChange: (v) => {
values[spec.이름] = v;
later();
},
}).root;
} else {
const box = el("input", {
className: "m01c__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tx("Enter_Value");
box.value = values[spec.이름] ?? "";
box.addEventListener("input", () => {
values[spec.이름] = box.value;
later();
});
control = box;
}
return el("label", {
className: "m01c__field",
children: [
el("span", {
className: "m01c__label",
text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름,
}),
control,
...(editing ? editView(`입력:${i}`) : []),
],
});
});
const add = addButton("입력");
return el("section", {
className: "m01c__col m01c__col--input",
children: [
el("h4", { className: "m01c__col-head", text: title("입력") }),
...(fields.length
? fields
: [el("p", { className: "m01c__muted", text: tx("Flow_NoInputs") })]),
...(add ? [add] : []),
],
});
};
draw();
run();
}
@@ -1,5 +1,5 @@
/* =============================================================================
* M01_MasterData_UI_Test_C_Model.ts
* M01_MasterData_UI_Logic_Flow_Model.ts
* 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자.
* 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
@@ -1,4 +1,4 @@
/* M01 로직 테스트 — 방식 C(흐름 그림). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */
/* M01 일위대가 로직 — 흐름 그림(기본 보기). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */
.m01c [hidden] {
display: none !important;
}
@@ -180,3 +180,88 @@
color: var(--color-text);
font-size: var(--text-body-sm);
}
/* 상자 속 설명 카드 — 쉬운 말 풀이 · 원문 표 미리보기(`M01_MasterData_UI_Logic_Note.ts`) */
.m01c__note-body {
padding: 2px 0;
}
.m01c__plain {
margin: 2px 0;
overflow-wrap: anywhere;
}
.m01c__tables {
overflow-x: auto;
}
.m01c__table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.m01c__table th,
.m01c__table td {
padding: 1px 4px;
border-bottom: 1px solid var(--color-border);
text-align: left;
white-space: nowrap;
}
.m01c__table th {
color: var(--color-text-muted);
font-weight: 500;
}
.m01c__hit {
background: var(--color-surface);
font-weight: 600;
}
.m01c__raw pre {
margin: 2px 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* 상자에서 고치기 — [고치기] 를 켰을 때만 */
.m01c__edits {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
border-top: 1px dashed var(--color-border);
}
.m01c__edit {
display: flex;
flex-direction: column;
gap: 1px;
}
.m01c__edit-bar {
display: flex;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
/* 재료·기계 바꿔 고르기 칸 — `M01_MasterData_UI_Logic_Pick.ts` */
.m01-test__picks {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.m01-test__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-test__filters {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}
@@ -0,0 +1,286 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Note.ts
* 설명 카드 — 식 하나를 쉬운 말로 풀고, 그 식이 기대는 원문 표를 미리 보임(걸린 줄 강조).
*
* 순수 읽기 — `/elements` 로 표 파일을 찾고 `/table` 로 통째 읽어 그 자리에서 줄을 맞힘.
* 엔진을 다시 돌리지 않음(수량·금액은 `/calc` 답이 줌) — 여기서는 「어느 줄이 걸렸나」만 보임.
* 흐름 그림 상자(`…_Logic_Flow.ts`)와 테스트 방식 B 가 같이 씀.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "@ui/ui_template_elements";
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
/* ── 원문 표(소요량·계수) 읽기 ─────────────────────────────────────────── */
export interface TableRow {
file: string;
키: string;
이름?: string;
원문번호?: string;
출처?: string;
용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
조건?: Record<string, string>;
값칸?: Record<string, string>;
줄?: Record<string, unknown>[];
}
const tableCache = new Map<string, Promise<TableRow | null>>();
async function getJson<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${API_BASE_URL}/m01${path}`);
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
export function loadTable(key: string): Promise<TableRow | null> {
const cached = tableCache.get(key);
if (cached) return cached;
const job = (async (): Promise<TableRow | null> => {
for (const group of ["소요량", "계수"]) {
const found = await getJson<{ items: ElementBrief[] }>(
`/elements?group=${encodeURIComponent(group)}&q=${encodeURIComponent(key)}&limit=5`,
);
const file = found?.items?.find((i) => i.ref === key)?.file;
if (!file) continue;
const got = await getJson<{ table: TableRow }>(
`/table?file=${encodeURIComponent(file)}&key=${encodeURIComponent(key)}`,
);
if (got?.table) return { ...got.table, file };
}
return null;
})();
tableCache.set(key, job);
return job;
}
/* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */
export interface FindCall {
table: string;
conds: [string, string][];
col: string;
}
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
function splitConds(raw: string): [string, string][] {
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean)
.map((p): [string, string] => {
const i = p.indexOf("=");
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
});
}
function findCalls(expr: string): FindCall[] {
const out: FindCall[] = [];
for (const m of expr.matchAll(FIND_RE)) {
out.push({ table: m[1], conds: splitConds(m[2]), col: m[3] });
}
return out;
}
/** 식 안에 이름이 낱말로 나오는지 — 한글엔 \b 가 안 먹어 앞뒤 글자를 직접 봄 */
function mentions(expr: string, name: string): boolean {
if (!name) return false;
const idx = expr.indexOf(name);
if (idx < 0) return false;
const isWord = (c: string | undefined) => !!c && /[A-Za-z0-9_가-힣]/.test(c);
return !isWord(expr[idx - 1]) && !isWord(expr[idx + name.length]);
}
export interface RelatedFind {
/** 「이 줄」 또는 물려 쓰는 중간 값 이름 */
source: string;
find: FindCall;
}
/** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */
export function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] {
const out: RelatedFind[] = findCalls(expr).map((find) => ({
source: tx("Note_ThisLine"),
find,
}));
for (const m of middles) {
if (!mentions(expr, m.이름)) continue;
for (const find of findCalls(m.식)) out.push({ source: m.이름, find });
}
return out;
}
/* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */
export interface EvalCtx {
values: Record<string, string>;
middle: Record<string, unknown>;
}
function resolveIdent(name: string, ctx: EvalCtx): string | number | undefined {
const quoted = name.match(/^["'](.*)["']$/);
if (quoted) return quoted[1];
if (/^-?\d+(\.\d+)?$/.test(name)) return Number(name);
if (ctx.values[name] !== undefined && ctx.values[name] !== "") return ctx.values[name];
if (ctx.middle[name] !== undefined) return ctx.middle[name] as number;
return undefined;
}
function sameValue(a: unknown, b: unknown): boolean {
if (a === undefined || a === null || b === undefined) return false;
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb;
return String(a) === String(b);
}
/** 지금 조건 값으로 찾기() 가 고를 줄 — 못 맞히면 null(값을 아직 안 골랐거나 후보 여럿) */
export function matchRow(
table: TableRow,
conds: [string, string][],
ctx: EvalCtx,
): Record<string, unknown> | null {
for (const row of table.줄 ?? []) {
if (conds.every(([col, rhs]) => sameValue(row[col], resolveIdent(rhs, ctx)))) return row;
}
return null;
}
/* ── 쉬운 말 풀이 — 찾기()/만약() 을 문장으로, 안 되면 식 그대로 ─────────── */
function splitTop(s: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of s) {
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur);
cur = "";
} else cur += ch;
}
out.push(cur);
return out;
}
function expandIf(expr: string): string {
const at = expr.indexOf("만약(");
if (at < 0) return expr;
let depth = 0;
let j = at + 2; // "만약(" 의 '(' 자리
for (; j < expr.length; j++) {
if (expr[j] === "(") depth++;
else if (expr[j] === ")") {
depth--;
if (depth === 0) {
j++;
break;
}
}
}
const [cond, then, els] = splitTop(expr.slice(at + 3, j - 1)).map((p) => p.trim());
const said = `(${expandIf(cond)} 이면 ${expandIf(then)}, 아니면 ${expandIf(els ?? "")})`;
return expandIf(expr.slice(0, at) + said + expr.slice(j));
}
export function explain(expr: string): string {
const withFind = expr.replace(FIND_RE, (_all, table: string, conds: string, col: string) => {
const cs = splitConds(conds)
.map(([, rhs]) => rhs)
.join(" · ");
return `[${table} 표에서 ${cs} 에 맞는 「${col}」]`;
});
return expandIf(withFind).trim() || expr;
}
/* ── 카드 그리기 ──────────────────────────────────────────────────────── */
export interface NoteOptions {
/** 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */
expr: string;
/** 물려 쓰는 중간 값(한 단계) */
middles: NamedFormula[];
ctx: EvalCtx;
}
/** 표 하나를 그림 — 지금 값으로 걸린 줄은 강조 */
function tableView(find: RelatedFind, table: TableRow, ctx: EvalCtx): HTMLElement[] {
const hit = matchRow(table, find.find.conds, ctx);
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
const body = (table.줄 ?? []).map((line) =>
el("tr", {
className: hit === line ? "m01c__hit" : "",
children: cols.map((c) => el("td", { text: formatNumber(line[c]) })),
}),
);
const use = table.용도
? [table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ")
: "";
return [
el("p", {
className: "m01c__muted",
text: `${find.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`,
}),
...(use ? [el("p", { className: "m01c__muted", text: `${tx("Use_Of")} — ${use}` })] : []),
el("table", {
className: "m01c__table",
children: [
el("thead", { children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })] }),
el("tbody", { children: body }),
],
}),
];
}
/**
* 설명 카드를 `host` 에 채움 — 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로).
* 표는 서버에서 읽어 오므로 먼저 「찾는 중」을 보이고 온 뒤 갈아 끼움.
*/
export function buildNote(host: HTMLElement, opts: NoteOptions): void {
const related = relatedFinds(opts.expr, opts.middles);
const advanced = el("details", {
className: "m01c__raw",
children: [el("summary", { text: tx("Note_Formula") }), el("pre", { text: opts.expr })],
});
const plain = explain(opts.expr);
const head: HTMLElement[] = [
el("p", { className: "m01c__muted", text: tx("Note_Plain") }),
el("p", { className: "m01c__plain", text: plain }),
];
if (!related.length) {
host.replaceChildren(...head, advanced);
return;
}
const tables = el("div", { className: "m01c__muted", text: `${tx("Note_Searching")}…` });
host.replaceChildren(
...head,
el("p", { className: "m01c__muted", text: tx("Note_Table") }),
tables,
advanced,
);
void Promise.all(related.map(async (r) => ({ r, table: await loadTable(r.find.table) }))).then(
(got) => {
tables.className = "m01c__tables";
tables.replaceChildren(
...got.flatMap(({ r, table }) =>
table
? tableView(r, table, opts.ctx)
: [
el("p", {
className: "m01c__muted",
text: tx("Note_TableMissing", { v: r.find.table }),
}),
],
),
);
},
);
}
+45 -3
View File
@@ -32,6 +32,7 @@ import {
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import type { SideHandle } from "./M01_MasterData_UI_Side";
@@ -59,6 +60,8 @@ interface Opened extends Draft {
}
const CACHE_KEY = "m01_logic_drafts";
/** 기본 보기 = 흐름 그림 · 「고급」 = 옛 호표 표 + 시험 계산 칸 */
const VIEW_KEY = "m01.logic.view";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
@@ -83,6 +86,8 @@ export async function mountM01Logic(
let files: LogicFile[] = [];
let opened: Opened | null = null;
let calcInputs = "";
let view: "flow" | "advanced" =
sessionStorage.getItem(VIEW_KEY) === "advanced" ? "advanced" : "flow";
const editor = el("div", { className: "m01-logic__editor" });
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
@@ -129,6 +134,7 @@ export async function mountM01Logic(
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
else drafts[opened.id] = pick(opened, clone(opened.row));
persist();
if (view === "flow") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘
const inputs = JSON.stringify(opened.row.입력 ?? []);
if (inputs !== calcInputs) drawCalc();
};
@@ -142,7 +148,7 @@ export async function mountM01Logic(
});
const drawCalc = (): void => {
if (!opened?.row) {
if (!opened?.row || view === "flow") {
calc.replaceChildren();
return;
}
@@ -162,10 +168,11 @@ export async function mountM01Logic(
};
const drawEditor = (): void => {
// 고른 로직이 없으면 목록 · 있으면 편집 + 시험 계산
// 고른 로직이 없으면 목록 · 있으면 흐름 그림(기본) 또는 고급 = 호표 표 + 시험 계산
const listing = !opened;
list.root.hidden = !listing;
editor.hidden = calc.hidden = back.hidden = listing;
editor.hidden = back.hidden = viewButton.hidden = listing;
calc.hidden = listing || view === "flow";
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
@@ -174,6 +181,23 @@ export async function mountM01Logic(
return;
}
const current = opened;
const shown = current.row as LogicRow;
if (view === "flow") {
mountFlow(editor, {
one: {
file: current.file,
version: current.version,
logic: shown,
blocked: false,
reasons: current.reasons,
prices: current.prices,
},
row: shown,
editable: isOwnLogic(current.file, shown),
onChange: touch,
});
return;
}
buildEditor(editor, {
row: current.row as LogicRow,
file: current.file,
@@ -373,6 +397,23 @@ export async function mountM01Logic(
}
};
const viewButton = createButton({
label: tx("View_Advanced"),
variant: "ghost",
onClick: () => {
view = view === "flow" ? "advanced" : "flow";
try {
sessionStorage.setItem(VIEW_KEY, view);
} catch {
/* 캐시가 막혀도 이 판 안에서는 바뀐 보기가 남음 */
}
viewButton.textContent = view === "flow" ? tx("View_Advanced") : tx("View_Flow");
editor.replaceChildren();
drawEditor();
drawCalc();
},
});
viewButton.textContent = view === "flow" ? tx("View_Advanced") : tx("View_Flow");
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
@@ -384,6 +425,7 @@ export async function mountM01Logic(
children: [
back,
el("h2", { text: tx("Title") }),
viewButton,
pending,
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
+170 -3
View File
@@ -1,7 +1,10 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Pick.ts
* 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`)
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
* 고르기 두 가지
* ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`)
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
* ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘
* 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로
* ========================================================================== */
import {
@@ -11,7 +14,15 @@ import {
el,
showToast,
} from "@ui/ui_template_elements";
import { searchElements, type ElementBrief } from "./M01_MasterData_UI_Logic_Api";
import {
fetchMaterials,
searchElements,
searchPrice,
type ElementBrief,
type HoLine,
type LogicOne,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -117,3 +128,159 @@ function itemRow(
row.addEventListener("click", () => choose(item));
return row;
}
/* ── ② 재료·기계 바꿔 고르기 ─────────────────────────────────────────────── */
export type Swaps = Map<number, ElementBrief>;
const PICKABLE = ["재료", "기계"];
/** 고를 수 있는 줄이 있는지 */
export const hasPickable = (logic: LogicRow): boolean =>
(logic.호표 ?? []).some((l) => PICKABLE.includes(l.종류));
/** 바꿔 본 줄이 있으면 고친 복사본 — 없으면 undefined(정본 그대로 셈) */
export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined {
if (!swaps.size) return undefined;
return {
...logic,
호표: (logic.호표 ?? []).map((l, i) => {
const s = swaps.get(i);
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l;
}),
};
}
type Brief = ElementBrief & {
구분?: string;
상세구분?: string;
값들?: Record<string, unknown>;
};
const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [
...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)),
];
/** 후보 글 — 값 열이 여럿이면 있는 열을 다 보임(계산은 그 가운데 낮은 값) */
const label = (it: Brief): string => {
const cols = Object.entries(it.값들 ?? {})
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k} ${formatNumber(v)}`);
const prices = cols.length ? cols : [formatNumber(it.값) || tx("Mat_NoPrice")];
return [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), ...prices].join(" · ");
};
const oldNames = (one: LogicOne, line: HoLine): string[] => {
const brief = one.prices[line.요소 as string] as { 이름?: string; 원문번호?: string } | null;
const names = [brief?.이름, brief?.원문번호, (line.이름 ?? "").split(/[\s(·]/)[0]];
return [...new Set(names.filter((n): n is string => !!n))];
};
/** 이름을 차례로 찾아 후보가 나오는 첫 결과 — 다 비면 마지막 결과 */
async function firstFound(names: string[]): Promise<Awaited<ReturnType<typeof searchPrice>>> {
let last = await searchPrice(names[0] ?? "");
for (const name of names.slice(1)) {
if (last.items.length) break;
last = await searchPrice(name);
}
return last;
}
function swapRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) {
const cond =
typeof line.요소 === "object" ? (line.요소 as unknown as Record<string, unknown>) : null;
const price = cond ? undefined : one.prices[line.요소]?.값;
let all: Brief[] = [];
let sub = "";
let detail = String(cond?.["상세구분"] ?? "");
const subBox = createSelectField({
options: [],
value: "",
compact: true,
onChange: (v) => ((sub = v), (detail = ""), fill()),
});
const detailBox = createSelectField({
options: [],
value: "",
compact: true,
onChange: (v) => ((detail = v), fill()),
});
const itemBox = createSelectField({
options: [{ value: "", text: tx("Mat_Default") }],
value: "",
onChange: (ref) => {
const item = all.find((it) => it.ref === ref);
if (item) swaps.set(i, item);
else swaps.delete(i);
onChange();
},
});
const subRow = el("div", {
className: "m01-test__filters",
children: [subBox.root, detailBox.root],
});
// 구분 → 상세구분 → 후보 — 앞 단을 고르면 뒷 단 목록이 좁혀짐(구분 칸이 없는 후보면 그 단은 숨김)
const fill = (): void => {
const subs = distinct(all, "구분");
const inSub = all.filter((it) => !sub || it.구분 === sub);
const details = distinct(inSub, "상세구분");
const inDetail = inSub.filter((it) => !detail || it.상세구분 === detail);
const all_ = { value: "", text: tx("Pick_All") };
subBox.root.hidden = subs.length < 1;
detailBox.root.hidden = details.length < 1;
subBox.setOptions([all_, ...subs.map((v) => ({ value: v, text: v }))], sub);
detailBox.setOptions([all_, ...details.map((v) => ({ value: v, text: v }))], detail);
itemBox.setOptions(
[
{ value: "", text: tx("Mat_Default") },
...inDetail.map((it) => ({ value: it.ref, text: label(it) })),
],
swaps.get(i)?.ref ?? "",
);
};
// 고르기 조건({구분, 상세구분, 규격}) 줄 = 조건 안 후보 · 옛 줄 = 이름으로 찾은 후보
const found = cond
? fetchMaterials({
sub: String(cond["구분"] ?? ""),
spec: String(cond["규격"] ?? ""),
region: "울진",
})
: line.종류 === "재료"
? firstFound(oldNames(one, line)) // 옛 줄(고르기 조건 없음) — 품셈재료 이름 → 원문번호 → 이름 첫 낱말 순으로 찾아 나온 첫 것
: searchElements(line.종류, line.이름 ?? "");
void found
.then((r) => {
all = r.items as Brief[];
if (cond) sub = String(cond["구분"] ?? "");
fill();
})
.catch(() => undefined);
const spec = line.규격 ? ` ${line.규격}` : "";
const cost =
price !== undefined && price !== null ? ` · ${tx("Mat_Price")} ${formatNumber(price)}` : "";
return el("div", {
className: "m01-test__pick",
children: [
el("strong", { text: `${line.종류} · ${line.이름 ?? String(line.요소)}${spec}` }),
el("span", { className: "m01c__muted", text: `${line.단위 ?? ""}${cost}` }),
subRow,
itemBox.root,
],
});
}
/** 재료·기계 줄마다 한 칸씩 — 한 번 만들어 두고 다시 그릴 때 같은 노드를 씀(고른 것이 안 날아감) */
export function pickPanel(one: LogicOne, swaps: Swaps, onChange: () => void): HTMLElement {
const rows = (one.logic.호표 ?? [])
.map((line, i) => ({ line, i }))
.filter((x) => PICKABLE.includes(x.line.종류))
.map((x) => swapRow(one, x.line, x.i, swaps, onChange));
return el("div", {
className: "m01-test__picks",
children: rows.length ? rows : [el("p", { text: tx("Mat_None") })],
});
}
@@ -69,6 +69,13 @@ const TEXT = {
Pick_Search: ["이름·키·원문번호", "Name · key · source no."],
Pick_Total: ["{n} 개 중 앞 100 개", "first 100 of {n}"],
Pick_Close: ["닫기", "Close"],
Pick_All: ["(전체)", "(All)"],
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Mat_Price: ["단가", "Unit price"],
Mat_NoPrice: ["단가 없음", "No price"],
Enter_Value: ["값을 넣어 주세요", "Enter a value"],
Save_Done: ["저장했음", "Saved"],
Save_Stale: [
"그 사이 파일이 바뀜 — 고친 것을 버리고 다시 여세요: {v}",
@@ -88,10 +95,85 @@ const TEXT = {
"Unsaved logics stay in the draft",
],
New_Key: ["새 로직", "New logic"],
/* ── 흐름 그림(기본 보기) ── */
View_Flow: ["흐름 그림", "Flow"],
View_Advanced: ["고급 — 호표 표", "Advanced — table"],
Flow_NoInputs: ["받을 값 없음", "No inputs"],
Flow_Other: ["그 밖", "Other"],
Flow_Sub: ["이 로직이 부르는 로직", "Logic called here"],
Flow_SubLines: ["호표", "Unit-cost lines"],
Col_입력: ["설계 값", "Design values"],
Col_중간: ["표 찾기", "Table lookup"],
Col_수량: ["수량", "Qty"],
Col_단가: ["× 단가", "× Unit price"],
Col_덧줄: ["할증·덧줄", "Extra lines"],
Col_비목: ["비목 합계", "Cost items"],
Col_계: ["계", "Total"],
Guide_Flow: [
"왼쪽 「설계 값」을 넣으면 오른쪽으로 수량 → 단가 → 합계 순으로 흐름이 이어짐|상자를 누르면 값·출처·원문 표·쉬운 말 풀이가 펼쳐짐|빨간 글이 뜨면 그 까닭 그대로 값을 채우거나 바꿈",
"Enter design values on the left; quantity, price and total follow to the right|Click a box for values, source, the source table and a plain-words reading|Red text says what is missing",
],
/* ── 상자 속 설명 카드 ── */
Note_Plain: ["쉬운 말 풀이", "In plain words"],
Note_Table: ["원문 표 미리보기", "Source table"],
Note_TableMissing: ["{v} 표 못 찾음", "Table {v} not found"],
Note_Searching: ["찾는 중", "Looking up"],
Note_Formula: ["고급 — 식 그대로", "Advanced — raw formula"],
Note_ThisLine: ["이 줄", "This line"],
Use_Of: ["이 표의 용도", "Used for"],
/* ── 상자에서 고치기 ── */
Edit_On: ["고치기", "Edit"],
Edit_Off: ["고치기 끝", "Done"],
Edit_AddHo: ["호표 줄 더하기", "Add unit-cost line"],
Edit_AddInput: ["설계 값 더하기", "Add design value"],
Edit_AddMiddle: ["표 찾기 더하기", "Add table lookup"],
Edit_AddExtra: ["덧줄 더하기", "Add extra line"],
Edit_Qty: ["수량 식", "Qty formula"],
Edit_OwnOnly: [
"정본 로직은 여기서 못 고침 — 「본떠 만들기」 로 내 것을 만든 뒤 고침",
"Master logics are read-only here — use “Copy as mine” first",
],
Edit_Copy: ["본떠 만들기", "Copy as mine"],
Edit_CopySoon: [
"복제 길(API)이 아직 없음 — 서버가 준비되면 여기서 바로 됨",
"The copy API is not ready yet",
],
/* ── 멈춘 까닭을 설계자 말로 ── */
Stop_Missing: [
"「{name}」 값을 아직 안 넣었습니다 — 값을 넣으면 계산됩니다",
"「{name}」 is empty — enter it to calculate",
],
Stop_NoRow: [
"넣은 값에 맞는 표 줄이 없습니다 — 값을 바꿔 보세요",
"No table row fits these values — try other values",
],
Stop_Spec: [
"「{name}」 규격이 여럿이라 정하지 못함 — 위 「쓰이는 재료·기계」에서 하나를 골라 주세요",
"「{name}」 has several specs — pick one in the materials list above",
],
Stop_NoPrice: [
"「{name}」 단가가 자료에 비어 있어 셈하지 못함 — 자료를 채워야 함",
"「{name}」 has no price in the data yet",
],
} as const satisfies Record<string, readonly [string, string]>;
export type TextKey = keyof typeof TEXT;
/** 엔진이 멈춘 까닭을 설계자 말로 — 모르는 글은 그대로 */
export function plainReason(reason: string): string {
const missing = /입력 「([^」]+)」 없음/.exec(reason);
if (missing) return tx("Stop_Missing", { name: missing[1] });
if (/맞는 줄 0개/.test(reason)) return tx("Stop_NoRow");
const spec = /「([^」]+)」 후보 여럿/.exec(reason);
if (spec) return tx("Stop_Spec", { name: spec[1] });
const noPrice = /「([^」]+)」 값 없음/.exec(reason);
if (noPrice) return tx("Stop_NoPrice", { name: noPrice[1] });
return reason;
}
/** 흐름 그림 머리 「이렇게 씀」 — 줄 목록 */
export const guideLines = (): string[] => tx("Guide_Flow").split("|");
/** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */
export function tx(key: TextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
+1 -1
View File
@@ -10,7 +10,7 @@ import { fetchLogics } from "./M01_MasterData_UI_Logic_Api";
import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard";
import { render as renderA } from "./M01_MasterData_UI_Test_A";
import { render as renderB } from "./M01_MasterData_UI_Test_B";
import { render as renderC } from "./M01_MasterData_UI_Test_C";
import { render as renderC } from "./M01_MasterData_UI_Logic_Flow";
import { tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_Style.css";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
type LogicOne,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Test_Pick";
import { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
type Step =
+8 -240
View File
@@ -13,206 +13,20 @@
* 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createSelectField, el } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type CalcLine,
type ElementBrief,
type HoLine,
type LogicOne,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { buildNote, relatedFinds, type EvalCtx } from "./M01_MasterData_UI_Logic_Note";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
/* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */
interface TableRow {
file: string;
키: string;
이름?: string;
원문번호?: string;
출처?: string;
용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
조건?: Record<string, string>;
값칸?: Record<string, string>;
줄?: Record<string, unknown>[];
}
const tableCache = new Map<string, Promise<TableRow | null>>();
async function getJson<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${API_BASE_URL}/m01${path}`);
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
function loadTable(key: string): Promise<TableRow | null> {
const cached = tableCache.get(key);
if (cached) return cached;
const job = (async (): Promise<TableRow | null> => {
for (const group of ["소요량", "계수"]) {
const found = await getJson<{ items: ElementBrief[] }>(
`/elements?group=${encodeURIComponent(group)}&q=${encodeURIComponent(key)}&limit=5`,
);
const file = found?.items?.find((i) => i.ref === key)?.file;
if (!file) continue;
const got = await getJson<{ table: TableRow }>(
`/table?file=${encodeURIComponent(file)}&key=${encodeURIComponent(key)}`,
);
if (got?.table) return { ...got.table, file };
}
return null;
})();
tableCache.set(key, job);
return job;
}
/* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */
interface FindCall {
table: string;
conds: [string, string][];
col: string;
}
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
function splitConds(raw: string): [string, string][] {
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean)
.map((p): [string, string] => {
const i = p.indexOf("=");
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
});
}
function findCalls(expr: string): FindCall[] {
const out: FindCall[] = [];
for (const m of expr.matchAll(FIND_RE)) {
out.push({ table: m[1], conds: splitConds(m[2]), col: m[3] });
}
return out;
}
/** 식 안에 이름이 낱말로 나오는지 — 한글엔 \b 가 안 먹어 앞뒤 글자를 직접 봄 */
function mentions(expr: string, name: string): boolean {
if (!name) return false;
const idx = expr.indexOf(name);
if (idx < 0) return false;
const isWord = (c: string | undefined) => !!c && /[A-Za-z0-9_가-힣]/.test(c);
return !isWord(expr[idx - 1]) && !isWord(expr[idx + name.length]);
}
interface RelatedFind {
source: string; // "이 줄" 또는 물려 쓰는 중간 이름
find: FindCall;
}
/** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */
function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] {
const out: RelatedFind[] = findCalls(expr).map((find) => ({ source: "이 줄", find }));
for (const m of middles) {
if (!mentions(expr, m.이름)) continue;
for (const find of findCalls(m.식)) out.push({ source: m.이름, find });
}
return out;
}
/* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */
interface EvalCtx {
values: Record<string, string>;
middle: Record<string, unknown>;
}
function resolveIdent(name: string, ctx: EvalCtx): string | number | undefined {
const quoted = name.match(/^["'](.*)["']$/);
if (quoted) return quoted[1];
if (/^-?\d+(\.\d+)?$/.test(name)) return Number(name);
if (ctx.values[name] !== undefined && ctx.values[name] !== "") return ctx.values[name];
if (ctx.middle[name] !== undefined) return ctx.middle[name] as number;
return undefined;
}
function sameValue(a: unknown, b: unknown): boolean {
if (a === undefined || a === null || b === undefined) return false;
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb;
return String(a) === String(b);
}
/** 지금 조건 값으로 찾기() 가 고를 줄 — 못 맞히면 null(값을 아직 안 골랐거나 후보 여럿) */
function matchRow(
table: TableRow,
conds: [string, string][],
ctx: EvalCtx,
): Record<string, unknown> | null {
for (const row of table.줄 ?? []) {
if (conds.every(([col, rhs]) => sameValue(row[col], resolveIdent(rhs, ctx)))) return row;
}
return null;
}
/* ── 쉬운 말 풀이 — 찾기()/만약() 을 문장으로, 안 되면 식 그대로 ─────────── */
function splitTop(s: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of s) {
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur);
cur = "";
} else cur += ch;
}
out.push(cur);
return out;
}
function expandIf(expr: string): string {
const at = expr.indexOf("만약(");
if (at < 0) return expr;
let depth = 0;
let j = at + 2; // "만약(" 의 '(' 자리
for (; j < expr.length; j++) {
if (expr[j] === "(") depth++;
else if (expr[j] === ")") {
depth--;
if (depth === 0) {
j++;
break;
}
}
}
const [cond, then, els] = splitTop(expr.slice(at + 3, j - 1)).map((p) => p.trim());
const said = `(${expandIf(cond)} 이면 ${expandIf(then)}, 아니면 ${expandIf(els ?? "")})`;
return expandIf(expr.slice(0, at) + said + expr.slice(j));
}
function explain(expr: string): string {
const withFind = expr.replace(FIND_RE, (_all, table: string, conds: string, col: string) => {
const cs = splitConds(conds)
.map(([, rhs]) => rhs)
.join(" · ");
return `[${table} 표에서 ${cs} 에 맞는 「${col}」]`;
});
return expandIf(withFind).trim() || expr;
}
/* ── 화면 상태 ────────────────────────────────────────────────────────── */
interface State {
@@ -357,7 +171,7 @@ function paint(host: HTMLElement, state: State): void {
const side = el("div", { className: "m01-logic__side" });
side.style.cssText =
"flex:1 1 36%;min-width:280px;border:1px solid var(--ui-border,#ddd);border-radius:8px;padding:12px;";
void buildCard(side, state);
buildCard(side, state);
const layout = el("div", {});
layout.style.cssText = "display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;";
@@ -429,7 +243,7 @@ function buildHoTable(host: HTMLElement, state: State): HTMLElement {
});
}
async function buildCard(side: HTMLElement, state: State): Promise<void> {
function buildCard(side: HTMLElement, state: State): void {
const ho: HoLine | undefined = (state.one.logic.호표 ?? [])[state.selected];
if (!ho) {
side.replaceChildren(el("p", { className: "m01-logic__muted", text: "호표 줄이 없음" }));
@@ -456,7 +270,6 @@ async function buildCard(side: HTMLElement, state: State): Promise<void> {
el("dd", { text: calc?.출처 ?? ho.비목 ?? "-" }),
],
}),
el("p", { text: explain(ho.수량) }),
);
const surcharge = related.filter((r) => r.source !== "이 줄" && r.source.includes("할증"));
@@ -471,53 +284,8 @@ async function buildCard(side: HTMLElement, state: State): Promise<void> {
);
}
side.append(el("h4", { text: "원문 표 미리보기" }));
const previewHost = el("div", { text: "찾는 중…" });
side.append(previewHost);
const previews = await Promise.all(
related.map(async (r) => ({ r, table: await loadTable(r.find.table) })),
);
previewHost.replaceChildren(
...previews.flatMap(({ r, table }) => {
if (!table)
return [el("p", { className: "m01-logic__muted", text: `${r.find.table} 표 못 찾음` })];
const hit = matchRow(table, r.find.conds, ctx);
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
const bodyRows = (table.줄 ?? []).map((line) => {
const isHit = hit === line;
const tr = el("tr", {
children: cols.map((c) => el("td", { text: formatNumber(line[c]) })),
});
if (isHit) tr.style.cssText = "background:var(--ui-accent-bg,#fff3cd);font-weight:600;";
return tr;
});
return [
el("p", {
className: "m01-logic__muted",
text: `${r.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`,
}),
...(table.용도
? [
el("p", {
className: "m01-logic__muted",
text: `${tt("Use_Of")} — ${[table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ")}`,
}),
]
: []),
el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
}),
el("tbody", { children: bodyRows }),
],
}),
];
}),
);
const advanced = el("details", {});
advanced.append(el("summary", { text: "고급 — 식 그대로" }), el("pre", { text: ho.수량 }));
side.append(advanced);
// 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로) — 흐름 그림과 같은 카드
const note = el("div");
side.append(note);
buildNote(note, { expr: ho.수량, middles: state.one.logic.중간 ?? [], ctx });
}
-288
View File
@@ -1,288 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Test_C.ts
* 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길
* (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는
* 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜.
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Test_C_Model.ts`.
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type LogicOne,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import {
buildFlow,
type FlowBox,
type FlowColumn,
type FlowKind,
} from "./M01_MasterData_UI_Test_C_Model";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_C_Style.css";
const TEXT = {
Loading: ["불러오는 중", "Loading"],
Failed: ["불러오지 못함", "Load failed"],
Stopped: ["멈춤", "Stopped"],
NoInputs: ["받을 값 없음", "No inputs"],
Other: ["그 밖", "Other"],
Sub: ["이 로직이 부르는 로직", "Logic called here"],
SubLines: ["호표", "Unit-cost lines"],
Col_입력: ["설계 값", "Design values"],
Col_중간: ["표 찾기", "Table lookup"],
Col_수량: ["수량", "Qty"],
Col_단가: ["× 단가", "× Unit price"],
Col_덧줄: ["할증·덧줄", "Extra lines"],
Col_비목: ["비목 합계", "Cost items"],
Col_계: ["계", "Total"],
} as const satisfies Record<string, readonly [string, string]>;
const tc = (key: keyof typeof TEXT): string =>
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
const title = (kind: FlowKind): string => tc(`Col_${kind}` as keyof typeof TEXT);
/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎) */
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") }));
void fetchLogic(logicKey)
.then((one) => mount(host, one))
.catch((error: unknown) => {
host.replaceChildren(
el("p", {
className: "m01c__bad",
text: error instanceof Error ? error.message : tc("Failed"),
}),
);
});
}
function mount(host: HTMLElement, one: LogicOne): void {
const row = one.logic;
const values: Record<string, string> = {};
// 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠)
for (const spec of row.입력 ?? []) {
if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]);
}
const swaps: Swaps = new Map();
const picker = pickPanel(one, swaps, () => run());
const open = new Set<string>();
const subs = new Map<string, LogicRow>();
let answer: CalcAnswer | null = null;
let timer: number | undefined;
const rest = el("div", { className: "m01c__rest" });
const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } });
const boxView = (box: FlowBox): HTMLElement => {
const node = el("details", {
className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`,
children: [
el("summary", {
children: [
el("span", { className: "m01c__label", text: box.label }),
el("span", { className: "m01c__value", text: box.value }),
...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []),
],
}),
el("dl", {
className: "m01c__detail",
children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]),
}),
],
});
if (open.has(box.id)) node.open = true;
node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id)));
if (box.logic) node.append(subView(box.logic));
return node;
};
/** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */
const subView = (key: string): HTMLElement => {
const body = el("div", { className: "m01c__sub-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body],
});
const fill = (sub: LogicRow): void => {
body.replaceChildren(
el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }),
el("p", { className: "m01c__muted", text: tc("SubLines") }),
el("ul", {
children: (sub.호표 ?? []).map((item) =>
el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }),
),
}),
);
};
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
const had = subs.get(key);
if (had) {
fill(had);
return;
}
body.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") }));
void fetchLogic(key)
.then((deep) => {
subs.set(key, deep.logic);
fill(deep.logic);
})
.catch(() => body.replaceChildren(el("p", { className: "m01c__bad", text: tc("Failed") })));
});
return node;
};
const columnView = (column: FlowColumn): HTMLElement => {
const head = el("h4", { className: "m01c__col-head", text: title(column.kind) });
// 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」)
const costOf = (box: FlowBox): string => box.cost ?? tc("Other");
const costs = [...new Set(column.boxes.map(costOf))];
const body =
column.boxes.length > 6 && costs.length > 1
? costs.map((cost) =>
el("details", {
className: "m01c__group",
attrs: { open: "" },
children: [
el("summary", { text: cost }),
...column.boxes.filter((b) => costOf(b) === cost).map(boxView),
],
}),
)
: column.boxes.map(boxView);
return el("section", { className: "m01c__col", children: [head, ...body] });
};
const redraw = (): void => {
const columns = buildFlow(row, answer, one.prices, values);
const nodes: HTMLElement[] = [];
for (const column of columns.slice(1)) {
nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column));
}
rest.replaceChildren(...nodes);
const reason = answer && !answer.ok ? answer.reason : "";
stopped.textContent = reason ? `${tc("Stopped")} — ${plainReason(reason)}` : "";
stopped.hidden = !reason;
};
const run = (): void => {
const inputs: Record<string, unknown> = {};
for (const spec of row.입력 ?? []) {
const raw = (values[spec.이름] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
const option = spec.고르기?.find((o) => String(o) === raw);
inputs[spec.이름] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
const swapped = swappedRow(row, swaps);
void runCalc({ key: row.키, inputs, ...(swapped ? { row: swapped, file: one.file } : {}) })
.then((got) => {
answer = got;
redraw();
})
.catch((error: unknown) => {
showToast(error instanceof Error ? error.message : tc("Failed"), "error");
});
};
const later = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(run, 250);
};
host.replaceChildren(
el("div", {
className: "m01c",
children: [
el("div", {
className: "m01c__head",
children: [
el("h3", { text: `${row.원문번호} ${row.이름}` }),
el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }),
],
}),
...(one.reasons.length
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
: []),
el("ul", {
className: "m01c__muted",
children: guideLines("C").map((t) => el("li", { text: t })),
}),
stopped,
...(hasPickable(row)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tt("Mat_Title") }), picker],
}),
]
: []),
el("div", {
className: "m01c__flow",
children: [inputColumn(row, values, later), rest],
}),
],
}),
);
redraw();
run();
}
/** 설계 값 칸 — 한 번만 세움(다시 그려도 적던 값·글쇠 자리가 안 날아감) */
function inputColumn(
row: LogicRow,
values: Record<string, string>,
onChange: () => void,
): HTMLElement {
const fields = (row.입력 ?? []).map((spec) => {
let control: HTMLElement;
if (spec.고르기?.length) {
control = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value: values[spec.이름] ?? "",
compact: true,
onChange: (v) => {
values[spec.이름] = v;
onChange();
},
}).root;
} else {
const box = el("input", {
className: "m01c__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tt("Enter_Value");
box.value = values[spec.이름] ?? "";
box.addEventListener("input", () => {
values[spec.이름] = box.value;
onChange();
});
control = box;
}
return el("label", {
className: "m01c__field",
children: [
el("span", {
className: "m01c__label",
text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름,
}),
control,
],
});
});
return el("section", {
className: "m01c__col m01c__col--input",
children: [
el("h4", { className: "m01c__col-head", text: title("입력") }),
...(fields.length ? fields : [el("p", { className: "m01c__muted", text: tc("NoInputs") })]),
],
});
}
@@ -1,172 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Test_Pick.ts
* 재료·기계 바꿔 고르기 부품 — 방식 A·B·C 가 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘
* 바꾼 것은 시험 계산용 복사본에만 — 정본 로직은 그대로
* ========================================================================== */
import { createSelectField, el } from "@ui/ui_template_elements";
import {
fetchMaterials,
searchElements,
searchPrice,
type ElementBrief,
type HoLine,
type LogicOne,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tt } from "./M01_MasterData_UI_Test_Text";
export type Swaps = Map<number, ElementBrief>;
const PICKABLE = ["재료", "기계"];
/** 고를 수 있는 줄이 있는지 */
export const hasPickable = (logic: LogicRow): boolean =>
(logic.호표 ?? []).some((l) => PICKABLE.includes(l.종류));
/** 바꿔 본 줄이 있으면 고친 복사본 — 없으면 undefined(정본 그대로 셈) */
export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined {
if (!swaps.size) return undefined;
return {
...logic,
호표: (logic.호표 ?? []).map((l, i) => {
const s = swaps.get(i);
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l;
}),
};
}
type Brief = ElementBrief & {
구분?: string;
상세구분?: string;
값들?: Record<string, unknown>;
};
const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [
...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)),
];
/** 후보 글 — 값 열이 여럿이면 있는 열을 다 보임(계산은 그 가운데 낮은 값) */
const label = (it: Brief): string => {
const cols = Object.entries(it.값들 ?? {})
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k} ${formatNumber(v)}`);
const prices = cols.length ? cols : [formatNumber(it.값) || tt("Mat_NoPrice")];
return [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), ...prices].join(" · ");
};
const oldNames = (one: LogicOne, line: HoLine): string[] => {
const brief = one.prices[line.요소 as string] as { 이름?: string; 원문번호?: string } | null;
const names = [brief?.이름, brief?.원문번호, (line.이름 ?? "").split(/[\s(·]/)[0]];
return [...new Set(names.filter((n): n is string => !!n))];
};
/** 이름을 차례로 찾아 후보가 나오는 첫 결과 — 다 비면 마지막 결과 */
async function firstFound(names: string[]): Promise<Awaited<ReturnType<typeof searchPrice>>> {
let last = await searchPrice(names[0] ?? "");
for (const name of names.slice(1)) {
if (last.items.length) break;
last = await searchPrice(name);
}
return last;
}
function pickRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) {
const cond =
typeof line.요소 === "object" ? (line.요소 as unknown as Record<string, unknown>) : null;
const price = cond ? undefined : one.prices[line.요소]?.값;
let all: Brief[] = [];
let sub = "";
let detail = String(cond?.["상세구분"] ?? "");
const subBox = createSelectField({
options: [],
value: "",
compact: true,
onChange: (v) => ((sub = v), (detail = ""), fill()),
});
const detailBox = createSelectField({
options: [],
value: "",
compact: true,
onChange: (v) => ((detail = v), fill()),
});
const itemBox = createSelectField({
options: [{ value: "", text: tt("Mat_Default") }],
value: "",
onChange: (ref) => {
const item = all.find((it) => it.ref === ref);
if (item) swaps.set(i, item);
else swaps.delete(i);
onChange();
},
});
const subRow = el("div", {
className: "m01-test__filters",
children: [subBox.root, detailBox.root],
});
// 구분 → 상세구분 → 후보 — 앞 단을 고르면 뒷 단 목록이 좁혀짐(구분 칸이 없는 후보면 그 단은 숨김)
const fill = (): void => {
const subs = distinct(all, "구분");
const inSub = all.filter((it) => !sub || it.구분 === sub);
const details = distinct(inSub, "상세구분");
const inDetail = inSub.filter((it) => !detail || it.상세구분 === detail);
const all_ = { value: "", text: tt("Pick_All") };
subBox.root.hidden = subs.length < 1;
detailBox.root.hidden = details.length < 1;
subBox.setOptions([all_, ...subs.map((v) => ({ value: v, text: v }))], sub);
detailBox.setOptions([all_, ...details.map((v) => ({ value: v, text: v }))], detail);
itemBox.setOptions(
[
{ value: "", text: tt("Mat_Default") },
...inDetail.map((it) => ({ value: it.ref, text: label(it) })),
],
swaps.get(i)?.ref ?? "",
);
};
// 고르기 조건({구분, 상세구분, 규격}) 줄 = 조건 안 후보 · 옛 줄 = 이름으로 찾은 후보
const found = cond
? fetchMaterials({
sub: String(cond["구분"] ?? ""),
spec: String(cond["규격"] ?? ""),
region: "울진",
})
: line.종류 === "재료"
? firstFound(oldNames(one, line)) // 옛 줄(고르기 조건 없음) — 품셈재료 이름 → 원문번호 → 이름 첫 낱말 순으로 찾아 나온 첫 것
: searchElements(line.종류, line.이름 ?? "");
void found
.then((r) => {
all = r.items as Brief[];
if (cond) sub = String(cond["구분"] ?? "");
fill();
})
.catch(() => undefined);
const spec = line.규격 ? ` ${line.규격}` : "";
const cost =
price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : "";
return el("div", {
className: "m01-test__pick",
children: [
el("strong", { text: `${line.종류} · ${line.이름 ?? String(line.요소)}${spec}` }),
el("span", { className: "m01-test__muted", text: `${line.단위 ?? ""}${cost}` }),
subRow,
itemBox.root,
],
});
}
/** 재료·기계 줄마다 한 칸씩 — 한 번 만들어 두고 다시 그릴 때 같은 노드를 씀(고른 것이 안 날아감) */
export function pickPanel(one: LogicOne, swaps: Swaps, onChange: () => void): HTMLElement {
const rows = (one.logic.호표 ?? [])
.map((line, i) => ({ line, i }))
.filter((x) => PICKABLE.includes(x.line.종류))
.map((x) => pickRow(one, x.line, x.i, swaps, onChange));
return el("div", {
className: "m01-test__picks",
children: rows.length ? rows : [el("p", { text: tt("Mat_None") })],
});
}
@@ -60,12 +60,6 @@
margin: 0;
}
.m01-test__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-test__input {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
@@ -103,15 +97,3 @@
background: var(--color-surface);
color: var(--color-danger, #c0392b);
}
.m01-test__picks {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.m01-test__filters {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}