From 964552c2ba5dbcfb886d476b6bd5320bdbe6f612 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 09:18:39 +0900 Subject: [PATCH 1/4] =?UTF-8?q?knowledge(=EB=A7=88=EC=8A=A4=ED=84=B0):=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EC=93=B8=20=EC=83=88=20API=20=EA=B3=84=EC=95=BD=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=88=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=A0=88=20=EA=B1=B0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=C2=B7=ED=91=9C=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EA=B0=92=C2=B7=EC=86=8C=EC=9C=A0=20=EA=B1=B0=EB=A6=84=C2=B7?= =?UTF-8?q?=EC=9E=90=EC=B2=B4=20=EB=A1=9C=EC=A7=81=C2=B7=EB=81=9D=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `GET /sections` · `GET /table/options` · `/tables` 의 `section` · `/logics` 의 `owner` - 자체 로직 `POST /logic/new|copy|edit|delete`(키 GX 서버 발급 · 파일 로직_자체_NN장_….json · 소유 현장/공용 · 정본은 403) - 저장 안 한 초안 시험 계산(`key` "" · `file` 없이) · 끝수 묶음 {대상, 자리, 방법} Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- resources/master_data/_화면_계약.md | 62 +++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 79467c42..42b93e99 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -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, 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` = 절 번호·제목 찾기). `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. 고치려면 먼저 복제. + +- 자리 = `로직_자체_NN장_<장 제목>.json`(`NN` = 원문 절 번호의 장 · 원문 절이 없으면 `로직_자체_00장_자체.json`) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "차례": 90, "판": <올해>, "줄": []}`)와 함께 만듦. +- 키 = 서버가 `_키대장.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 = 소수 넷째 자리에서). +- `방법` — `버림` · `올림` · `반올림`. +- 옛 줄의 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적어 둔 설명 — 계산에 안 붙음(그 끊기는 이미 식 안에 있음). From aa86e02b8f745d220eb268aeadb5efc85a3ee04c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 09:31:45 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(M01):=20=EB=81=9D=EC=88=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=B6=99=EC=9E=84=20=C2=B7=20=EC=A0=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=A0=88=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=C2=B7?= =?UTF-8?q?=ED=91=9C=20=EC=A1=B0=EA=B1=B4=20=EA=B0=92=C2=B7=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=20=EA=B1=B0=EB=A6=84=C2=B7=EC=9E=90=EC=B2=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진 — 로직 줄의 묶음 `끝수`{대상, 자리, 방법}를 계산 끝에 붙임(비목 합마다 끊고 계 = 그 합 · 돈 아닌 로직은 결과) · 글자 끝수는 설명 그대로 · 검사가 묶음 모양을 봄 - 서버 — `GET /sections`(원문 본문 절 목록 + 그 절의 표·로직 수) · `/tables` 의 `section`(13-4 가 13-40 에 안 걸림) · `GET /table/options`(조건 값만) · `/logics` 의 `owner` - 자체 로직 — `POST /logic/new|copy|edit|delete` · 파일 `로직_자체.json` · 키 GX 서버 발급(원문번호 같아도 줄마다 다른 키) · 정본은 403 · 저장 안 한 초안은 `/calc` 에 `key` "" 로 - 저장소가 700줄을 넘어 쓰기 모양(`keep_shape`·`dump`)을 `M01_MasterData_Store_Shape.py` 로 가름 - 시험 `resources/tester/test_m01_make.py` 8건 통과 · 기존 M01 시험 25건 통과 · 일괄 시험 계산 1,291 그대로 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Router.py | 67 ++++- M01_MasterData/M01_MasterData_Store.py | 146 ++++------- M01_MasterData/M01_MasterData_Store_Make.py | 239 ++++++++++++++++++ M01_MasterData/M01_MasterData_Store_Shape.py | 100 ++++++++ resources/master_data/_틀.md | 3 +- resources/master_data/_화면_계약.md | 10 +- .../master_data/scripts/master_formula.py | 44 +++- resources/tester/test_m01_make.py | 178 +++++++++++++ 8 files changed, 678 insertions(+), 109 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Make.py create mode 100644 M01_MasterData/M01_MasterData_Store_Shape.py create mode 100644 resources/tester/test_m01_make.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index b0236019..2edbe080 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -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) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index fcb04e1c..aa4b2a7a 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -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") diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py new file mode 100644 index 00000000..9cf6377c --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -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}]) diff --git a/M01_MasterData/M01_MasterData_Store_Shape.py b/M01_MasterData/M01_MasterData_Store_Shape.py new file mode 100644 index 00000000..c3b8f627 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Shape.py @@ -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") diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index 7c730c7a..20d0f130 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -194,7 +194,7 @@ "중간": [{ "이름": "증가율", "식": "…" }], "호표": [{ "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" }], "덧줄": [{ "이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비", "출처": "…" }], - "끝수": "원 미만 버림" + "끝수": { "대상": "계", "자리": 0, "방법": "버림", "출처": "산림품셈 13-4-1" } } ] } @@ -204,6 +204,7 @@ - `종류` = 인력 · 재료 · 기계 · 로직(하위 로직의 결과를 단가로 씀) — 요소 키의 테이블ID 그룹과 같음. - `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀. - `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식. +- `끝수` = 묶음 `{대상, 자리, 방법, 출처?, 비고?}` — 엔진이 계산 맨 끝에 붙임. `대상` 은 `계`(비목 합 셋을 각각 끊고 계 = 그 합) · `결과`(돈 아닌 로직) · `자리` 는 소수 자리(0 = 원 미만) · `방법` 은 `버림`·`올림`·`반올림`. 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적은 설명일 뿐 — 그 끊기는 식 안에 이미 있음. - `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음. 재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음: diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 42b93e99..9acb3067 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -23,7 +23,7 @@ | `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, section, title, tables, logics}]}` — 원문 절 목록 | +| `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, 원문번호, 구분, 상세구분, 이름, 규격, 단위, 값, 값칸}]}` | @@ -39,7 +39,7 @@ - `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` = 절 번호·제목 찾기). `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절. +- `/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 키. @@ -80,9 +80,9 @@ 정본 로직(`소유` 「공용」 · `로직_산림품셈…` · `로직_건설품셈…`)은 이 길로 못 고침 — 403. 고치려면 먼저 복제. -- 자리 = `로직_자체_NN장_<장 제목>.json`(`NN` = 원문 절 번호의 장 · 원문 절이 없으면 `로직_자체_00장_자체.json`) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "차례": 90, "판": <올해>, "줄": []}`)와 함께 만듦. -- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음. -- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」 은 서버가 넣음. +- 자리 = `로직_자체.json` 한 파일(원문 장으로 나누지 않음 — 틀이 「자체」 파일에 장 이름을 두지 않음) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "판": <올해>, "줄": []}`)와 함께 만듦. +- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음. 원문 절이 같아도 줄마다 다른 키. +- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」(`출처`·`원문번호` 에서 서버가 찾음 · 원문 절이 없으면 빈 글)은 서버가 넣음. | 길 | 받음 | 줌 | | -------------------- | ----------------------- | ------------------------------------------------------------ | diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index b3fbd0c2..fed17bbe 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -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 = [], {} diff --git a/resources/tester/test_m01_make.py b/resources/tester/test_m01_make.py new file mode 100644 index 00000000..3ccb36cc --- /dev/null +++ b/resources/tester/test_m01_make.py @@ -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] From 5759d99803061a7918955a058df2659093595d6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 09:34:25 +0900 Subject: [PATCH 3/4] =?UTF-8?q?knowledge(=EB=A7=88=EC=8A=A4=ED=84=B0):=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EA=B8=80=EC=9D=84=20PLAN=202=EC=9E=A5(=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20C=20=EA=B8=B0=EB=B3=B8=20+=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20A)=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보기·고치기 = 방식 C 기본 · 옛 호표 편집기는 고급 · 만들기 = [새로 만들기] 모달 + 방식 A - 모자란 것을 화면 쪽 넷으로 줄임(엔진·서버 일곱 가운데 다섯은 붙었음) - 저장 자리 · 본뜨기 · 예(13-4-1)를 새 API 로 고쳐 씀 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- .../master_data/ref/_설계_로직_만들기.md | 114 ++++++++++-------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/resources/master_data/ref/_설계_로직_만들기.md b/resources/master_data/ref/_설계_로직_만들기.md index ff7dc1af..4ee92294 100644 --- a/resources/master_data/ref/_설계_로직_만들기.md +++ b/resources/master_data/ref/_설계_로직_만들기.md @@ -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 로 열림. 공용 정본으로 올리는 것은 관리자 몫(뒤 단계). From 98e40f44961c95f4b0befd61ca6a6c3f2b529422 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 09:35:33 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(M01):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EA=B8=B0=EB=B3=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=ED=9D=90=EB=A6=84=20=EA=B7=B8=EB=A6=BC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=83=81=EC=9E=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B3=A0=20=EA=B3=A0=EC=B9=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방식 C 를 테스트 컨테이너에서 꺼내 로직 화면 기본 보기로 올림(옛 호표 표는 「고급」). 상자를 누르면 값·출처에 더해 쉬운 말 풀이와 원문 표 미리보기(걸린 줄 강조)가 펼쳐짐. 자체 로직은 [고치기] 로 상자 안에서 고치고 줄을 더하거나 지움 — 정본은 「본떠 만들기」 뒤. - UI_Test_C·_Model·_Style → UI_Logic_Flow·_Model·_Style - UI_Test_Pick 의 재료·기계 바꿔 고르기를 UI_Logic_Pick 에 합침 - 방식 B 의 설명 카드 로직을 UI_Logic_Note 로 옮겨 흐름 그림과 같이 씀 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- .../M01_MasterData_UI_Logic_Flow.ts | 533 ++++++++++++++++++ ... => M01_MasterData_UI_Logic_Flow_Model.ts} | 2 +- ...=> M01_MasterData_UI_Logic_Flow_Style.css} | 87 ++- .../M01_MasterData_UI_Logic_Note.ts | 286 ++++++++++ .../M01_MasterData_UI_Logic_Page.ts | 48 +- .../M01_MasterData_UI_Logic_Pick.ts | 173 +++++- .../M01_MasterData_UI_Logic_Text.ts | 82 +++ M01_MasterData/M01_MasterData_UI_Test.ts | 2 +- M01_MasterData/M01_MasterData_UI_Test_A.ts | 2 +- M01_MasterData/M01_MasterData_UI_Test_B.ts | 248 +------- M01_MasterData/M01_MasterData_UI_Test_C.ts | 288 ---------- M01_MasterData/M01_MasterData_UI_Test_Pick.ts | 172 ------ .../M01_MasterData_UI_Test_Style.css | 18 - 13 files changed, 1213 insertions(+), 728 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Flow.ts rename M01_MasterData/{M01_MasterData_UI_Test_C_Model.ts => M01_MasterData_UI_Logic_Flow_Model.ts} (99%) rename M01_MasterData/{M01_MasterData_UI_Test_C_Style.css => M01_MasterData_UI_Logic_Flow_Style.css} (66%) create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Note.ts delete mode 100644 M01_MasterData/M01_MasterData_UI_Test_C.ts delete mode 100644 M01_MasterData/M01_MasterData_UI_Test_Pick.ts diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts b/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts new file mode 100644 index 00000000..80df10cc --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts @@ -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 = {}; + // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) + 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(); + const subs = new Map(); + 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(`[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 = {}; + 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(); +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_C_Model.ts b/M01_MasterData/M01_MasterData_UI_Logic_Flow_Model.ts similarity index 99% rename from M01_MasterData/M01_MasterData_UI_Test_C_Model.ts rename to M01_MasterData/M01_MasterData_UI_Logic_Flow_Model.ts index a7a8a656..b2d6e088 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_C_Model.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Flow_Model.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * M01_MasterData_UI_Test_C_Model.ts + * M01_MasterData_UI_Logic_Flow_Model.ts * 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자. * 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 * diff --git a/M01_MasterData/M01_MasterData_UI_Test_C_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Flow_Style.css similarity index 66% rename from M01_MasterData/M01_MasterData_UI_Test_C_Style.css rename to M01_MasterData/M01_MasterData_UI_Logic_Flow_Style.css index 434e52b1..4a43d7fa 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_C_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Flow_Style.css @@ -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; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts new file mode 100644 index 00000000..612cf0e0 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts @@ -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; + 값칸?: Record; + 줄?: Record[]; +} + +const tableCache = new Map>(); + +async function getJson(path: string): Promise { + 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 { + const cached = tableCache.get(key); + if (cached) return cached; + const job = (async (): Promise => { + 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; + middle: Record; +} + +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 | 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 }), + }), + ], + ), + ); + }, + ); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index 0de623fd..b3ca491d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -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 = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -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() }), diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts index 5f59798a..9a788695 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts @@ -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; + +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; +}; + +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>> { + 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) : 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") })], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 1126a01d..615687bd 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -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; 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 { const entry = TEXT[key]; diff --git a/M01_MasterData/M01_MasterData_UI_Test.ts b/M01_MasterData/M01_MasterData_UI_Test.ts index 570d1644..00f514c4 100644 --- a/M01_MasterData/M01_MasterData_UI_Test.ts +++ b/M01_MasterData/M01_MasterData_UI_Test.ts @@ -9,7 +9,7 @@ import { createSelectField, el, showToast } from "@ui/ui_template_elements"; import { fetchLogics } from "./M01_MasterData_UI_Logic_Api"; 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"; diff --git a/M01_MasterData/M01_MasterData_UI_Test_A.ts b/M01_MasterData/M01_MasterData_UI_Test_A.ts index b2f08693..6717ff80 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_A.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_A.ts @@ -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 = diff --git a/M01_MasterData/M01_MasterData_UI_Test_B.ts b/M01_MasterData/M01_MasterData_UI_Test_B.ts index 482edaab..e582fc41 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_B.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_B.ts @@ -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; - 값칸?: Record; - 줄?: Record[]; -} - -const tableCache = new Map>(); - -async function getJson(path: string): Promise { - 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 { - const cached = tableCache.get(key); - if (cached) return cached; - const job = (async (): Promise => { - 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; - middle: Record; -} - -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 | 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 { +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 { 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 { ); } - 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 }); } diff --git a/M01_MasterData/M01_MasterData_UI_Test_C.ts b/M01_MasterData/M01_MasterData_UI_Test_C.ts deleted file mode 100644 index c2ca1baf..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test_C.ts +++ /dev/null @@ -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; - -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 = {}; - // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) - 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(); - const subs = new Map(); - 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 = {}; - 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, - 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") })]), - ], - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Pick.ts b/M01_MasterData/M01_MasterData_UI_Test_Pick.ts deleted file mode 100644 index ccf07618..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test_Pick.ts +++ /dev/null @@ -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; - -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; -}; - -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>> { - 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) : 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") })], - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Style.css b/M01_MasterData/M01_MasterData_UI_Test_Style.css index dcf76091..3fbb2c66 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Test_Style.css @@ -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; -}