사용자 결정(2026-08-08): 새 자료를 올리면 재계산해 덮어쓰고, 화면은 불러올 자료가 없으면 대시보드로 보낸다. 그러려면 자료가 갈리는 순간 옛 계산 결과가 남아 있으면 안 된다. - common_util_project_reset 신설: 단계별 산출물 폴더(B04~B09)와 산출물 DB 레코드 (surface_models·routes·route_points·route_statistics·longitudinal_sections· cross_sections·structures·quantity_items·outputs·processed_point_cloud)를 함께 지운다. **B03_FileInput(업로드 원본)은 지우지 않는다** — 같은 파일인지 가리는 중복 검사가 쓴다. - 직접 업로드 완료·보관함 연결 양쪽에서 정리를 호출하고, 진행 단계도 1단계 이후를 NOT_STARTED로 되돌린다(reset_stages_after_input_change). - is_analysis_running(): 전처리가 도는 중이면 업로드 세션 생성과 보관함 연결을 409로 막는다. 화면 버튼 잠금은 새로고침·다른 탭으로 우회되므로 서버에도 문을 단다. - fail_stage 아래 있던 리비전(번호표) 안은 폐기 — 사용자가 "산출물이 없으면 대시보드" 방식으로 정리했다. 곁들여: os.replace 공유 위반 재시도(replace_with_retry) 진행률 파일은 서버가 쓰는 동안 화면이 계속 읽어 WinError 5가 났고, 전처리 structured.npz 교체에서는 같은 이유로 분석이 통째로 죽었다(결함 3의 사망 원인). 짧게 여러 번 다시 시도하도록 바꿨다. 검증(실서버 f45243b3, 계획노선 CSV 재업로드로 자료 교체): 전처리 결과 112개 -> 재분석분만, 노선 2->0, 횡단 20->0, DB 지표면 15->0 / 노선 1->0 / 횡단 19->0, 업로드 원본 6개는 그대로. 진행 단계가 초기로 돌아가고 재분석이 자동 시작됨. 교체 재시도는 읽는 쪽이 파일을 잡고 있는 상황을 만들어 성공 확인. ruff format·check 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""입력 자료가 갈릴 때 옛 계산 결과를 지우는 유틸.
|
|
|
|
새 자료를 올리면 B04~B06 자동 계산이 처음부터 다시 돌아 결과를 덮어쓴다. 그런데 옛 자료로
|
|
만든 산출물이 남아 있으면, 아직 다시 만들어지지 않은 뒷단계(B07 이후) 화면이 **옛 결과를
|
|
그대로 보여준다** — 사용자는 새 자료 기준인 줄 알고 검토하게 된다.
|
|
|
|
그래서 자료가 갈리는 순간 계산 결과를 통째로 지운다. 화면 쪽 규칙은 단순해진다:
|
|
**불러올 자료가 없으면 대시보드로 돌려보낸다**(2026-08-08 사용자 지시).
|
|
|
|
지우지 않는 것: `B03_FileInput/` — 업로드 원본과 그 메타데이터다. 같은 파일을 다시 올렸는지
|
|
가리는 중복 검사가 이걸 본다.
|
|
"""
|
|
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import aiomysql
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 계산 결과가 쌓이는 단계 폴더. B03(원본 입력)은 일부러 뺐다.
|
|
OUTPUT_STAGE_DIRS = (
|
|
"B04_PreProcess",
|
|
"B05_Profile",
|
|
"B06_Section",
|
|
"B07_Quantity",
|
|
"B08_DesignDetail",
|
|
"B09_Estimation",
|
|
)
|
|
|
|
# 프로젝트에 직접 매달린 산출물 테이블. 지우는 순서는 자식 → 부모.
|
|
_PROJECT_OUTPUT_TABLES = (
|
|
"cross_sections",
|
|
"longitudinal_sections",
|
|
"structures",
|
|
"quantity_items",
|
|
"outputs",
|
|
"surface_models",
|
|
"processed_point_cloud",
|
|
)
|
|
|
|
|
|
def purge_output_folders(project_root: Path) -> list[str]:
|
|
"""단계별 산출물 폴더를 비운다. 지운 폴더 이름을 돌려준다."""
|
|
removed: list[str] = []
|
|
for name in OUTPUT_STAGE_DIRS:
|
|
target = project_root / name
|
|
if not target.exists():
|
|
continue
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
removed.append(name)
|
|
return removed
|
|
|
|
|
|
async def purge_output_records(connection: aiomysql.Connection, project_id: str) -> dict[str, int]:
|
|
"""산출물 DB 레코드를 지운다. 파일만 지우면 화면은 "자료가 있다"고 착각한다.
|
|
|
|
호출부가 트랜잭션을 관리한다(여기서 커밋하지 않는다).
|
|
"""
|
|
deleted: dict[str, int] = {}
|
|
async with connection.cursor() as cursor:
|
|
# 노선에 매달린 자식부터 — 외래키 없이도 고아 행이 남지 않게 한다.
|
|
await cursor.execute(
|
|
"DELETE rp FROM route_points rp JOIN routes r ON r.id = rp.route_id "
|
|
"WHERE r.project_id = %s",
|
|
(project_id,),
|
|
)
|
|
deleted["route_points"] = cursor.rowcount
|
|
await cursor.execute(
|
|
"DELETE rs FROM route_statistics rs JOIN routes r ON r.id = rs.route_id "
|
|
"WHERE r.project_id = %s",
|
|
(project_id,),
|
|
)
|
|
deleted["route_statistics"] = cursor.rowcount
|
|
for table in _PROJECT_OUTPUT_TABLES:
|
|
await cursor.execute(f"DELETE FROM {table} WHERE project_id = %s", (project_id,))
|
|
deleted[table] = cursor.rowcount
|
|
await cursor.execute("DELETE FROM routes WHERE project_id = %s", (project_id,))
|
|
deleted["routes"] = cursor.rowcount
|
|
return deleted
|
|
|
|
|
|
async def purge_project_outputs(
|
|
connection: aiomysql.Connection, project_id: str, project_root: Path
|
|
) -> None:
|
|
"""입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다."""
|
|
removed = purge_output_folders(project_root)
|
|
deleted = await purge_output_records(connection, project_id)
|
|
logger.info(
|
|
"입력 자료 교체 — 옛 산출물 정리: project_id=%s 폴더=%s 레코드=%s",
|
|
project_id,
|
|
",".join(removed) or "없음",
|
|
{key: value for key, value in deleted.items() if value},
|
|
)
|