Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -200,6 +200,17 @@ function selectField(
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */
|
||||
const GROUND_LABELS: Record<string, string> = {
|
||||
soil: "토사",
|
||||
ripping_rock: "리핑암",
|
||||
blasting_rock: "발파암",
|
||||
};
|
||||
|
||||
function groundLabel(kind: string): string {
|
||||
return GROUND_LABELS[kind] ?? kind;
|
||||
}
|
||||
|
||||
/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */
|
||||
export interface SupplyChoice {
|
||||
supply: string;
|
||||
@@ -222,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(
|
||||
@@ -233,13 +310,28 @@ 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 ?? {});
|
||||
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 +447,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 몫).
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -629,9 +629,22 @@ 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_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"],
|
||||
|
||||
Reference in New Issue
Block a user