Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_4
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -194,7 +194,7 @@
|
||||
"중간": [{ "이름": "증가율", "식": "…" }],
|
||||
"호표": [{ "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" }],
|
||||
"덧줄": [{ "이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비", "출처": "…" }],
|
||||
"끝수": "원 미만 버림"
|
||||
"끝수": { "대상": "계", "자리": 0, "방법": "버림", "출처": "산림품셈 13-4-1" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -204,6 +204,7 @@
|
||||
- `종류` = 인력 · 재료 · 기계 · 로직(하위 로직의 결과를 단가로 씀) — 요소 키의 테이블ID 그룹과 같음.
|
||||
- `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀.
|
||||
- `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식.
|
||||
- `끝수` = 묶음 `{대상, 자리, 방법, 출처?, 비고?}` — 엔진이 계산 맨 끝에 붙임. `대상` 은 `계`(비목 합 셋을 각각 끊고 계 = 그 합) · `결과`(돈 아닌 로직) · `자리` 는 소수 자리(0 = 원 미만) · `방법` 은 `버림`·`올림`·`반올림`. 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적은 설명일 뿐 — 그 끊기는 식 안에 이미 있음.
|
||||
- `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음.
|
||||
재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음:
|
||||
|
||||
|
||||
@@ -14,18 +14,20 @@
|
||||
|
||||
## 2. 읽기
|
||||
|
||||
| 길 | 받음 | 줌 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /subs` | `file` 또는 `group` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · `group`(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로) |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` 또는 `group` · `sub` · `detail` · `q` · `page`(1) · `size`(30) | `{file, version, total, page, size, tables: [{file, 키, 원문번호, 구분, 상세구분, 이름, 기준, 출처, 조건, 값칸, count}]}` — 파일을 가로지른 한 목록 |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /logics` | `sub` · `detail` · `q` · `blocked`(0/1) | `{logics: [{file, 키, 원문번호, 구분, 상세구분, 이름, 결과단위, 출처, blocked, reasons}]}` — 장 차례대로 |
|
||||
| `GET /logic` | `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 구분, 상세구분, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
| `GET /materials` | `sub`(구분) · `detail` · `spec` · `region`(자재지역) · `limit`(50 · 200까지) | `{total, 기본, items: [{ref, 구분, 상세구분, 이름, 규격, 단위, 값, 관급}]}` — 재료 고르기 조건 안 후보 · `기본` = 시험 계산이 쓸 줄 |
|
||||
| 길 | 받음 | 줌 |
|
||||
| --------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /subs` | `file` 또는 `group` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · `group`(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로) |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` 또는 `group` · `sub` · `detail` · `section` · `q` · `page`(1) · `size`(30) | `{file, version, total, page, size, tables: [{file, 키, 원문번호, 구분, 상세구분, 이름, 기준, 출처, 조건, 값칸, count}]}` — 파일을 가로지른 한 목록 |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /table/options` | `file` · `key` | `{file, key, 이름, 조건, 범위규칙, 값칸, count, 값: {조건 이름: [값…]}}` — 표 줄을 통째로 안 받고 조건 칸의 값만 |
|
||||
| `GET /sections` | `book`(필수) · `division` · `chapter` · `q` · `limit`(200 · 1000까지) | `{total, sections: [{book, division, chapter, chapterNo, section, title, tables, logics}]}` — 원문 절 목록 |
|
||||
| `GET /logics` | `sub` · `detail` · `q` · `blocked`(0/1) · `owner` | `{logics: [{file, 키, 원문번호, 구분, 상세구분, 이름, 결과단위, 출처, 소유, blocked, reasons}]}` — 장 차례대로 |
|
||||
| `GET /logic` | `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 구분, 상세구분, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
| `GET /materials` | `sub`(구분) · `detail` · `spec` · `region`(자재지역) · `limit`(50 · 200까지) | `{total, 기본, items: [{ref, 구분, 상세구분, 이름, 규격, 단위, 값, 관급}]}` — 재료 고르기 조건 안 후보 · `기본` = 시험 계산이 쓸 줄 |
|
||||
|
||||
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
|
||||
- `q` = 찾기 — `키`·`원문번호`·`이름`·`옛이름` 에 든 글(대소문자 무시).
|
||||
@@ -36,6 +38,10 @@
|
||||
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴.
|
||||
- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 품셈재료 이름(없으면 이름 첫 낱말)으로 `pick?kind=price` 후보. 후보 글 = 이름 규격 · 값 있는 열 전부(물가자료 · 유통물가 …). 멈춤 까닭 `입력 「X」 없음` · `맞는 줄 0개` 는 방식 A·B·C 가 쉬운 말로 바꿔 보임(`plainReason`) · 빈 수 칸은 「값을 넣어 주세요」 안내 글.
|
||||
- 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임 · `/tables` 는 `usage` 로 `용도.대상` 거름(기계 `sub`·`detail` 은 인력과 같이 구분 → 상세구분).
|
||||
- `section` = 절 번호 거름 — 그 절과 그 아래 절만(`13-4` 는 `13-4` · `13-4-1` 만 · `13-40` 은 안 걸림). 「만들기」가 절을 고르면 그 절의 표만 보임.
|
||||
- `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `chapter` = 「부문 NN장 장 제목」 · `chapterNo` = 「NN」 · `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절.
|
||||
- `/table/options` = 조건 값 고르기 — `고르기` 조건은 줄에 나온 값 차례대로(중복 없음) · `범위` 조건은 `[[아래, 위]…]` · `count` = 줄 수. 표 몸을 통째로 받지 않으려는 자리에만 씀(통째는 `/table`).
|
||||
- `owner` = 로직 `소유` 거름(`공용` 정본 · `현장` 자체) · 빈 글 = 전체.
|
||||
- `pick` = 고르기 창 — `price` = `재료_자재품목.json`(값 = 값 열 다섯 가운데 낮은 값 · `관급` = 관급 값 있음) ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키.
|
||||
|
||||
## 3. 시험 계산
|
||||
@@ -46,6 +52,8 @@
|
||||
- 돈 아닌 로직 — `{ok: true, result, middle}`
|
||||
- 멈춤 — `{ok: false, reason}` (200). 까닭 = 엔진 글 그대로(「입력 「돌」 없음」 · 「값 없음 — 관리자가 채울 값」 …).
|
||||
- `row` 없음 = 저장된 파일로 계산 · `row` 있음 = 그 로직만 고친 줄로 바꿔(없던 로직이면 `file` 에 더해) 메모리에서 계산 — 파일에 안 씀. 요소 값은 늘 저장된 파일.
|
||||
- 저장 안 한 새 초안 = `key` "" · `row` 에 로직 한 줄 통째 · `file` 은 안 보내도 됨(서버가 메모리에만 얹음). 「만들기」 모달의 [시험 계산] 이 이 길을 씀 — 키를 미리 받지 않음.
|
||||
- `끝수` 가 묶음이면 계산 끝에 붙음(6장) · 글자면 설명일 뿐 계산에 안 붙음.
|
||||
|
||||
## 4. 저장
|
||||
|
||||
@@ -67,3 +75,33 @@
|
||||
- 검사 = `check_master.py` 의 틀(고친 파일) + 로직 변수(모든 로직 파일, 고친 뒤 마스터 기준). **고친 뒤 새로 생긴 걸림**만 막음 — 원래 있던 걸림은 막지 않음. 인력·재료의 원문번호 겹침도 걸림(표·로직 원문번호는 절 번호라 겹쳐도 됨 · 자재품목은 줄에 원문번호가 없음).
|
||||
- 요소를 지워 어떤 로직이 가리키던 것이 사라지면 로직 변수 검사에 걸림(422).
|
||||
- 쓰기는 UTF-8 · LF · 들여쓰기 2칸.
|
||||
|
||||
## 5. 자체 로직 (만들기 · 본뜨기 · 고치기 · 지우기)
|
||||
|
||||
정본 로직(`소유` 「공용」 · `로직_산림품셈…` · `로직_건설품셈…`)은 이 길로 못 고침 — 403. 고치려면 먼저 복제.
|
||||
|
||||
- 자리 = `로직_자체.json` 한 파일(원문 장으로 나누지 않음 — 틀이 「자체」 파일에 장 이름을 두지 않음) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "판": <올해>, "줄": []}`)와 함께 만듦.
|
||||
- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음. 원문 절이 같아도 줄마다 다른 키.
|
||||
- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」(`출처`·`원문번호` 에서 서버가 찾음 · 원문 절이 없으면 빈 글)은 서버가 넣음.
|
||||
|
||||
| 길 | 받음 | 줌 |
|
||||
| -------------------- | ----------------------- | ------------------------------------------------------------ |
|
||||
| `POST /logic/new` | `{logic, owner?}` | `{file, version, key, logic}` |
|
||||
| `POST /logic/copy` | `{key, 이름?, owner?}` | `{file, version, key, logic}` — 정본·자체 둘 다 본뜰 수 있음 |
|
||||
| `POST /logic/edit` | `{key, version, logic}` | `{file, version, logic}` |
|
||||
| `POST /logic/delete` | `{key, version}` | `{file, version}` |
|
||||
|
||||
- `logic` = 로직 한 줄 통째(`_틀.md` 7장) · `키`·`구분`·`상세구분` 은 보내도 서버 것이 이김.
|
||||
- `version` = `GET /logic` 이 준 그 파일 판본 — 다르면 409 `{detail: {stale: [file]}}`.
|
||||
- `copy` 는 비고 끝에 「`<본뜬 키>` 를 본뜸」 을 붙이고 `시험입력` 은 그대로 가져옴 · `이름` 을 주면 그 이름으로.
|
||||
- 답 — 200 · 400(틀린 몸) · 403(정본 로직) · 404(없는 키·파일) · 409(낡은 판본) · 422 `{detail: {errors: […]}}`(검사 걸림 — `/save` 와 같은 검사).
|
||||
- 지움은 그 로직을 부르는 다른 로직이 있으면 422.
|
||||
|
||||
## 6. 끝수
|
||||
|
||||
로직 줄의 `끝수` 는 묶음 `{대상, 자리, 방법, 출처?, 비고?}` — 계산 맨 끝에 붙음.
|
||||
|
||||
- `대상` — `계`(돈 로직 · 비목 합 셋을 각각 끊고 계 = 그 셋의 합) · `결과`(돈 아닌 로직의 결과 수).
|
||||
- `자리` = 소수 자리(0 = 원 미만 · 3 = 소수 넷째 자리에서).
|
||||
- `방법` — `버림` · `올림` · `반올림`.
|
||||
- 옛 줄의 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적어 둔 설명 — 계산에 안 붙음(그 끊기는 이미 식 안에 있음).
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# 일위대가 로직 만들기 화면 설계
|
||||
|
||||
품셈을 모르는 설계자가 **빈 화면에서 로직 한 줄을 새로 만드는 길**. 지금 테스트 컨테이너(`M01_MasterData_UI_Test*.ts`)는 이미 있는 로직을 읽고 시험 계산만 해서 이 길을 못 재는 자리를 메움.
|
||||
품셈을 모르는 설계자가 **빈 화면에서 로직 한 줄을 새로 만드는 길**(`PLAN.md` 2장).
|
||||
|
||||
- 만들기 = 방식 A(질문·답 마법사) 확장 · 보기 = 방식 C(흐름 그림).
|
||||
- 보기·고치기 = **방식 C(흐름 그림)가 일위대가 로직 화면의 기본** · 옛 호표 편집기는 「고급」 으로 남김.
|
||||
- 만들기 = 그 화면의 **[새로 만들기] 모달 + 방식 A(질문·답)** · 저장하면 방식 C 로 열림.
|
||||
- 식은 글자로 치지 않음 — 선택지와 블록으로 세움. 글자 식은 「고급」 뒤.
|
||||
- 저장·계산은 지금 서버 그대로(`POST /calc` · `POST /save`) — 화면만 새로 붙임.
|
||||
- 서버 길은 다 섬 — `GET /sections` · `/tables?section=` · `GET /table/options` · `/logics?owner=` · `POST /calc`(저장 전 초안) · `POST /logic/new|copy|edit|delete`(계약 `_화면_계약.md` 2·3·5·6장). 남은 것은 화면과 TS 타입뿐(③).
|
||||
|
||||
---
|
||||
|
||||
@@ -23,20 +24,20 @@
|
||||
↓
|
||||
[6 시험 계산] 견본 값을 넣어 호표와 합계 확인(저장 전)
|
||||
↓
|
||||
[7 저장] 개인 · 공용 고르기 → 키는 서버가 발급
|
||||
[7 저장] 현장 · 공용 고르기 → 키는 서버가 발급 → 방식 C 로 열림
|
||||
```
|
||||
|
||||
1. **이름** — 「무엇을 얼마만큼 하는 값인지」 한 줄 · 결과 단위는 고르기(`원/㎡` · `원/m` · `원/㎥` · `원/개소` · `원/본` · 그 밖 직접). 단위가 「원」 으로 시작하면 돈 로직(호표) · 아니면 결과 식 하나(`_틀.md` 7장).
|
||||
2. **원문 절** — 책(산림품셈 · 건설품셈 · 자체) → 장 → 절. 절을 고르면 그 절 번호의 표를 모아 보임 — 「이 절에 쓰는 표」 목록에 표 이름 · 기준(1㎡당) · 용도(`공종` · `대상` = 인력 · 재료 · 기계 · 작업량 조건). 표 하나를 고르면 그 표의 **값 칸이 곧 호표 후보 줄**(석공 · 보통인부 …).
|
||||
2. **원문 절** — 책 → (건설품셈이면 부문) → 장 → 절을 `GET /sections` 로 고름(절마다 그 절의 표·로직 수가 같이 옴 — 0 이면 아직 안 올린 절). 고른 절 번호를 `GET /tables?section=` 에 그대로 넘기면 그 절의 표만 옴 — 표 이름 · 기준(1㎡당) · 용도(`공종` · `대상` = 인력 · 재료 · 기계 · 작업량 조건). 표 하나를 고르면 그 표의 **값 칸이 곧 호표 후보 줄**(석공 · 보통인부 …). 원문 절 없이 만들 수도 있음(출처 「자체」).
|
||||
3. **호표 줄** — 줄마다 세 가지를 정함.
|
||||
- 무엇 — 인력 · 재료 · 기계 · 다른 로직. 인력은 직종 찾기 창(`GET /pick?kind=job`) · 재료는 구분 → 상세구분 → 후보(`GET /materials`) · 기계는 구분 → 기종 · 로직은 로직 목록에서.
|
||||
- 단위 — 고른 줄의 단위를 그대로 씀(재료 후보는 이 단위로 거름).
|
||||
- 수량 — 아래 셋 가운데 하나(②).
|
||||
- 비목은 자동 — 인력 노무비 · 재료 재료비 · 기계 경비(고칠 수 있음).
|
||||
4. **설계 입력** — 2·3 에서 고른 표의 `조건` 칸을 그대로 입력 후보로 제안. 조건 종류대로 칸 모양이 정해짐 — `고르기`는 표 줄에서 뽑은 목록 · `범위`는 아래·위 · `수`는 수 칸. 설계자에게 물을 것만 남기고 나머지는 붙박이 값으로 접음. 남긴 칸마다 **쉬운 말 한 줄(`설명`)** 과 단위를 받음.
|
||||
5. **덧줄·끝수** — 「노무비의 몇 %」 같은 줄은 비율 블록으로(비목 고르기 + 퍼센트). 끝수는 고르기(`원 미만 버림` · `원 미만 반올림` · 없음).
|
||||
6. **시험 계산** — 입력마다 견본 값을 넣고 `POST /calc` 에 `row`(저장 전 줄) · `file` 을 실어 보냄 — 파일에 안 씀. 막히면 까닭을 쉬운 말로(`plainReason`) 보이고 그 걸음으로 되돌림.
|
||||
7. **저장** — 소유(개인 · 공용) · 파일 고르기 → `POST /save` `op: add`. 검사에 걸리면(422) 아무것도 안 쓰고 까닭을 걸음별로 되짚음.
|
||||
4. **설계 입력** — 2·3 에서 고른 표의 조건 칸을 `GET /table/options` 로 받아 그대로 입력 후보로 제안(표 몸은 안 받음). 조건 종류대로 칸 모양이 정해짐 — `고르기`는 그 표 줄에 나온 값 목록 · `범위`는 아래·위 · `수`는 수 칸. 설계자에게 물을 것만 남기고 나머지는 붙박이 값으로 접음. 남긴 칸마다 **쉬운 말 한 줄(`설명`)** 과 단위를 받음.
|
||||
5. **덧줄·끝수** — 「노무비의 몇 %」 같은 줄은 비율 블록으로(비목 고르기 + 퍼센트). 끝수는 세 고르기로 받아 묶음 `{대상, 자리, 방법}` 을 만듦 — 대상 `계`(돈 로직) · `결과`(돈 아닌 로직) · 자리 0 이면 원 미만 · 방법 버림·올림·반올림. 엔진이 계산 맨 끝에 붙임.
|
||||
6. **시험 계산** — 입력마다 견본 값을 넣고 `POST /calc` 에 `key` "" 와 `row`(저장 전 줄)만 실어 보냄 — 키를 미리 받지 않고 파일에도 안 씀. 막히면 까닭을 쉬운 말로(`plainReason`) 보이고 그 걸음으로 되돌림.
|
||||
7. **저장** — 소유(현장 · 공용)만 고르고 `POST /logic/new` — 파일과 키는 서버가 잡음. 검사에 걸리면(422) 아무것도 안 쓰고 까닭을 걸음별로 되짚음. 저장이 끝나면 그 키를 방식 C 보기로 엶.
|
||||
|
||||
## ② 식을 글자로 치지 않는 법
|
||||
|
||||
@@ -57,34 +58,34 @@
|
||||
|
||||
## ③ 지금 식 언어·틀·엔진으로 되는 것과 모자란 것
|
||||
|
||||
되는 것(`_틀.md` 8장 · `scripts/master_formula.py`):
|
||||
되는 것(`_틀.md` 8장 · `scripts/master_formula.py` · `M01_MasterData/`):
|
||||
|
||||
- `찾기(표, 조건=값).값칸` · `로직(키, 입력=값)` · `만약` · `올림·버림·반올림` · `최소·최대` · 사칙·비교 · 비목 합 이름 · `{입력}` 끼우기 — 위 블록이 쓸 것은 모두 있음.
|
||||
- 저장 전 계산(`/calc` 의 `row`·`file`) · 새 로직 키 발급(`/save` `op: add`) · 저장 때 전체 검사(422) — 만들기에 필요한 서버 길은 이미 있음.
|
||||
- `찾기(표, 조건=값).값칸` · `로직(키, 입력=값)` · `만약` · `올림·버림·반올림` · `최소·최대` · 사칙·비교 · 비목 합 이름 · 덧줄의 `줄.'호표 줄 이름'` · `{입력}` 끼우기 — 위 블록이 쓸 것은 모두 있음.
|
||||
- 묶음 `끝수` 를 엔진이 계산 끝에 붙임(대상 `계`·`결과`) · 검사가 묶음 모양을 봄.
|
||||
- 절 목록(`GET /sections`) · 절로 표 거르기(`/tables?section=` — `13-4` 가 `13-40` 에 안 걸림) · 표 조건 값만 받기(`GET /table/options`) · 소유 거름(`/logics?owner=`).
|
||||
- 저장 안 한 초안 계산(`/calc` 에 `key` "" · `row`) · 자체 로직 만들기·본뜨기·고치기·지우기(`/logic/*` · 키 GX 는 서버 발급 · 정본은 403) · 저장 때 전체 검사(422).
|
||||
- 재료 후보 거르기(`/materials` — 구분 · 상세구분 · 규격 · 자재지역 · **호표 줄 단위**) · 표 거르기(`/tables` 의 `usage` = 용도 대상).
|
||||
|
||||
모자란 것(고쳐야 만들기 화면이 섬):
|
||||
모자란 것(화면 쪽 · 2-2·2-3 에서 고침):
|
||||
|
||||
1. **`끝수` 를 엔진이 안 씀** — 로직 줄의 칸일 뿐, `run()` 이 읽지 않아 계산에 안 붙음. 만들기에서 받으려면 엔진에 한 자리(비목 합·계에 적용) 필요 · 그 전에는 화면에 「적어만 둠」 으로 표시.
|
||||
2. **TS 타입이 재료 고르기 조건을 못 담음** — `M01_MasterData_UI_Logic_Api.ts` 의 `HoLine.요소: string` · `Logic_Edit.priceCell` 이 `요소.includes(…)` 를 불러 조건 묶음 줄에서 깨짐. `요소: string | 고르기조건` 으로 넓히기 전에는 만들기 화면이 재료 줄을 조건으로 못 세움(키로만 셈).
|
||||
3. **입력 `설명` 칸이 화면 타입에 없음** — `_틀.md` 7장엔 있고 산림 시험 대상 10개에 채워져 있으나 `LogicInput` 에 없어 마법사가 도움말로 못 씀.
|
||||
4. **절 목록 API 없음** — 책·장·절과 절 제목은 원문 본문 폴더 이름에 있음(`scripts/master_keys.py` 의 `title`). `GET /sections?book=&chapter=` 한 길 필요.
|
||||
5. **절로 표 거르기 없음** — 지금은 `/tables?q=13-4` 로 글자 찾기라 `13-40` 까지 걸림. `section=` 한 칸 필요.
|
||||
6. **표 조건 값 목록이 무거움** — 입력 후보를 뽑으려면 `/table` 로 표를 통째 받아야 함(40줄 표는 괜찮으나 큰 표는 무거움). `/table` 답에 `조건값: {칸: [값…]}` 요약 한 칸 권함.
|
||||
7. **소유 거름 없음** — `/logics` 에 `owner` 가 없어 개인 로직만 보기가 안 됨.
|
||||
1. **TS 타입이 재료 고르기 조건을 못 담음** — `M01_MasterData_UI_Logic_Api.ts` 의 `HoLine.요소: string` · `Logic_Edit.priceCell` 이 `요소.includes(…)` 를 불러 조건 묶음 줄에서 깨짐. `요소: string | 고르기조건` 으로 넓히기 전에는 만들기 화면이 재료 줄을 조건으로 못 세움(키로만 셈).
|
||||
2. **입력 `설명` 칸이 화면 타입에 없음** — `_틀.md` 7장엔 있고 산림 시험 대상 10개에 채워져 있으나 `LogicInput` 에 없어 마법사가 도움말로 못 씀.
|
||||
3. **`LogicRow` 에 `소유`·묶음 `끝수` 가 없음** — 목록에서 현장 로직을 가르고 끝수 걸음을 그리려면 타입에 둘 다 필요.
|
||||
4. **자체 로직 길을 부르는 화면 함수가 없음** — `Logic_Api.ts` 에 `/logic/new|copy|edit|delete` 를 부르는 넷과 `/sections` · `/table/options` 를 더해야 함.
|
||||
|
||||
## ④ 저장 자리
|
||||
|
||||
- **소유 칸** — 로직 줄의 `소유` = `공용`(원문 품셈을 옮긴 정본) · `개인`(현장에서 만든 것). 칸은 이미 있음 · 새 로직은 기본 `개인`.
|
||||
- **파일 가르기** — 정본 장 파일(`로직_산림품셈_13장_구조물.json`)에 개인 줄을 섞지 않음. 자체 로직은 `로직_자체_NN장_<제목>.json` — 파일 이름 규칙(`master_keys.CHAPTER`)이 `자체` 를 이미 받음 · 키는 `GX`(대장 `다음` 에 GX 가 없어 첫 줄이 `GX000001`).
|
||||
- **키 발급** — 화면은 키를 만들지 않음. `op: add` 로 보내면 서버가 `_키대장.json` 의 다음 번호를 줌. 대장 열쇠는 「절 번호 + 이름」 이라 줄의 절 번호와 겹치지 않음 — 같은 절에 로직을 둘 만들어도 새 키.
|
||||
- **막는 것** — 저장 전 검사(`check_saved`)를 그대로 받음 · 하나라도 걸리면 아무것도 안 씀. 「값 없는 요소」 처럼 관리자 몫인 까닭은 저장은 되고 계산만 멈춤.
|
||||
- 프로젝트별·사용자별 자리는 뒤 단계 — 마스터에는 사람 개념이 없음. 지금은 「개인 = 이 설치본의 현장 로직」.
|
||||
- **소유 칸** — 로직 줄의 `소유` = `공용`(원문 품셈을 옮긴 정본) · `현장`(현장에서 만든 것). 새 로직은 기본 `현장` · 목록은 `/logics?owner=` 로 가름.
|
||||
- **파일 가르기** — 정본 장 파일(`로직_산림품셈_13장_구조물.json`)에 현장 줄을 섞지 않음. 자체 로직은 `로직_자체.json` 한 파일 — 틀이 「자체」 파일에 장 이름을 두지 않아 장으로 쪼개지 않음. 줄의 `상세구분` 에 「13장 구조물」 이 들어가 목록에서 장별로 모임.
|
||||
- **키 발급** — 화면은 키를 만들지 않음. `POST /logic/new` 면 서버가 `_키대장.json` 의 `GX` 다음 번호를 줌(첫 줄이 `GX000001`). 대장 열쇠는 「절 번호 + 이름」 이고 겹치면 뒤에 번호를 붙여 — 같은 절에 로직을 둘 만들어도 키가 갈림.
|
||||
- **못 고치는 것** — 정본 로직은 `/logic/edit`·`/logic/delete` 가 403. 고치려면 `/logic/copy` 로 본뜬 뒤 고침.
|
||||
- **막는 것** — 저장 전 검사(`check_saved`)를 그대로 받음 · 하나라도 걸리면 아무것도 안 씀. 「값 없는 요소」 처럼 관리자 몫인 까닭은 저장은 되고 계산만 멈춤. 지우려는 로직을 다른 로직이 부르면 422.
|
||||
- 프로젝트별·사용자별 자리는 뒤 단계 — 마스터에는 사람 개념이 없음. 지금은 「현장 = 이 설치본에서 만든 로직」.
|
||||
|
||||
## ⑤ 기존 로직 본떠 만들기
|
||||
|
||||
- 로직 목록·시험 컨테이너에서 「이것처럼 새로 만들기」 한 단추 — 고른 로직을 복사해 키 빈 줄로 만들기 걸음 7 번(저장) 직전 상태로 엶.
|
||||
- 복사할 때 바꾸는 것 — 키 비움 · 소유 `개인` · 파일 자체 파일 · 이름 뒤에 「(수정)」 · 출처는 본뜬 원문 절 그대로 두고 비고에 「<키> 를 본뜸」 한 줄.
|
||||
- 방식 C 보기에서 「이것처럼 새로 만들기」 한 단추 — `POST /logic/copy` 한 번이면 자체 파일에 새 키로 복사본이 생기고, 그 키를 방식 C 로 열어 상자에서 고침.
|
||||
- 서버가 바꾸는 것 — 새 키 · 소유 `현장` · 파일 `로직_자체.json` · 비고 끝에 「<본뜬 키> 를 본뜸」. 이름은 보내면 그 이름 · 출처와 시험입력은 본뜬 줄 그대로.
|
||||
- 걸음은 같음 — 이름 → (원문 절은 그대로 두고) 호표 줄 고치기 → 입력 다듬기 → 시험 계산 → 저장.
|
||||
- 가장 흔한 길 — 원문 품셈 로직에서 재료 한 줄만 현장 자재로 바꾸기 · 덧줄 비율만 바꾸기.
|
||||
- 본뜬 뒤 원본은 안 바뀜 — 시험 컨테이너의 「바꿔 보기」(`swappedRow`)와 같은 방식으로 복사본에만 씀.
|
||||
@@ -92,29 +93,36 @@
|
||||
## ⑥ 화면 구성과 구현 조각
|
||||
|
||||
```
|
||||
로직 화면(관리자) 테스트 컨테이너
|
||||
┌───────────────┐ ┌─────────────────────┐
|
||||
│ 목록 · 고급 편집기 │◀──「고급」──│ [만들기] A 확장 마법사 │
|
||||
└───────────────┘ │ [보기] C 흐름 그림 │
|
||||
▲ └─────────────────────┘
|
||||
└─────── 같은 서버 길(/calc · /save) · 같은 부품(Pick) ───────┘
|
||||
일위대가 로직 화면
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 목록(소유 거름) │ [새로 만들기] ──▶ 모달 = 방식 A │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 기본 보기 = 방식 C 흐름 그림 │
|
||||
│ 상자 누름 → 값·출처·원문 표·쉬운 말 풀이 │
|
||||
│ 자체 로직이면 상자에서 바로 고치기·줄 더하기·지우기 │
|
||||
│ 정본이면 [이것처럼 새로 만들기] 만 │
|
||||
├──────────────────────────────────────────┤
|
||||
│ [고급] 옛 호표 편집기(글자 식) │
|
||||
└──────────────────────────────────────────┘
|
||||
저장 끝 ──▶ 그 키를 방식 C 로 엶
|
||||
```
|
||||
|
||||
- **만들기 = A 확장** — A 는 「입력을 묻고 계산」 · 만들기는 그 앞에 「무엇을 만들지」 걸음을 더한 같은 마법사 틀(한 번에 한 질문 · 뒤로 · 다시).
|
||||
- **보기 = C** — 저장 직전과 저장 뒤 모두 C 흐름 그림으로 전체를 보임(설계 값 → 표 찾기 → 수량 → × 단가 → 덧줄 → 비목 → 계). `Test_C_Model.ts` 는 순수 함수라 저장 전 줄에도 그대로 씀.
|
||||
- **만들기 = 모달 + A** — 한 번에 한 질문 · 뒤로 · 다시. A 는 「입력을 묻고 계산」 이었고 만들기는 그 앞에 「무엇을 만들지」 걸음(①)을 더한 같은 틀.
|
||||
- **보기·고치기 = C** — 저장 직전 미리보기와 저장 뒤 보기가 같은 그림(설계 값 → 표 찾기 → 수량 → × 단가 → 덧줄 → 비목 → 계). `Test_C_Model.ts` 는 순수 함수라 저장 전 줄에도 그대로 씀.
|
||||
- 테스트 컨테이너(`UI_Test`)와 방식 B 화면 · 방식 A 의 읽기 전용 화면은 모달로 옮긴 뒤 걷음.
|
||||
|
||||
파일 경계(새로 만들 것 · 한 파일 700줄 아래):
|
||||
|
||||
| 파일 | 몫 |
|
||||
| --- | --- |
|
||||
| `M01_MasterData_UI_New.ts` | 걸음 틀 · 만드는 중인 줄 한 벌 · 앞뒤 이동 · 저장 |
|
||||
| `M01_MasterData_UI_New_Steps.ts` | 걸음별 화면(이름 · 절 · 호표 · 입력 · 덧줄) |
|
||||
| `M01_MasterData_UI_New_Qty.ts` | 수량 블록(직접 값 · 표에서 찾기 · 다른 로직 · 할증 · 조건) → 식 글자 |
|
||||
| `M01_MasterData_UI_New_Text.ts` | 화면 글(쉬운 말) |
|
||||
| 파일 | 몫 |
|
||||
| -------------------------------- | -------------------------------------------------------------------- |
|
||||
| `M01_MasterData_UI_New.ts` | 모달 틀 · 만드는 중인 줄 한 벌 · 앞뒤 이동 · 저장 |
|
||||
| `M01_MasterData_UI_New_Steps.ts` | 걸음별 화면(이름 · 절 · 호표 · 입력 · 덧줄·끝수) |
|
||||
| `M01_MasterData_UI_New_Qty.ts` | 수량 블록(직접 값 · 표에서 찾기 · 다른 로직 · 할증 · 조건) → 식 글자 |
|
||||
| `M01_MasterData_UI_New_Text.ts` | 화면 글(쉬운 말) |
|
||||
|
||||
그대로 쓰는 것 — `Test_Pick.ts`(재료·기계 고르기) · `Logic_Api.ts`(`fetchLogic` · `runCalc` · `saveFiles` · `fetchMaterials`) · `Test_C*.ts`(보기) · `Logic_Edit.ts`(고급) · `Test_Text.ts`(`plainReason`).
|
||||
그대로 쓰는 것 — `Test_Pick.ts`(재료·기계 고르기) · `Logic_Api.ts`(`fetchLogic` · `runCalc` · `fetchMaterials`) · `Test_C*.ts`(기본 보기로 옮김) · `Logic_Edit.ts`(고급) · `Test_Text.ts`(`plainReason`).
|
||||
|
||||
서버에 더할 것 — `GET /sections`(책·장·절 목록) · `/tables` 의 `section` 거름 · `/table` 답의 `조건값` 요약 · `/logics` 의 `owner` 거름 · `Logic_Api` 타입 셋(`HoLine.요소` 넓히기 · `LogicInput.설명` · `LogicRow.소유`).
|
||||
`Logic_Api.ts` 에 더할 것 — 새 길 여섯(`/sections` · `/table/options` · `/logic/new|copy|edit|delete`)과 타입 넷(`HoLine.요소` 넓히기 · `LogicInput.설명` · `LogicRow.소유` · 묶음 `끝수`).
|
||||
|
||||
## ⑦ 예 — 산림 13-4-1 메쌓기를 빈 화면에서
|
||||
|
||||
@@ -132,14 +140,14 @@
|
||||
— 표 마지막 칸이 범위 값(80∼100)이라 그 자리는 설계자 입력 `초과증가율` 로 받음(`_틀.md` 6장 · 값 칸 범위는 입력으로).
|
||||
5. **설계 입력** — 3·4 에서 물은 칸이 그대로 입력이 됨.
|
||||
|
||||
| 이름 | 모양 | 설명(쉬운 말) |
|
||||
| --- | --- | --- |
|
||||
| 뒷길이 | 고르기 25·30·35·45·55·60·75 (㎝) | 쌓을 돌의 뒤쪽 길이 |
|
||||
| 돌 | 고르기 견치돌·깬돌·깬잡석·호박돌 및 야면석 | 쌓을 돌 종류 |
|
||||
| 쌓기 | 고르기 골쌓기·켜쌓기 | 돌을 어긋나게 쌓는지 줄 맞춰 쌓는지 |
|
||||
| 높이 | 수 (m) | 쌓는 벽 높이 |
|
||||
| 초과증가율 | 범위 80∼100 (%) | 기준 높이를 넘어 더 드는 품 비율 |
|
||||
| 이름 | 모양 | 설명(쉬운 말) |
|
||||
| ---------- | ------------------------------------------ | ----------------------------------- |
|
||||
| 뒷길이 | 고르기 25·30·35·45·55·60·75 (㎝) | 쌓을 돌의 뒤쪽 길이 |
|
||||
| 돌 | 고르기 견치돌·깬돌·깬잡석·호박돌 및 야면석 | 쌓을 돌 종류 |
|
||||
| 쌓기 | 고르기 골쌓기·켜쌓기 | 돌을 어긋나게 쌓는지 줄 맞춰 쌓는지 |
|
||||
| 높이 | 수 (m) | 쌓는 벽 높이 |
|
||||
| 초과증가율 | 범위 80∼100 (%) | 기준 높이를 넘어 더 드는 품 비율 |
|
||||
|
||||
6. **덧줄·끝수** — 원문에 잡재료·할증 줄이 없어 덧줄 없음 · 끝수 없음.
|
||||
7. **시험 계산** — 견본으로 뒷길이 35 · 돌 깬잡석 · 쌓기 골쌓기 · 높이 4 · 초과증가율 80 을 넣어 호표 두 줄과 계를 확인. 표에 없는 조합(뒷길이 25 · 견치돌 등)은 「맞는 줄 0개」 로 멈추므로 화면이 「원문 표에 없는 조합」 으로 되짚어 줌.
|
||||
8. **저장** — 소유 `개인` · 파일 `로직_자체_13장_구조물.json` → 키 `GX……` 발급. 공용 정본으로 올리는 것은 관리자 몫(뒤 단계).
|
||||
6. **덧줄·끝수** — 원문에 잡재료·할증 줄이 없어 덧줄 없음 · 끝수 없음(원문에 끊기 규정이 없음).
|
||||
7. **시험 계산** — 견본으로 뒷길이 35 · 돌 깬잡석 · 쌓기 골쌓기 · 높이 4 · 초과증가율 80 을 넣고 `POST /calc` 에 `key` "" 와 초안 줄만 보내 호표 두 줄과 계를 확인. 표에 없는 조합(뒷길이 25 · 견치돌 등)은 「맞는 줄 0개」 로 멈추므로 화면이 「원문 표에 없는 조합」 으로 되짚어 줌.
|
||||
8. **저장** — 소유 `현장` 만 고르고 `POST /logic/new` → 서버가 `로직_자체.json` 에 키 `GX……` 로 넣고 `상세구분` 을 「13장 구조물」 로 채움. 저장 끝나면 그 키가 방식 C 로 열림. 공용 정본으로 올리는 것은 관리자 몫(뒤 단계).
|
||||
|
||||
@@ -426,6 +426,21 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
|
||||
raise FormulaError(f"모르는 마디 {kind}")
|
||||
|
||||
|
||||
ROUND_WAYS = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}
|
||||
ROUND_ON = ("계", "결과")
|
||||
|
||||
|
||||
def cut(row: dict, value: Decimal, on: str) -> Decimal:
|
||||
"""줄의 `끝수` 를 붙임 — 묶음 `{대상, 자리, 방법}` 만 · 글자 끝수는 원문 근거를 적은 설명."""
|
||||
rule = row.get("끝수")
|
||||
if not isinstance(rule, dict) or rule.get("대상") != on:
|
||||
return value
|
||||
way = ROUND_WAYS.get(str(rule.get("방법")))
|
||||
if way is None:
|
||||
raise FormulaError(f"「{_name(row)}」 끝수 방법 「{rule.get('방법')}」")
|
||||
return _round(value, rule.get("자리") or 0, way)
|
||||
|
||||
|
||||
def _inputs(row: dict, given: dict) -> dict:
|
||||
env = {}
|
||||
for spec in row.get("입력", []):
|
||||
@@ -463,7 +478,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
for step in row.get("중간", []):
|
||||
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
|
||||
if "결과" in row:
|
||||
return {"결과": evaluate(parse(row["결과"]["식"]), env, master, depth), "중간": env}
|
||||
value = evaluate(parse(row["결과"]["식"]), env, master, depth)
|
||||
return {"결과": cut(row, value, "결과"), "중간": env}
|
||||
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
|
||||
lines = []
|
||||
for item in row.get("호표", []):
|
||||
@@ -526,6 +542,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
"비목": {extra["비목"]: value},
|
||||
}
|
||||
)
|
||||
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": env}
|
||||
|
||||
|
||||
@@ -679,9 +696,34 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
|
||||
names = names | {extra["이름"]}
|
||||
if extra.get("비목") not in COST_ITEMS:
|
||||
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」")
|
||||
out += _check_cut(row, key)
|
||||
return out, calls
|
||||
|
||||
|
||||
def _check_cut(row: dict, key: str) -> list[str]:
|
||||
"""끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함)."""
|
||||
rule = row.get("끝수")
|
||||
if rule is None or isinstance(rule, str):
|
||||
return []
|
||||
if not isinstance(rule, dict):
|
||||
return [f"{key} 끝수 · 묶음도 글자도 아님"]
|
||||
out = []
|
||||
unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"}
|
||||
if unknown:
|
||||
out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}")
|
||||
on = rule.get("대상")
|
||||
if on not in ROUND_ON:
|
||||
out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}")
|
||||
elif ("결과" in row) != (on == "결과"):
|
||||
out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음")
|
||||
if rule.get("방법") not in ROUND_WAYS:
|
||||
out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}")
|
||||
place = rule.get("자리", 0)
|
||||
if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6:
|
||||
out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수")
|
||||
return out
|
||||
|
||||
|
||||
def find_loops(graph: dict[str, set]) -> list[list[str]]:
|
||||
"""로직 부르기의 돌고 도는 길."""
|
||||
loops, state = [], {}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""M01 「만들기」 API — 절 목록 · 절 거르기 · 표 조건 값 · 소유 거름 · 자체 로직 · 끝수.
|
||||
|
||||
계약 `resources/master_data/_화면_계약.md` 2장·5장·6장. 임시 폴더 사본에서 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
|
||||
REAL = store.FOLDER
|
||||
LABOR = "LB000002" # 보통인부
|
||||
SAMPLE = "GF000219" # 산림 13-4-1 메쌓기(인력)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
for path in REAL.glob("*.json"):
|
||||
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
|
||||
shutil.copy(path, tmp_path / path.name)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _get(client: TestClient, url: str, **params) -> dict:
|
||||
res = client.get(url, params=params)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
||||
res = client.post(url, json=body)
|
||||
assert res.status_code == want, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _draft(끝수=None) -> dict:
|
||||
"""보통인부 1/7 인 한 줄짜리 초안 — 저장 안 함."""
|
||||
return {
|
||||
"원문번호": "",
|
||||
"이름": "시험 초안",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": LABOR,
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": "1 / 7",
|
||||
"비목": "노무비",
|
||||
}
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": 끝수,
|
||||
}
|
||||
|
||||
|
||||
# ── 끝수 ──────────────────────────────────────────────────────────────
|
||||
def test_끝수_묶음이_계에_붙고_글자는_안_붙는다(client: TestClient) -> None:
|
||||
plain = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft()})
|
||||
assert plain["ok"] is True, plain
|
||||
raw = Decimal(str(plain["sums"]["계"]))
|
||||
assert raw != raw.to_integral_value() # 172068 / 7 — 원 미만이 남음
|
||||
|
||||
cut = _post(
|
||||
client,
|
||||
"/api/m01/calc",
|
||||
{"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": 0, "방법": "버림"})},
|
||||
)
|
||||
assert Decimal(str(cut["sums"]["계"])) == raw.to_integral_value(rounding="ROUND_DOWN")
|
||||
assert cut["sums"]["노무비"] == cut["sums"]["계"] # 비목 합마다 끊고 계 = 그 합
|
||||
|
||||
text = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft("원 미만 버림")})
|
||||
assert Decimal(str(text["sums"]["계"])) == raw # 글자 끝수는 설명일 뿐
|
||||
|
||||
|
||||
def test_끝수_묶음_모양을_검사가_잡는다() -> None:
|
||||
bad = {"키": "GX000001", "원문번호": "", "호표": [], "끝수": {"대상": "줄", "방법": "내림"}}
|
||||
found = store.cm.mf.check_logic(store.cm.master(REAL), bad)[0]
|
||||
assert any("대상" in x for x in found) and any("방법" in x for x in found)
|
||||
|
||||
|
||||
# ── 절 목록 · 절 거르기 · 표 조건 값 ──────────────────────────────────
|
||||
def test_절_목록과_절로_표_거르기(client: TestClient) -> None:
|
||||
got = _get(client, "/api/m01/sections", book="산림품셈", chapter="13")
|
||||
one = next(s for s in got["sections"] if s["section"] == "13-4-1")
|
||||
assert one["title"].startswith("메쌓기") and one["chapter"] == "13장 구조물"
|
||||
assert one["tables"] >= 1 and one["logics"] >= 1
|
||||
assert client.get("/api/m01/sections", params={"book": "없는품셈"}).status_code == 404
|
||||
|
||||
hits = _get(client, "/api/m01/tables", group="소요량", section="13-4", size=500)["tables"]
|
||||
assert hits and all(store.in_section(t["원문번호"], "13-4") for t in hits)
|
||||
assert not store.in_section("13-40", "13-4") and store.in_section("13-4", "13-4")
|
||||
|
||||
|
||||
def test_표_조건_값만_받는다(client: TestClient) -> None:
|
||||
listed = _get(client, "/api/m01/tables", group="소요량", section="13-4-1", size=50, q="메쌓기")[
|
||||
"tables"
|
||||
]
|
||||
one = listed[0]
|
||||
got = _get(client, "/api/m01/table/options", file=one["file"], key=one["키"])
|
||||
assert got["count"] >= 1 and "줄" not in got
|
||||
for name, kind in got["조건"].items():
|
||||
assert name in got["값"]
|
||||
if kind == "고르기":
|
||||
assert len(got["값"][name]) == len(set(map(str, got["값"][name])))
|
||||
|
||||
|
||||
def test_로직_목록을_소유로_거른다(client: TestClient) -> None:
|
||||
every = _get(client, "/api/m01/logics")["logics"]
|
||||
assert all("소유" in x for x in every)
|
||||
only = _get(client, "/api/m01/logics", owner="공용")["logics"]
|
||||
assert only and all(x["소유"] == "공용" for x in only)
|
||||
assert _get(client, "/api/m01/logics", owner="현장")["logics"] == []
|
||||
|
||||
|
||||
# ── 자체 로직 ─────────────────────────────────────────────────────────
|
||||
def test_자체_로직_만들기_본뜨기_고치기_지우기(client: TestClient) -> None:
|
||||
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
|
||||
assert made["key"].startswith("GX") and made["file"] == make.OWN
|
||||
assert made["logic"]["구분"] == "자체" and made["logic"]["소유"] == "현장"
|
||||
assert (store.FOLDER / make.OWN).is_file()
|
||||
|
||||
again = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
|
||||
assert again["key"] != made["key"] # 원문번호가 같아도 키는 하나씩
|
||||
|
||||
copied = _post(client, "/api/m01/logic/copy", {"key": SAMPLE, "이름": "메쌓기 본뜸"})
|
||||
assert copied["key"] != SAMPLE and f"{SAMPLE} 를 본뜸" in copied["logic"]["비고"]
|
||||
assert copied["logic"]["이름"] == "메쌓기 본뜸" and copied["logic"]["소유"] == "현장"
|
||||
assert copied["logic"]["상세구분"] == "13장 구조물"
|
||||
|
||||
fixed = dict(copied["logic"], 이름="고친 이름")
|
||||
done = _post(
|
||||
client,
|
||||
"/api/m01/logic/edit",
|
||||
{"key": copied["key"], "version": copied["version"], "logic": fixed},
|
||||
)
|
||||
assert done["logic"]["이름"] == "고친 이름" and done["logic"]["키"] == copied["key"]
|
||||
|
||||
stale = {"key": copied["key"], "version": copied["version"], "logic": fixed}
|
||||
_post(client, "/api/m01/logic/edit", stale, want=409)
|
||||
|
||||
gone = _post(
|
||||
client, "/api/m01/logic/delete", {"key": copied["key"], "version": done["version"]}
|
||||
)
|
||||
assert gone["file"] == make.OWN
|
||||
assert client.get("/api/m01/logic", params={"key": copied["key"]}).status_code == 404
|
||||
|
||||
|
||||
def test_정본_로직은_못_고친다(client: TestClient) -> None:
|
||||
got = _get(client, "/api/m01/logic", key=SAMPLE)
|
||||
body = {"key": SAMPLE, "version": got["version"], "logic": got["logic"]}
|
||||
_post(client, "/api/m01/logic/edit", body, want=403)
|
||||
_post(client, "/api/m01/logic/delete", {"key": SAMPLE, "version": got["version"]}, want=403)
|
||||
_post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "남의것"}, want=400)
|
||||
|
||||
|
||||
def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> None:
|
||||
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "공용"})
|
||||
got = _post(client, "/api/m01/calc", {"key": made["key"], "inputs": {}})
|
||||
assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0
|
||||
listed = _get(client, "/api/m01/logics", owner="공용")["logics"]
|
||||
assert made["key"] in [x["키"] for x in listed]
|
||||
Reference in New Issue
Block a user