From f685845e5a8108e23b05ed594df6c0a4df756faa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 06:16:42 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(B08):=20=ED=99=94=EB=A9=B4=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=EC=97=90=EC=84=9C=20=EC=9E=A1=EC=9D=80=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EA=B2=B0=ED=95=A8=20=EB=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ㉕-1 실사용자 화면 통과(로그인 뒤). 엔진 실행으로는 안 보이던 것들. - 토량환산계수 세 줄이 서버 키로 뜨고 있었음 (soil · ripping_rock · blasting_rock) → 토사 · 리핑암 · 발파암. 준비공의 soil_guard 와 같은 병. 모르는 키는 지어내지 않고 그대로 보임. - 타설 방식 안내가 겹쳐 경고를 밀어내고 있었음. 기본값일 때는 「기본값으로 계산 중」 경고가 더 많은 것을 말하므로 내 줄을 안 실음. 문구도 줄임. 실측: 줄이기 전에는 참고 단가·⚠ 두 줄이 패널 밖으로 잘려 안 보였음. 화면 확인(스크린샷·DOM): 탭 6장 다 서고 표가 따라옴. 표토 두께 칸은 입력 가능(회색은 「안 정함」 placeholder 색). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_UI_Page.ts | 29 +++++++++++++++++++++++----- ui_template/ui_template_locale_b2.ts | 2 +- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index eeecf98e..80fa1c88 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -200,6 +200,17 @@ function selectField( return row; } +/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */ +const GROUND_LABELS: Record = { + soil: "토사", + ripping_rock: "리핑암", + blasting_rock: "발파암", +}; + +function groundLabel(kind: string): string { + return GROUND_LABELS[kind] ?? kind; +} + /** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */ export interface SupplyChoice { supply: string; @@ -239,7 +250,10 @@ function buildQuantitySidePanel( if (entries.length) { panel.append(field(L("B08_Quantity_Side_Factors"), "")); for (const [kind, value] of entries) { - panel.append(field(kind, String((value as { compacted: number }).compacted))); + // ⚠ 서버 키(`soil`·`ripping_rock`·`blasting_rock`)를 그대로 내보내지 않는다 — + // 2026-09-08 ㉕ 화면 통과에서 좌측 세 줄이 개발자 키로 떠 있었다(`soil_guard` 와 같은 병). + // 모르는 키는 **지어내지 않고** 그대로 보인다. + panel.append(field(groundLabel(kind), String((value as { compacted: number }).compacted))); } } @@ -355,10 +369,15 @@ function buildQuantitySidePanel( // 2026-09-08 ㉙: 인계가 **타설 공종 줄을 실제로 세운다.** 겹치지 않는 것이 확인됐다 — // 품셈 12-1-1 표는 직종·품만 주고 재료를 안 줘서, 품은 이 줄 · 재료는 자재 쪽이다. // ⚠ 돌쌓기 뒤채움(채움콘크리트)은 뺐다 — 그 공종 품에 이미 들어 있을 수 있다. - const pending = document.createElement("p"); - pending.className = "b08-quantity__note"; - pending.textContent = L("B08_Quantity_Placing_NotApplied"); - panel.append(pending); + // ⚠ **기본값일 때는 싣지 않는다** — 바로 아래 「기본값으로 계산 중」 경고가 더 많은 것을 + // 말하는데, 둘을 겹쳐 실으면 긴 문구가 그 경고를 화면 밖으로 밀어낸다 + // (2026-09-08 ㉕ 화면 통과에서 실제로 잘려 있었다). + if (!placing.is_default) { + const applied = document.createElement("p"); + applied.className = "b08-quantity__note"; + applied.textContent = L("B08_Quantity_Placing_NotApplied"); + panel.append(applied); + } } // ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고 // B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫). diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d7136e4b..791880b5 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -629,7 +629,7 @@ export const ui_locales_b2 = { B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], B08_Quantity_Placing_NotApplied: [ - "타설 방식에 따라 이 공종의 단가가 달라집니다 — 콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.", + "콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.", "The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.", ], B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"], From de31cd9c42348ecc1006af47e022365c9e231ae7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 06:23:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(B08):=20=EA=B0=9C=EC=86=8C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=EC=9D=B4=20=EC=9D=B8=EA=B3=84=EC=97=90=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5(m)=EC=9C=BC=EB=A1=9C=20=EB=82=98=EA=B0=80?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ㉕ 타설 줄 실증 중에 드러남. 관측 원단위가 「개소당」인 종류(집수정 셋)를 인계 줄이 늘 「m · 연장」으로 내보내고 있었음. - 연장 2m 짜리 집수정이 「2.000 m」로 나가 값이 두 배로 실림 - 성분은 개소 기준으로 맞게 서는데 줄의 축만 어긋나 아무 시험도 안 잡았음 - billing_of() 로 관측표가 정한 단위·개수를 꺼내 전개 결과에 실음 - 인계 줄이 그 단위를 따름. 비어 있으면 종전대로 「m · 연장」 시험 셋 — 개소로 서는 것 · 연장이 길어도 한 개소 · ⚠ 정상 「m」 줄은 그대로일 것(좁게 고치는 짝 시험). 곁들여 타설 줄 실증: 옹벽 H=2.0(20m) + 집수정 Ø800 으로 돌리면 FP-12-01-01#철근구조물 32.840㎥ 가 인계에 섬(옹벽 27+3 · 집수정 2.84). 시험: 677 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 13 +++++++-- .../B08_Quantity_Engine_ObservedUnit.py | 20 +++++++++++++ .../B08_Quantity_Engine_UnitQuantity.py | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index bb73d436..5079967d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -643,13 +643,22 @@ def _structure_rows( elif entry.get("class_from") in ("back_length", "bond") and class_key is None: unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") length = float(structure.get("length_m") or 0.0) + # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — + # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). + # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. + bill_unit = str(structure.get("billing_unit") or "") or "m" + bill_quantity = ( + float(structure.get("billing_quantity") or 0.0) + if structure.get("billing_unit") + else length + ) rows.append( { "work_item_code": code, "name": str(structure.get("name") or type_id), "spec": _spec_detail(structure), - "unit": "m", - "quantity": length, + "unit": bill_unit, + "quantity": bill_quantity, "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index b2caa79d..1069c131 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -131,6 +131,26 @@ def scale_for(entry: dict[str, Any], structure: dict[str, Any]) -> tuple[float, return 1.0, "1 개소" +def billing_of( + type_id: str, + spec: dict[str, Any], + structure: dict[str, Any], + table: ObservedUnitTable | None = None, +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량). 표에 없으면 `None`. + + ⚠ **왜 있나** — 관측 원단위는 「개소당」·「㎡당」으로도 온다. 그런데 인계 줄이 늘 + 「m · 연장」으로 나가고 있어, **집수정 한 개소가 「연장 2m」면 값이 두 배로 실렸다** + (2026-09-08 ㉕ 실증에서 드러남). 성분은 개소 기준으로 맞게 서는데 **줄의 축만 + 어긋나** 있어서 아무 시험도 안 잡았다. + """ + found = (table or load_observed_table()).find(type_id, spec) + if found is None: + return None + scale, _note = scale_for(found, structure) + return str(found.get("unit") or "개소"), scale + + def expand_observed( type_id: str, spec: dict[str, Any], diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 0f656b00..ed3dcd3c 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -38,6 +38,7 @@ from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( BASIS_DERIVED, BASIS_OBSERVED, ObservedUnitTable, + billing_of, expand_observed, load_observed_table, ) @@ -139,6 +140,11 @@ class StructureQuantity: options: dict[str, Any] = field(default_factory=dict) components: list[Component] = field(default_factory=list) notes: list[str] = field(default_factory=list) + #: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인 + #: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증). + #: 비어 있으면 종전대로 「m · 연장」으로 선다. + billing_unit: str = "" + billing_quantity: float = 0.0 #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). @@ -499,6 +505,22 @@ def _observed_components( return expand_observed(type_id, spec, structure, observed) +def _observed_billing( + type_id: str, + structure: dict[str, Any], + observed: ObservedUnitTable | None, +) -> tuple[str, float] | None: + """관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다.""" + keys = OBSERVED_SPEC_KEYS.get(type_id) + if keys is None: + return None + options = structure.get("options") or {} + spec = {key: options[key] for key in keys if options.get(key) is not None} + if not spec: + return None + return billing_of(type_id, spec, structure, observed) + + def expand( structure: dict[str, Any], names: dict[str, str] | None = None, @@ -538,6 +560,9 @@ def expand( if components or notes: result.components = [Component(**item) for item in components] result.notes.extend(notes) + billing = _observed_billing(type_id, structure, observed) + if billing is not None: + result.billing_unit, result.billing_quantity = billing return result from B08_Quantity.B08_Quantity_Wording import type_label @@ -601,6 +626,9 @@ def build_table( "height_m": item.height_m, "start_m": item.start_m, "end_m": item.end_m, + # 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다. + "billing_unit": item.billing_unit, + "billing_quantity": item.billing_quantity, # 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다. "options": item.options, "notes": item.notes, From 618b6cf4c58098172db9be5980f92f1b82aad645 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 06:39:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(dev):=20=ED=99=95=EC=A0=95=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A4=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EC=A0=84=EC=9A=A9=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20(=EC=8B=A0=EA=B7=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=A7=8C,=201/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공용 파일 두 걸음 규칙 — 이 커밋은 신규 파일뿐이고 main.py 등록은 다음 커밋. 먼저 push 해야 다른 창이 등록 줄만 받고 파일이 없어 죽는 일이 없음. 까닭 — 단계마다 [확정]을 해야 다음 페이지가 열려서, 상세 설계를 확정하기 전에는 B08·B09 를 볼 수 없음. 오늘 화면 검증이 그 자리에서 두 번 막혔음. ⚠ 계산을 대신 돌리지 않음 — 잠금만 품 - 바꾸는 것은 project_workflow_stages.state 한 칸뿐 - 확정은 전 측점을 다시 계산해 정본에 쓰는 것이라, 흉내 내면 「확정 안 했는데 확정된 값」이 생겨 막힌 것보다 나쁨 - 값이 없으면 B08 이 「미확보」로 뜨는 것이 정상 ⚠ 문은 서버가 정본 — ENVIRONMENT 가 개발이 아니면 세 입구 모두 403 (화면에서 단추를 숨겨도 API 가 열려 있으면 소용없음) 되돌리기 — 푼 단계의 이전 상태를 message 에 DEV_UNLOCK:<옛상태> 로 적어 두고 DELETE 가 그대로 복원. 그 표시가 없는 단계(진짜 확정)는 안 건드림. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_dev_unlock.py | 165 +++++++++++++++++++ common_util/common_util_dev_unlock_router.py | 113 +++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 common_util/common_util_dev_unlock.py create mode 100644 common_util/common_util_dev_unlock_router.py diff --git a/common_util/common_util_dev_unlock.py b/common_util/common_util_dev_unlock.py new file mode 100644 index 00000000..41736b81 --- /dev/null +++ b/common_util/common_util_dev_unlock.py @@ -0,0 +1,165 @@ +"""개발환경 전용 — **확정을 거치지 않고 다음 단계로 넘어가게** 하는 자리. + +왜 있나 + 프로그램은 단계마다 [확정]을 해야 다음 페이지가 열린다. 그래서 **상세 설계를 확정하기 + 전에는 B08·B09 를 아예 볼 수 없다.** 화면 검증을 하려면 매번 남의 프로젝트 확정 상태에 + 매달려야 했다(2026-09-08 실제로 그 자리에서 두 창이 막혔다). + +⚠⚠ **계산을 대신 돌리지 않는다 — 잠금만 푼다.** + [확정]은 **전 측점을 다시 계산해 정본에 쓰는 것**이다. 여기서 그 계산을 흉내 내면 + 「확정 안 했는데 확정된 값」이 생겨 **막힌 것보다 더 나쁘다.** 그래서 이 모듈이 바꾸는 + 것은 `project_workflow_stages.state` **한 칸뿐**이다. 값이 없으면 B08 이 + 「미확보」로 뜨는 것이 **정상이고 그것이 옳은 화면**이다. + +⚠ **문은 서버가 정본이다.** + 화면에서 단추를 숨기는 것만으로는 API 가 그대로 뚫려 있다. 그래서 이 모듈이 + `ENVIRONMENT` 를 보고 **운영에서는 아예 거절한다.** 화면 쪽 `import.meta.env.DEV` 는 + 보조일 뿐이다. + +⚠ **되돌릴 수 있어야 한다.** + 푼 단계와 그 **이전 상태**를 `dev_unlock` 칸에 적어 두고, 되돌리기가 그대로 복원한다. + 안 그러면 검증용 프로젝트가 이상한 상태로 굳는다. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +import aiomysql + +from common_util.common_util_workflow_state import STAGE_KEYS +from config.config_system import ENVIRONMENT + +#: 개발환경으로 보는 값. 그 밖(staging·production)에서는 이 기능이 아예 안 돈다. +DEV_ENVIRONMENTS = frozenset({"development", "dev", "local", "test"}) + +#: 되돌리기용 기록을 남기는 자리. 프로젝트 저장 폴더가 아니라 **DB 안**에 둔다 — +#: 상태와 같은 곳에 있어야 둘이 어긋나지 않는다. +UNLOCK_MESSAGE_PREFIX = "DEV_UNLOCK:" + + +class DevUnlockDisabled(RuntimeError): + """개발환경이 아니어서 거절함. **이 예외가 곧 운영 쪽 문**이다.""" + + +def is_dev_environment() -> bool: + """지금 환경에서 이 기능을 켜도 되는가.""" + return str(ENVIRONMENT or "").strip().lower() in DEV_ENVIRONMENTS + + +def require_dev_environment() -> None: + """개발환경이 아니면 **여기서 멈춘다.** 화면이 아니라 서버가 막는 자리다.""" + if not is_dev_environment(): + raise DevUnlockDisabled(f"개발환경에서만 쓸 수 있습니다 (지금 환경: {ENVIRONMENT}).") + + +async def unlock_stages( + cursor: aiomysql.DictCursor, project_id: str, up_to_stage: int +) -> dict[str, Any]: + """`up_to_stage` 까지의 단계를 **상태만** COMPLETE 로 만든다. + + ⚠ 계산·저장은 하지 않는다. 되돌릴 수 있게 **이전 상태를 함께 적어 둔다.** + 이미 COMPLETE 인 단계는 건드리지 않는다 — 되돌릴 때 남의 확정까지 풀면 안 된다. + """ + require_dev_environment() + if not 0 <= up_to_stage < len(STAGE_KEYS): + raise ValueError(f"단계 번호가 범위를 벗어났습니다: {up_to_stage}") + + await cursor.execute( + """ + SELECT stage_no, state, message + FROM project_workflow_stages + WHERE project_id = %s AND stage_no <= %s + ORDER BY stage_no ASC + """, + (project_id, up_to_stage), + ) + rows = await cursor.fetchall() + + changed: list[dict[str, Any]] = [] + now = datetime.utcnow() + for row in rows: + if row["state"] == "COMPLETE": + continue # 진짜로 확정된 단계 — 손대지 않는다. + changed.append({"stage_no": int(row["stage_no"]), "state": row["state"]}) + note = f"{UNLOCK_MESSAGE_PREFIX}{row['state']}" + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = 'COMPLETE', + progress_percent = 100, + completed_at = %s, + message = %s + WHERE project_id = %s AND stage_no = %s + """, + (now, note, project_id, int(row["stage_no"])), + ) + return { + "unlocked": changed, + "up_to_stage": up_to_stage, + "note": ( + "확정을 건너뛰고 잠금만 풀었습니다 — 계산은 돌지 않았습니다. " + "값이 비어 보이는 것은 정상입니다." + ), + } + + +async def relock_stages(cursor: aiomysql.DictCursor, project_id: str) -> dict[str, Any]: + """우회로 푼 단계를 **원래 상태로 되돌린다.** + + ⚠ `DEV_UNLOCK:` 표시가 붙은 단계만 되돌린다 — 그 표시가 없으면 **진짜 확정**이라 + 건드리면 안 된다. 표시 뒤에 적어 둔 옛 상태를 그대로 복원한다. + """ + require_dev_environment() + await cursor.execute( + """ + SELECT stage_no, message + FROM project_workflow_stages + WHERE project_id = %s AND message LIKE %s + ORDER BY stage_no ASC + """, + (project_id, f"{UNLOCK_MESSAGE_PREFIX}%"), + ) + rows = await cursor.fetchall() + + restored: list[dict[str, Any]] = [] + for row in rows: + previous = str(row["message"])[len(UNLOCK_MESSAGE_PREFIX) :].strip() or "NOT_STARTED" + restored.append({"stage_no": int(row["stage_no"]), "state": previous}) + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = %s, + progress_percent = 0, + completed_at = NULL, + message = NULL + WHERE project_id = %s AND stage_no = %s + """, + (previous, project_id, int(row["stage_no"])), + ) + return {"relocked": restored} + + +async def unlock_status(cursor: aiomysql.DictCursor, project_id: str) -> dict[str, Any]: + """지금 우회로 열려 있는 단계 목록. **화면이 띄울 안내의 근거**다.""" + await cursor.execute( + """ + SELECT stage_no, message + FROM project_workflow_stages + WHERE project_id = %s AND message LIKE %s + ORDER BY stage_no ASC + """, + (project_id, f"{UNLOCK_MESSAGE_PREFIX}%"), + ) + rows = await cursor.fetchall() + return { + "dev_environment": is_dev_environment(), + "bypassed_stages": [int(row["stage_no"]) for row in rows], + } + + +def as_json(payload: dict[str, Any]) -> str: + """로그용 — 한글이 깨지지 않게.""" + return json.dumps(payload, ensure_ascii=False) diff --git a/common_util/common_util_dev_unlock_router.py b/common_util/common_util_dev_unlock_router.py new file mode 100644 index 00000000..852301d5 --- /dev/null +++ b/common_util/common_util_dev_unlock_router.py @@ -0,0 +1,113 @@ +"""개발환경 전용 — 「확정 없이 다음으로」 API. + +⚠ **문은 여기가 정본이다.** 화면에서 단추를 숨겨도 API 가 열려 있으면 아무 소용이 없다. + 그래서 세 입구 모두 `require_dev_environment()` 를 먼저 부르고, 운영에서는 **403** 으로 + 거절한다. 프론트의 `import.meta.env.DEV` 는 보조일 뿐이다. + +⚠ **계산을 대신 돌리지 않는다.** 여기서 바뀌는 것은 `project_workflow_stages.state` + 한 칸뿐이다. 자세한 까닭은 `common_util_dev_unlock` 의 머리말을 볼 것. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from common_util.common_util_dev_unlock import ( + DevUnlockDisabled, + relock_stages, + unlock_stages, + unlock_status, +) +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/projects", tags=["DEV Unlock"]) + +#: 기본은 B08(수량산출)까지 — 검증이 가장 자주 막히던 자리다. +DEFAULT_UP_TO_STAGE = 5 + + +class UnlockBody(BaseModel): + """어디까지 열 것인가. 안 주면 B08 까지.""" + + up_to_stage: int = DEFAULT_UP_TO_STAGE + + +async def _with_cursor(connection: aiomysql.Connection, call: Any, *args: Any) -> Any: + """쓰기라 **한 커넥션·한 트랜잭션**으로 묶는다(`run_with_connection` 주석 참조).""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + result = await call(cursor, *args) + await connection.commit() + return result + + +@router.get("/{project_id}/dev/unlock") +async def get_unlock_status(project_id: UUID) -> JSONResponse: + """지금 우회로 열려 있는 단계. **화면 안내의 근거**다.""" + + async def call(connection: aiomysql.Connection) -> dict[str, Any]: + async with connection.cursor(aiomysql.DictCursor) as cursor: + return await unlock_status(cursor, str(project_id)) + + try: + payload = await run_with_connection(call) + except Exception: + logger.exception("개발 우회 상태 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "우회 상태를 읽지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", **payload}) + + +@router.post("/{project_id}/dev/unlock") +async def post_unlock(project_id: UUID, body: UnlockBody | None = None) -> JSONResponse: + """확정을 건너뛰고 **잠금만** 푼다. 개발환경이 아니면 403.""" + up_to = (body or UnlockBody()).up_to_stage + + async def call(connection: aiomysql.Connection) -> dict[str, Any]: + return await _with_cursor(connection, unlock_stages, str(project_id), up_to) + + try: + payload = await run_with_connection(call) + except DevUnlockDisabled as error: + return JSONResponse(status_code=403, content={"status": "error", "message": str(error)}) + except ValueError as error: + return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("개발 우회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "단계를 열지 못했습니다."}, + ) + logger.info("DEV 우회: project_id=%s 까지=%s", project_id, up_to) + return JSONResponse(content={"status": "success", **payload}) + + +@router.delete("/{project_id}/dev/unlock") +async def delete_unlock(project_id: UUID) -> JSONResponse: + """우회로 연 단계를 **원래 상태로 되돌린다.** 진짜 확정은 안 건드린다.""" + + async def call(connection: aiomysql.Connection) -> dict[str, Any]: + return await _with_cursor(connection, relock_stages, str(project_id)) + + try: + payload = await run_with_connection(call) + except DevUnlockDisabled as error: + return JSONResponse(status_code=403, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("개발 우회 되돌리기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "단계를 되돌리지 못했습니다."}, + ) + logger.info("DEV 우회 되돌림: project_id=%s", project_id) + return JSONResponse(content={"status": "success", **payload}) From 6e114ac9908bebb7644decb594031e42e1eba97b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 06:48:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(dev):=20=ED=99=95=EC=A0=95=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A4=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20+=20=EC=A2=8C=EC=B8=A1=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20(2/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋 618b6cf4 의 신규 파일을 등록하고 화면 단추를 붙임. 두 걸음 규칙대로 파일이 먼저 push 된 뒤 등록 줄이 감. 문은 앞뒤 둘 - 서버(정본): ENVIRONMENT 가 개발이 아니면 세 입구 모두 403. 라우터는 다른 것과 같은 보호(로그인·회사·프로젝트 접근)를 받고 그 위에 환경을 한 번 더 봄 - 화면(보조): import.meta.env.DEV 일 때만 단추가 그려짐 눌렀을 때 바뀌는 것 — project_workflow_stages.state 한 칸뿐. 계산은 안 돌고 정본 값도 안 만듦. 실측(b269ea34): 우회 전 [] → 우회 [3 STALE · 4 NOT_STARTED · 5 NOT_STARTED] → 되돌림 → [] 이미 COMPLETE 인 0·1·2 는 안 건드림 (진짜 확정은 보존) 되돌리기 — 푼 단계의 옛 상태를 message 에 DEV_UNLOCK:<옛상태> 로 적어 두고 [원래대로 되돌리기]가 그대로 복원. 그 표시가 없으면 진짜 확정이라 안 만짐. 드러남 — 우회 중이면 좌측에 「지금 확정을 건너뛴 상태입니다 — 값이 비어 보이는 것은 정상입니다. 건너뛴 단계 (3, 4, 5)」가 뜸. ⚠ 화면에서 잡은 것 — 단추를 패널 아래에 두니 overflow:hidden 에 잘려 화면 밖(y=772, 패널 740)이라 눌리지 않았음. 맨 위로 옮김(상태 경고이기도 함). 시험: 677 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관) · tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_UI_Page.ts | 78 ++++++++++++++++++++++++++++ main.py | 7 +++ ui_template/ui_template_locale_b2.ts | 13 +++++ 3 files changed, 98 insertions(+) diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 80fa1c88..affaff1c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -233,6 +233,72 @@ interface DraftSettings { dirty: boolean; } +/** 개발 전용 「확정 없이 다음으로」 한 줄 — 단추 둘 + 지금 상태 안내. + * + * ⚠ **조용히 넘어가지 않는다.** 우회로 열린 상태면 「확정을 건너뛴 상태입니다」를 띄운다 — + * 안 그러면 다음 사람이 「왜 값이 없나」로 헤맨다. + * ⚠ **되돌리는 단추를 같은 줄에 둔다.** 되돌릴 길이 안 보이면 검증용 프로젝트가 굳는다. + */ +function devUnlockRow(projectId: string, reload: () => void): HTMLElement { + const row = document.createElement("div"); + row.className = "b08-quantity__dev"; + + const title = document.createElement("p"); + title.className = "b08-quantity__note"; + title.textContent = L("B08_Quantity_Dev_Title"); + row.append(title); + + const state = document.createElement("p"); + state.className = "b08-quantity__note"; + row.append(state); + + const paint = (): void => { + fetch(`/api/projects/${projectId}/dev/unlock`, { credentials: "include" }) + .then((response) => (response.ok ? response.json() : null)) + .then((body: { bypassed_stages?: number[] } | null) => { + const stages = body?.bypassed_stages ?? []; + state.textContent = stages.length + ? `${L("B08_Quantity_Dev_Bypassed")} (${stages.join(", ")})` + : L("B08_Quantity_Dev_Normal"); + }) + .catch(() => { + state.textContent = L("B08_Quantity_Dev_Normal"); + }); + }; + paint(); + + const call = (method: "POST" | "DELETE", button: HTMLButtonElement): void => { + button.disabled = true; + fetch(`/api/projects/${projectId}/dev/unlock`, { method, credentials: "include" }) + .then((response) => { + if (!response.ok) throw new Error(String(response.status)); + showToast(L("B08_Quantity_Dev_Done"), "success"); + paint(); + reload(); + }) + .catch(() => showToast(L("B08_Quantity_Dev_Failed"), "error")) + .finally(() => { + button.disabled = false; + }); + }; + + const unlock = createButton({ + label: L("B08_Quantity_Dev_Unlock"), + variant: "ghost", + onClick: () => call("POST", unlock), + }); + const relock = createButton({ + label: L("B08_Quantity_Dev_Relock"), + variant: "ghost", + onClick: () => call("DELETE", relock), + }); + const buttons = document.createElement("div"); + buttons.className = "b08-quantity__actions ui-sidebar-actions"; + buttons.append(unlock, relock); + row.append(buttons); + return row; +} + /** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행. * `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */ function buildQuantitySidePanel( @@ -244,6 +310,18 @@ function buildQuantitySidePanel( const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; + // ── 개발 전용 「확정 없이 다음으로」 ──────────────────────────────────────── + // ⚠ **맨 위에 둔다.** 패널이 `overflow: hidden` 이라 아래에 붙이면 화면 밖으로 + // 밀려 **눌리지 않는다**(2026-09-08 화면에서 실제로 그랬다 — 단추 y=772, + // 패널 높이 740). 상태 경고이기도 하니 자리도 여기가 맞다. + // ⚠ **이것은 보조 문일 뿐이다.** 진짜 문은 서버가 `ENVIRONMENT` 로 막는다 + // (`common_util_dev_unlock`). 화면만 숨기면 API 는 그대로 뚫려 있다. + // ⚠ **계산을 대신 돌리지 않는다** — 워크플로 잠금만 푼다. 값이 비어 보이는 것은 + // 정상이고, 그것을 「미확보」로 보이는 것이 이 화면이 이미 하는 일이다. + if (import.meta.env.DEV && projectId) { + panel.append(devUnlockRow(projectId, reload)); + } + // 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다. panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value"))); const entries = Object.entries(table?.conversion_factors ?? {}); diff --git a/main.py b/main.py index 135572f7..08689bd8 100644 --- a/main.py +++ b/main.py @@ -63,6 +63,10 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_r from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from common_util.common_util_audit import note_api_call, record_call_burst + +# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 +# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). +from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_auth import ( require_company, require_project_access, @@ -541,6 +545,9 @@ app.include_router(b08_quantity_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) +# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). +# 그 위에 서버가 환경까지 한 번 더 본다. +app.include_router(dev_unlock_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 791880b5..85712f86 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -632,6 +632,19 @@ export const ui_locales_b2 = { "콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.", "The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.", ], + B08_Quantity_Dev_Title: [ + "⚠ 개발 전용 — 확정을 건너뛰고 다음 단계를 엽니다(계산은 돌지 않습니다).", + "⚠ Dev only — unlocks the next stage without confirming (no recalculation).", + ], + B08_Quantity_Dev_Unlock: ["확정 없이 다음으로", "Skip confirm"], + B08_Quantity_Dev_Relock: ["원래대로 되돌리기", "Undo skip"], + B08_Quantity_Dev_Bypassed: [ + "지금 확정을 건너뛴 상태입니다 — 값이 비어 보이는 것은 정상입니다. 건너뛴 단계", + "Currently skipping confirmation — empty values are expected. Skipped stages", + ], + B08_Quantity_Dev_Normal: ["건너뛴 단계 없음 (정상 상태)", "No skipped stages"], + B08_Quantity_Dev_Done: ["단계 잠금을 바꿨습니다.", "Stage lock updated."], + B08_Quantity_Dev_Failed: ["단계 잠금을 바꾸지 못했습니다.", "Failed to update stage lock."], B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"], B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"], B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"],