Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-26 04:02:30 +09:00
7 changed files with 59 additions and 19 deletions
+26 -11
View File
@@ -1,9 +1,9 @@
"""D 예정노선 확정 · 업체 지정 API (요구 345 · PLAN 531-1 · 531-2).
- `GET /api/projects/{id}/plan/assignees` — 연도별 지정 업체
- `GET /api/projects/{id}/plan/assignees` — 연도별 지정 업체(지정 회사 = 자기 행만)
- `PUT /api/projects/{id}/plan/assignees` — 연도별 지정 업체 저장(등록 회사 또는 업체명 + 메일)
- `POST /api/projects/{id}/plan/finalize` — 연도 노선 확정 → 업체별 zip → 메일 첨부 → 발송 시각
- `GET /api/projects/{id}/plan/summary` — D04 화면 연도 요약(연장 · 시점 · 종점 · 누가 · 확정)
- `GET /api/projects/{id}/plan/summary` — D04 화면 연도 요약(지정 회사 = 자기 연도 · 자기 행만)
- `GET /api/projects/{id}/plan/export` — [내보내기] zip(산림청 = 전 연도 + 전체 · 공유 = 자기 연도)
지정 · 확정은 산림청(시스템 관리자 또는 메일 *.go.kr)만. 지정받은 회사는 공용 접근 판정이
@@ -116,10 +116,25 @@ async def _plan_project(project_id: UUID) -> dict[str, Any]:
return project
def visible_to(
session: dict[str, Any], years: list[dict[str, Any]], assignees: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""산림청 = 전부 · 지정 회사 = 자기 회사가 지정된 연도 줄 · 자기 업체 행만(남의 이름 · 메일 뺌)."""
if is_forest_service(session):
return years, assignees
company = session.get("company_id")
mine = [a for a in assignees if a["company_id"] is not None and a["company_id"] == company]
own_years = {a["year"] for a in mine}
return [y for y in years if y["year"] in own_years], mine
@router.get("/{project_id}/plan/assignees", response_model=None)
async def get_plan_assignees(project_id: UUID) -> dict[str, Any]:
async def get_plan_assignees(
project_id: UUID, session: dict = Depends(verify_session)
) -> dict[str, Any]:
await _plan_project(project_id)
return {"status": "success", "assignees": await repo.list_assignees(str(project_id))}
_, assignees = visible_to(session, [], await repo.list_assignees(str(project_id)))
return {"status": "success", "assignees": assignees}
@router.put("/{project_id}/plan/assignees", response_model=None)
@@ -249,15 +264,15 @@ def year_summary(row: dict[str, Any]) -> dict[str, Any]:
@router.get("/{project_id}/plan/summary", response_model=None)
async def get_plan_summary(project_id: UUID) -> dict[str, Any]:
async def get_plan_summary(
project_id: UUID, session: dict = Depends(verify_session)
) -> dict[str, Any]:
project = await _plan_project(project_id)
rows = await repo.list_year_routes(str(project_id))
return {
"status": "success",
"name": project["name"],
"years": [year_summary(row) for row in rows],
"assignees": await repo.list_assignees(str(project_id)),
}
years, assignees = visible_to(
session, [year_summary(row) for row in rows], await repo.list_assignees(str(project_id))
)
return {"status": "success", "name": project["name"], "years": years, "assignees": assignees}
def export_zip(bundle: Any, years: list[int], with_all: bool) -> bytes:
+2 -1
View File
@@ -59,7 +59,7 @@ def _summary(rock: dict[str, Any]) -> str:
def fill_rock(sheet: dict[str, Any], rock: dict[str, Any], labels: dict) -> None:
"""s11 — 1. 조건 · 요약(5~9 행) · 2. 절리군(한 줄씩 · 13 행 서식) · 3. 측점(17 행 서식)."""
"""s11 — 1. 조건 · 요약(5~9 행) · 2. 절리군(한 줄씩 · 13 행 서식) · 3. 측점(17 행 서식 · 머리 줄 쪽마다)."""
s = rock["settings"]
d = rock_model.DEFAULT_SETTINGS
lateral = (s["lateral_planar_deg"], s["lateral_toppling_deg"])
@@ -143,6 +143,7 @@ def fill_rock(sheet: dict[str, Any], rock: dict[str, Any], labels: dict) -> None
# 3. 측점별 판정
r = head(r + 1, 15)
sheet["인쇄"]["머리행"] = [r - 1, r - 1] # 측점 표 머리 줄 = 쪽마다
for entry in rock["stations"]:
r = _station_rows(sheet, put_row, r, entry, labels)
+1 -1
View File
@@ -274,7 +274,7 @@ def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -
def _fmt(value: float | None) -> float | None:
if value is None or not math.isfinite(value):
return None
return round(value, 4)
return round(value, 4) + 0.0 # -0.0 → 0.0(들림 · 볼트 없음)
def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds):
@@ -4071,7 +4071,7 @@
"폭": 14
},
"B": {
"폭": 26
"폭": 33
},
"C": {
"폭": 16
@@ -117,7 +117,7 @@
- 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나.
## s11 암반검토 (프로그램 몫 · E06 암반 해석 저장본 · 없으면 시트를 뺌)
## s11 암반검토 (프로그램 몫 · E06 암반 해석 저장본 · 없으면 시트를 뺌 · 측점 표 머리 줄 쪽마다 반복)
| 칸 | 내용 |
| --- | --- |
+22 -4
View File
@@ -418,8 +418,10 @@ def plan_repo(monkeypatch):
async def assignees(_pid):
return [
{"id": 7, "year": 2026, "company_id": 5},
{"id": 8, "year": 2027, "company_id": 5},
{"id": 7, "year": 2026, "company_id": 5, "company_name": "가", "email": "a@ga.kr"},
{"id": 8, "year": 2027, "company_id": 5, "company_name": "가", "email": "a@ga.kr"},
{"id": 9, "year": 2027, "company_id": None, "company_name": "나", "email": "b@na.kr"},
{"id": 10, "year": 2028, "company_id": 6, "company_name": "다", "email": "c@da.kr"},
]
monkeypatch.setattr(repo, "get_plan_project", project)
@@ -447,13 +449,29 @@ def test_export_shared_gets_own_confirmed_year(plan_repo):
def test_summary_year_rows(plan_repo):
out = asyncio.run(d04.get_plan_summary(PLAN_ID))
forest = {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1}
out = asyncio.run(d04.get_plan_summary(PLAN_ID, forest))
first = out["years"][0]
assert first["saved"] and first["start"]["x"] == P2026[0][0] and first["confirmed_at"] == "x"
assert 120 < first["start"]["lon"] < 135 and first["end_chainage_m"] == pytest.approx(
first["length_m"]
)
assert out["years"][1]["confirmed_at"] is None and len(out["assignees"]) == 2
assert out["years"][1]["confirmed_at"] is None and len(out["assignees"]) == 4
def test_shared_company_sees_only_own_firm(plan_repo):
"""지정 회사 = 자기 연도 줄 · 자기 행만 — 남의 업체 이름 · 메일은 응답에 없음(요구 351)."""
other = {"role": "USER", "email": "x@ga.kr", "company_id": 6} # 2028 만(노선 없음)
for session, years, ids in ((SESSION, [2026, 2027], [7, 8]), (other, [], [10])):
out = asyncio.run(d04.get_plan_summary(PLAN_ID, session))
assert [y["year"] for y in out["years"]] == years
assert [a["id"] for a in out["assignees"]] == ids
listed = asyncio.run(d04.get_plan_assignees(PLAN_ID, session))["assignees"]
assert [a["id"] for a in listed] == ids
text = str(asyncio.run(d04.get_plan_summary(PLAN_ID, SESSION)))
assert "b@na.kr" not in text and "c@da.kr" not in text and "나" not in text
admin = {"role": "SYSTEM_ADMIN", "email": "root@x.kr", "company_id": 99}
assert len(asyncio.run(d04.get_plan_assignees(PLAN_ID, admin))["assignees"]) == 4
def test_put_assignees_years_clears_empty_year(plan_repo, monkeypatch):
+6
View File
@@ -227,6 +227,11 @@ def test_wedge_gravity_water_and_bolt():
assert bolted["F"] > dry["F"]
def test_fmt_negative_zero():
assert str(model._fmt(-0.0)) == "0.0" # 들림 · 볼트 없음 = 0(「-0.0」 아님)
assert model._fmt(math.inf) is None
def test_wedge_not_formed():
# 교선이 사면보다 가파름 → 사면으로 안 나옴
assert _wedge((141, 75), (219, 75), (180, 50), 35) is None
@@ -480,6 +485,7 @@ def test_e05_rock_sheet(monkeypatch):
assert "기본값 · 사용자 확정 전" in cells["B7"]["값"] and "½" in cells["B7"]["값"]
assert cells["B9"]["값"].startswith("깎기 비탈 1")
assert cells["A13"]["값"] == doc["sets"][0]["name"]
assert sheet["인쇄"]["머리행"] == [16, 16] and cells["A16"]["값"] == "측점" # 절리 한 줄
rows = [r for r in range(17, 40) if cells.get(f"A{r}", {}).get("값") == "STA"]
assert len(rows) == len(stations[0]["cases"]) + len(stations[0]["kinematic"]["toppling"])
for r, case in zip(rows, stations[0]["cases"]):