feat(Z01): 기초단가 정본을 DB 로 — 저장소 신설 · 일괄 저장·줄 추가/삭제·초기값 되돌리기 API · 덮개 층 삭제

- Z01_MasterData_Repository_BasePrices.py 신설 — master_base_price·master_revision Raw SQL(읽기·줄 수·판번호·저장·되돌리기) · 판번호 줄을 잠그고 다시 봄 · 틀리면 통째로 되돌림
- GET /base-prices/{kind} 가 DB 를 읽음 · 응답에 revision · 줄마다 changed_columns·is_added 추가
- PUT /base-prices/{kind} {base_revision, edits, added, deleted} — 판 다르면 409 · 하나라도 틀리면 아무것도 안 씀 · edits 500 상한 · 모르는 칸 거절
- POST /base-prices/{kind}/reset {row_keys} — 주입 줄은 data := seed(지운 줄도 되살림) · 추가 줄은 지움
- 기계 시간당 단가는 DB 노임·유가로 읽을 때마다 셈 · 제원은 줄 값에서(추가한 기종도 섬)
- 옛 덮개 층 삭제 — Z01_MasterData_Overrides.py · 칸 단위 PUT · /overrides·/overrides/clear
- 새 줄 열쇠는 서버가 줌(added/{난수}) · 주입 data 에 '@' 칸이 없어도 축 잠금은 파일 원본으로 섬
- 시험: DB 가짜(helper_z01_fake_repo.py) 로 API 손질 · 저장소 SQL 흐름은 가짜 커넥션으로 잼

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
This commit is contained in:
2026-09-18 23:53:50 +09:00
co-authored by Claude Opus 5
parent 9629430aff
commit 6fe4056d98
11 changed files with 883 additions and 702 deletions
+158 -111
View File
@@ -1,16 +1,20 @@
"""Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약).
표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만)
줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄)
줄 수준: `@id`(자료 제 열쇠) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자 추가 줄) · `@formula`(계산값 줄)
`@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code ·
자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]`(원본 값 그대로 · 열 이름은 이름표 것) · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌)
파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`).
= DB `master_base_price`(2026-09-18 PLAN 1-0 · `Z01_MasterData_Repository_BasePrices`) — 파일 원본(`base_rows`)
초기값 1회 주입(`Z01_MasterData_Seed`)의 재료 · 공종 축 둘만 아직 파일 읽기.
"""
from __future__ import annotations
import asyncio
import json
import math
import re
import uuid
from datetime import date
from decimal import Decimal
from functools import lru_cache
@@ -19,7 +23,7 @@ from typing import Any
from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
from Z01_MasterData import Z01_MasterData_Tables as tables
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
@@ -28,7 +32,7 @@ KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work
SOURCES_DIR = (
tables.RESOURCES / "data_master_sources"
) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지
_KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음"
_KEY = "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음"
_SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜"
_RATE_EDITABLE = (
"rate_percent",
@@ -39,14 +43,14 @@ _RATE_EDITABLE = (
"manager_thresholds.default_estimated_amount_krw",
"manager_thresholds.civil_main_work_estimated_amount_krw",
)
#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
#: ⚠ **지금 상태를 먼저** — 고친 값을 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
#: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠.
_SCOPE_NOTICE = (
"⚠ **아직 어디에도 안 쓰임** — 고친 값은 마스터에만 쌓임 · 프로젝트로 옮기는 길(프로젝트 복사)이 아직 없음."
" 그 길이 붙으면: 여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
"(프로젝트는 만들 때 뜬 사본을 봄)"
)
_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)"
_RATE_NOTICE = "법이 정한 값 — 고시가 바뀔 때만 고칠 것(구간 칸은 규칙이라 못 고침)"
def _source(file_id: str) -> Path:
@@ -277,7 +281,7 @@ def merge_same_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
def base_rows(kind: str) -> list[dict[str, Any]]:
"""덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈)."""
"""파일 원본 줄 — 초기값 주입의 재료 · 기계는 입력 칸까지(계산 칸은 읽을 때 셈)."""
if kind == "machine":
doc, name = _doc("mach_base")
return machine.base_rows(name, edition_of(doc))
@@ -290,33 +294,67 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
return _BUILDERS[kind]()
def _machine_inputs() -> machine.Inputs:
"""기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일."""
labor = [r for r in overrides.overlay("labor", _labor()) if r["source"] == "labor_const"]
def _machine_inputs(labor: list[dict[str, Any]], oil: list[dict[str, Any]]) -> machine.Inputs:
"""기계가 끌어 쓰는 밑값 — DB 의 노임(건설)·전국평균 유가 + 그 판들의 기준일(빈 값 줄은 뺌)."""
labor = [r for r in labor if r.get("source") == "labor_const" and r.get("occupation_code")]
oil = [
r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}"
r
for r in oil
if r["@id"] == f"national_average/{r.get('fuel')}" and r.get("price_krw_per_l") is not None
]
return machine.Inputs(
wages={
str(r["occupation_code"]): None
if r["daily_wage_krw"] is None
if r.get("daily_wage_krw") is None
else Decimal(str(r["daily_wage_krw"]))
for r in labor
},
wage_date=labor[0]["effective_date"] if labor else None,
wage_date=labor[0].get("effective_date") if labor else None,
oil={r["fuel"]: Decimal(str(r["price_krw_per_l"])) for r in oil},
oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil},
oil_dates={r["fuel"]: r.get("date") or r.get("effective_date") for r in oil},
operating_date=edition_of(_doc("pum_const")[0]),
)
def rows(kind: str) -> list[dict[str, Any]]:
"""덮개를 얹은 줄 — 기계는 그 위에서 시간당 단가를 다시 셈."""
out = overrides.overlay(kind, base_rows(kind))
if kind == "machine":
inputs = _machine_inputs()
out = [machine.compute(r, inputs) for r in out]
return out
def same(a: Any, b: Any) -> bool:
"""값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음."""
if a is None or b is None:
return a is None and b is None
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return Decimal(str(a)) == Decimal(str(b))
return a == b
def _row(record: dict[str, Any]) -> dict[str, Any]:
"""DB 한 줄 → 표 줄. `@data` = 저장된 값 그대로(쓸 때 여기에 고친 칸만 얹음 · 기계 계산 칸이 섞이지 않게)."""
data, seed = record["data"], record["seed"]
changed = []
if seed is not None:
changed = [
c
for c in dict.fromkeys([*seed, *data])
if not c.startswith("@") and not same(data.get(c), seed.get(c))
]
row = {"@id": record["row_key"], **data}
row.update(
{"@id": record["row_key"], "@data": data, "@added": seed is None, "@changed": changed}
)
return row
async def _stored(kind: str) -> list[dict[str, Any]]:
return [_row(r) for r in await repo.load(kind)]
async def rows(kind: str) -> list[dict[str, Any]]:
"""DB 정본 줄(공종 축 둘만 파일) — 기계는 DB 노임·유가로 시간당 단가를 읽을 때마다 셈(식은 코드 · 브레인)."""
if kind in work_items.KINDS:
return work_items.base_rows(kind)
if kind != "machine":
return await _stored(kind)
out, labor, oil = await asyncio.gather(_stored("machine"), _stored("labor"), _stored("oil"))
inputs = _machine_inputs(labor, oil)
return [machine.compute(r, inputs) for r in out]
def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]:
@@ -446,45 +484,26 @@ def group_label(group: str) -> str:
return named.get("name_ko") or group
def kinds() -> list[dict[str, Any]]:
async def kinds() -> list[dict[str, Any]]:
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수.
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수(DB · 공종 축은 파일).
"""
counted = await repo.counts()
return [
{
"group": kind_group(kind),
"group_label": group_label(kind_group(kind)),
"kind": kind,
"label": kind_label(kind),
"rows": len(rows(kind)),
"rows": len(work_items.base_rows(kind))
if kind in work_items.KINDS
else counted.get(kind, 0),
}
for kind in KINDS
]
def row_label(kind: str, row: dict[str, Any] | None) -> str:
if row is None:
return ""
if kind == "labor":
return str(row.get("occupation_name") or "")
if kind in work_items.KINDS:
return str(row.get("name") or "")
if kind == "machine":
return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip()
if kind == "material":
return str(row.get("specification") or row.get("classification_name") or "")
if kind == "oil":
return f"{row.get('source_product_name') or row.get('fuel')} {row.get('region_name') or '전국평균'}"
if kind in base_tables.SPEC: # 기초데이터 아홉 — 축 칸을 이어 사람이 읽을 한 줄로
return " · ".join(str(row[c]) for c in row.get("@axis", ()) if row.get(c) is not None)
named = (tables.load_labels().get("value_overrides") or {}).get(
f"rates::variables/{row['variable']}"
) or {}
bracket = row["@id"].split("/", 2)[2].replace(";", " · ") if row["part"] else ""
return f"{named.get('name_ko') or row['variable']} {bracket}".strip()
def column_meta(kind: str, key: str) -> dict[str, Any]:
"""열 이름 — 이름표 `merged_tables[kind]` → `columns[열key]` → 영문 key(이름은 이름표에만 · 브레인 ②)."""
labels = tables.load_labels()
@@ -501,27 +520,12 @@ def column_meta(kind: str, key: str) -> dict[str, Any]:
}
#: 뜻 칸에서 뺄 자리 — 판·공표 날짜는 갱신 때마다 바뀌어 「뜻 바뀜」과 구별이 안 됨
_NOT_MEANING = ("effective_date", "date", "notice_datetime")
def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str, Any]:
"""그 줄의 **값이 아닌 글자 칸**(이름·규격·단위·인도조건 …) — 이름표가 숨김 아니라 한 열만.
갱신으로 같은 코드에 다른 기준이 붙으면(노임 국가유산직종 개편 · 자재 KS 개정) 값만으로는 못 잡음.
"""
out = {}
for key, value in row.items():
if key.startswith("@") or key in editable or key in _NOT_MEANING or key.endswith("_date"):
continue
if isinstance(value, str) and value.strip() and not column_meta(kind, key)["hidden"]:
out[key] = value
return out
_INNER = ("@source", "@axis", "@data", "@added", "@changed")
def public(row: dict[str, Any]) -> dict[str, Any]:
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌."""
return {k: v for k, v in row.items() if k not in ("@source", "@axis")}
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`·`@data`…)은 뺌."""
return {k: v for k, v in row.items() if k not in _INNER}
#: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」).
@@ -538,6 +542,7 @@ def fold(text: str) -> str:
def table(
kind: str,
all_rows: list[dict[str, Any]],
page: int = 1,
size: int = tables.DEFAULT_PAGE_SIZE,
q: str = "",
@@ -545,7 +550,7 @@ def table(
desc: bool = False,
filters: dict[str, str] | None = None,
) -> dict[str, Any]:
all_rows = rows(kind)
"""한 표 한 쪽 — `all_rows` 는 `rows(kind)` 가 준 전체(DB 읽기와 셈을 떼어 둠)."""
columns = _columns(kind, all_rows)
needle = fold(q)
hits = (
@@ -569,7 +574,14 @@ def table(
"columns": [column_meta(kind, c) for c in columns],
# 못 고치는 칸(계산값)도 **세우기는 됨** — 화면은 이 목록으로 눌릴 제목을 가림
"sortable": list(columns),
"rows": [public(r) for r in hits[start : start + size]],
"rows": [
{
**public(r),
"changed_columns": r.get("@changed", []),
"is_added": r.get("@added", False),
}
for r in hits[start : start + size]
],
"total": len(hits),
**spec(kind, columns),
"notice": notice(kind),
@@ -578,49 +590,84 @@ def table(
}
def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, dict[str, Any]]:
"""고치기 — (상태 코드, 고친 뒤 그 줄 또는 막은 까닭)."""
base = next((r for r in base_rows(kind) if r["@id"] == row_id), None)
if base is None:
return 404, {"message": f"없는 줄: {row_id}"}
info = spec(kind, _columns(kind, rows(kind)))
for column, value in values.items():
if column in (base.get("@axis") or ()):
return 400, {"message": f"못 고치는 칸 {column}{_KEY}"}
if column not in info["editable"] or column not in base:
reason = info["locked"].get(column) or "이 줄에 없는 칸"
return 400, {"message": f"못 고치는 칸 {column}{reason}"}
problem = overrides.check_value(value)
class SaveError(ValueError):
"""[저장] 을 막는 까닭 — 하나라도 있으면 아무것도 안 씀(전부 아니면 전무)."""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
def check_value(value: Any) -> str | None:
"""값 칸에 넣을 수 있나 — 막는 까닭(없으면 None). null 은 빈 값(미공표 노임처럼)."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return "숫자만 넣을 수 있음"
if not math.isfinite(value) or value < 0:
return "0 이상 유한한 수만 넣을 수 있음"
return None
def _axis_of(kind: str) -> dict[str, tuple[str, ...]]:
"""줄마다 축 칸 — 파일 원본에서(표 모양이라 값 고치기로 안 바뀜 · 주입 data 에 `@axis` 가 없어도 섬)."""
if kind not in base_tables.SPEC:
return {}
return {r["@id"]: tuple(r.get("@axis") or ()) for r in base_rows(kind)}
def _cell_problem(
row: dict[str, Any], column: str, value: Any, info: dict[str, Any], axis: dict, fillable: set
) -> str | None:
"""한 칸 고치기를 막는 까닭 — 추가 줄은 저장 칸이면 글자 칸도(이름·규격을 사람이 넣은 줄)."""
if row["@added"]:
if column not in fillable:
return f"못 고치는 칸 {column}{info['locked'].get(column) or '이 표에 없는 칸'}"
elif column in axis.get(row["@id"], ()):
return f"못 고치는 칸 {column}{_KEY}"
elif column not in info["editable"] or column not in row:
return f"못 고치는 칸 {column}{info['locked'].get(column) or '이 줄에 없는 칸'}"
if column in info["editable"]:
problem = check_value(value)
return f"{column}: {problem}" if problem else None
if isinstance(value, (dict, list)):
return f"{column}: 글자나 숫자만 넣을 수 있음"
return None
def plan_save(
kind: str,
shown: list[dict[str, Any]],
edits: list[dict[str, Any]],
added: list[dict[str, Any]],
deleted: list[str],
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[str]]:
"""[저장] 한 번 — 다 맞춰 본 뒤 쓸 것만: (고친 줄 data · 새 줄 data · 지울 줄). 막히면 SaveError.
`shown` = `rows(kind)`(기계는 계산 칸까지 붙은 것) — 쓰는 값은 그 줄 `@data` 에 고친 칸만 얹음.
새 줄 열쇠 = `added/{난수}`(브레인 계약에 열쇠 규칙이 없어 서버가 줌 · 응답으로 알림).
"""
by_key = {r["@id"]: r for r in shown}
info = spec(kind, _columns(kind, shown))
axis = _axis_of(kind)
fillable = {c for r in shown for c in r["@data"] if not c.startswith("@")} # 계산 칸 제외
changed: dict[str, dict[str, Any]] = {}
for edit in edits:
row = by_key.get(edit["row_key"])
if row is None:
raise SaveError(404, f"없는 줄: {edit['row_key']}")
problem = _cell_problem(row, edit["column"], edit["value"], info, axis, fillable)
if problem:
return 400, {"message": f"{column}: {problem}"}
overrides.write(kind, base, values, by, meaning_of(kind, base, info["editable"]))
return 200, public(next(r for r in rows(kind) if r["@id"] == row_id))
def override_items(
kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE
) -> dict[str, Any]:
"""모아 보기 — 서버가 정렬(원본 바뀜 → 주인 없음 → 고쳐짐) · 거름(kind·state) · 쪽 · kind 별 state 수."""
out = []
counts: dict[str, dict[str, int]] = {}
for each in KINDS: # 수는 늘 다섯 다 셈 — 거름은 줄에만
base = base_rows(each)
by_id = {r["@id"]: r for r in base}
for item in overrides.items(each, base):
counts.setdefault(each, {}).setdefault(item["state"], 0)
counts[each][item["state"]] += 1
if (kind and each != kind) or (state and item["state"] != state):
continue
item["row_label"] = row_label(each, by_id.get(item["row_id"]))
meta = column_meta(each, item["column"])
item["column_label"] = meta["label"]
item["unit"] = meta["unit"]
out.append(item)
out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"]))
size = max(1, min(size, tables.MAX_PAGE_SIZE))
start = (max(page, 1) - 1) * size
return {"items": out[start : start + size], "total": len(out), "counts": counts}
def clear_overrides(kind: str, state: str) -> int:
return overrides.clear(kind, state, base_rows(kind))
raise SaveError(400, problem)
changed.setdefault(row["@id"], dict(row["@data"]))[edit["column"]] = edit["value"]
new: dict[str, dict[str, Any]] = {}
for data in added:
for column, value in data.items():
problem = _cell_problem({"@added": True}, column, value, info, axis, fillable)
if problem:
raise SaveError(400, problem)
new[f"added/{uuid.uuid4().hex[:12]}"] = dict(data)
missing = [key for key in deleted if key not in by_key]
if missing:
raise SaveError(404, f"없는 줄: {', '.join(missing)}")
return changed, new, list(dict.fromkeys(deleted))
@@ -1,7 +1,7 @@
"""Z01 기초단가 — 기계 한 표: 기종 한 줄 + 시간당 단가(계산값) · 산출근거(2026-09-15 브레인 계약).
기계단가는 취득가 613 · 손료계수 387 · 운전경비(연료·잡재료·조종원 — 건설품셈 8-4) · 노임 · 유가가 계산으로 물림.
고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **덮개 얹은 값**을 끌어 씀
고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **DB 의 지금 값**을 끌어 씀
계산 칸 = 손료 · 연료 · 조종원 · 시간당 단가 — editable 에서 빼고 locked 에 까닭
⚠ 셈은 B09 함수를 그대로 부름(`load_machine_catalog` · `load_operating_records` · `hourly_machine_cost`) — 식을 두 벌로 안 둠.
`hourly_cost_of`(B09) 와 같은 값인지 `test_z01_base_prices.py` 가 대조. 반만 선 값(연료·조종원 빠짐)은 시간당 단가를 안 냄.
@@ -9,13 +9,14 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from dataclasses import dataclass
from decimal import Decimal
from functools import lru_cache
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_WAGE_FORMULA,
MachineSpec,
hourly_machine_cost,
load_machine_catalog,
)
@@ -40,7 +41,7 @@ _FORMULA = {
_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜"
_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님"
_LOCKED = {
"machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음",
"machine_code": "자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음",
"machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"specification": "공표 원문 칸 — 원본 갱신으로만 바뀜",
"economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜",
@@ -50,8 +51,8 @@ _LOCKED = {
"misc_material_percent": _LOCKED_OPERATING,
"operator_occupation_code": _LOCKED_OPERATING,
"operator_person_per_day": _LOCKED_OPERATING,
"fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀",
"operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀",
"fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 전국평균을 끌어 씀",
"operator_daily_wage_krw": "노임 표에서 고침 — 여기는 건설 노임을 끌어 씀",
"hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬",
"effective_date": "원본 판 기준일 — 원본 갱신으로만 바뀜",
"operating_effective_date": "운전경비 원단위가 나온 건설품셈 판 기준일",
@@ -75,7 +76,7 @@ def _fmt(value: Decimal | int | float) -> str:
@dataclass(frozen=True)
class Inputs:
"""기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께."""
"""기계 셈이 다른 표에서 끌어 오는 값 — DB 지금 값 · 판 기준일과 함께."""
wages: dict[str, Decimal | None]
wage_date: str | None
@@ -123,10 +124,13 @@ def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, An
def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]:
"""덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길."""
catalog, records = _inputs()
"""DB 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길.
기종 제원은 줄 값에서(카탈로그 아님) — 관리자가 추가한 줄도 섬 · 운전경비 원단위는 코드로 찾아 없으면 빈칸.
"""
_, records = _inputs()
code = row["@id"]
coef = row["loss_coefficient_per_hour"]
coef = row.get("loss_coefficient_per_hour")
record = records.get(code)
out = {
**row,
@@ -137,13 +141,17 @@ def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]:
"operator_wage_date": None,
**dict.fromkeys(COMPUTED),
}
if coef is None:
out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬"
if coef is None or row.get("price_thousand_krw") is None:
out["hourly_note"] = f"{'손료계수' if coef is None else '취득가'} 없음 — 시간당 단가 안 섬"
return out
spec = replace(
catalog.machines[code],
spec = MachineSpec(
machine_code=code,
name=str(row.get("machine_name") or ""),
specification=str(row.get("specification") or ""),
price_thousand_krw=Decimal(str(row["price_thousand_krw"])),
loss_coefficient_per_hour=Decimal(str(coef)),
economic_life_hours=row.get("economic_life_hours"),
annual_standard_hours=row.get("annual_standard_hours"),
)
liters = fuel_price = wage = None
if record is not None:
@@ -4,7 +4,7 @@
태움 — 토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목재 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래
안 태움 — 이음표(`type_map`·`form_map`·`bond.codes`) · 품셈 원문 표(coef `surcharge_*`) · 글·방침 ·
구조물 실측(울진 한 현장 관찰값) · 기슭막이 교본값(값과 글이 한 줄에 섞임)
⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 덮개가 줄을 못 찾음.
⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 다른 표가 줄을 못 찾음.
⚠ 원문 중복(coef 「암괴…점토」 두 줄)은 **값이 같을 때만** 한 줄로 합침 — 갈리면 `DuplicateRowError`(브레인 조건).
"""
-202
View File
@@ -1,202 +0,0 @@
"""Z01 기초단가 덮개 — 사람이 고친 값만 담는 층(2026-09-15 브레인 틀 · 사용자 지시 「기초단가를 고칠 수 있게」).
원본 resources/data_*/… 읽기만 — 주기 갱신은 이 파일을 통째로 갈아끼움
덮개 resources/data_master_override/{kind}.json 고친 것만 — 주기가 달라 kind 마다 한 파일
읽기 원본 줄 위에 덮개를 얹음 · 줄 수준 `@overrides[열] = {original, value, state, current}`
state 셋(판정은 **여기 한 곳** — 화면이 따로 재지 않음):
edited 고쳐짐 — 원본 그대로(덮개를 쓸 때 본 원본 = 지금 원본)
source_changed 고쳐짐 — 원본이 바뀜 · ⚠ 덮개 값을 그대로 씀(자동으로 원본을 쓰면 금액이 말없이 바뀜 — 사람이 정함)
orphan 주인 없음 — 갱신으로 그 줄·칸이 사라짐 · 버리지 않고 모아 보기에 남김
되돌리기(「새 원본 값으로 받기」 도 같음) = 값 null → 그 칸을 덮개에서 뺌.
"""
from __future__ import annotations
import json
import math
import threading
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override"
EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan"
#: ⚠ 넷째 — 값이 아니라 **줄의 뜻**이 바뀜(같은 코드에 다른 기준: 노임 문화재→국가유산 · 자재 KS 개정 규격).
#: 값이 틀린 것보다 **다른 물건에 값이 붙은 것**이 더 나쁨 → 맨 위(브레인 2026-09-16).
MEANING_CHANGED = "meaning_changed"
STATE_ORDER = (MEANING_CHANGED, SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 센 것이 맨 위
_LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요
def _path(kind: str) -> Path:
return OVERRIDE_DIR / f"{kind}.json"
def load(kind: str) -> dict[str, dict[str, dict[str, Any]]]:
"""`@id` → 열 → {value, original, source, at, by}. 파일이 없으면 빈 덮개."""
path = _path(kind)
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8")).get("overrides") or {}
def same(a: Any, b: Any) -> bool:
"""값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음."""
if a is None or b is None:
return a is None and b is None
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return Decimal(str(a)) == Decimal(str(b))
return a == b
def check_value(value: Any) -> str | None:
"""넣을 수 있는 값인가 — 막는 까닭(없으면 None). null 은 되돌리기라 통과."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return "숫자만 넣을 수 있음"
if not math.isfinite(value) or value < 0:
return "0 이상 유한한 수만 넣을 수 있음"
return None
#: 이름 칸이 여럿인 자료가 있어(자재 품명·규격·단위·인도조건) **서버가 한 줄로 합침** — 화면이 합치면 판정이 두 벌.
_JOIN = " · "
def meaning_line(entry: dict[str, Any], row: dict[str, Any] | None) -> tuple[str, str]:
"""(고칠 때 그 줄 · 지금 그 줄) 한 줄씩 — 달라진 데가 없으면 빈 둘."""
stored = entry.get("meaning") or {}
keys = [k for k in stored if row is not None and k in row]
if not keys or all(row[k] == stored[k] for k in keys):
return "", ""
return (
_JOIN.join(str(stored[k]) for k in keys),
_JOIN.join(str(row[k]) for k in keys),
)
def mark(entry: dict[str, Any], current: Any, row: dict[str, Any] | None = None) -> dict[str, Any]:
original, value = entry.get("original"), entry.get("value")
was, now = meaning_line(entry, row)
if was:
state = MEANING_CHANGED
else:
state = EDITED if same(original, current) else SOURCE_CHANGED
out = {"original": original, "value": value, "state": state, "current": current}
if was: # ⚠ meaning_changed 인데 비어 오면 서버 버그 — 늘 함께 나감
out["was"], out["now"] = was, now
return out
def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**."""
store = load(kind)
out = []
for row in rows:
entries = store.get(row["@id"])
if not entries:
out.append(row)
continue
row = dict(row)
marks = {}
for column, entry in entries.items():
if column not in row:
continue # 주인 없는 칸 — 모아 보기에만
marks[column] = mark(entry, row[column], row)
row[column] = entry.get("value")
if marks:
row["@overrides"] = marks
out.append(row)
return out
def write(
kind: str,
base_row: dict[str, Any],
values: dict[str, Any],
by: Any,
meaning: dict[str, Any] | None = None,
) -> None:
"""고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림."""
row_id = base_row["@id"]
with _LOCK:
store = load(kind)
entries = dict(store.get(row_id) or {})
for column, value in values.items():
current = base_row.get(column)
if value is None or same(value, current):
entries.pop(column, None)
continue
entries[column] = {
"value": value,
"original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed
"source": base_row.get("@source", ""),
# 값이 아닌 글자 칸 — 갱신으로 **줄의 뜻**이 바뀌면 이것으로 잡음
"meaning": meaning or {},
"at": datetime.now().astimezone().isoformat(timespec="seconds"),
"by": by,
}
if entries:
store[row_id] = entries
else:
store.pop(row_id, None)
_save(kind, store)
def _save(kind: str, store: dict[str, dict[str, dict[str, Any]]]) -> None:
atomic_write_json(
_path(kind),
{
"schema_version": "1.0",
"kind": kind,
"note": "사람이 고친 기초단가만 — 원본 파일은 그대로 · Z01 마스터 데이터 화면이 씀",
"overrides": store,
},
)
def clear(kind: str, state: str, base_rows: list[dict[str, Any]]) -> int:
"""한 kind 의 그 state 덮개를 한꺼번에 뺌 — 품목 퇴출로 여럿이 주인을 잃는 갱신 뒤(브레인 ②). 뺀 수."""
with _LOCK:
doomed = {(i["row_id"], i["column"]) for i in items(kind, base_rows) if i["state"] == state}
if not doomed:
return 0
store = load(kind)
for row_id, column in doomed:
store[row_id].pop(column, None)
if not store[row_id]:
del store[row_id]
_save(kind, store)
return len(doomed)
def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""모아 보기 한 kind 몫 — 주인 없는 것까지 · 차례는 부르는 쪽이 STATE_ORDER 로."""
by_id = {r["@id"]: r for r in base_rows}
out = []
for row_id, entries in load(kind).items():
row = by_id.get(row_id)
for column, entry in entries.items():
if row is None or column not in row:
current, marked = None, {"state": ORPHAN}
else:
current = row[column]
marked = mark(entry, current, row)
out.append(
{
"kind": kind,
"row_id": row_id,
"column": column,
"original": entry.get("original"),
"current": current,
"value": entry.get("value"),
"state": marked["state"],
**({"was": marked["was"], "now": marked["now"]} if "was" in marked else {}),
}
)
return out
@@ -0,0 +1,168 @@
"""Z01 기초단가 저장소 — `master_base_price` · `master_revision` (020 · PLAN 1-0 계약 · 2026-09-18).
정본 = DB. 줄 = (kind, row_key) 한 줄 · `data` 는 지금 값 한 줄 전체 · `seed` 는 주입 때 초기값(관리자 추가 줄은 NULL).
판번호 = `master_revision.revision`(scope `base:{kind}`) — 쓸 때마다 +1 · 읽어 간 판과 다르면 안 씀(409).
⚠ 여기는 SQL 만 — 무엇을 고칠 수 있나·값 검사는 `Z01_MasterData_BasePrices` 가 함.
"""
from __future__ import annotations
import json
from typing import Any
import aiomysql
from config.config_db import get_db_pool
class RevisionConflict(Exception):
"""읽어 간 판번호가 지금 판과 다름 — 그 사이 누가 저장함."""
class UnknownRows(LookupError):
"""없는 줄 열쇠 — 되돌릴 줄을 못 찾음."""
def _scope(kind: str) -> str:
return f"base:{kind}"
def _json(text: Any) -> Any:
return json.loads(text) if isinstance(text, (str, bytes)) else text
def _dump(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
async def load(kind: str) -> list[dict[str, Any]]:
"""지운 줄 뺀 전부 — 넣은 차례(id) 그대로. {row_key, data, seed}."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT row_key, data, seed FROM master_base_price
WHERE kind = %s AND is_deleted = 0 ORDER BY id""",
(kind,),
)
return [
{"row_key": r["row_key"], "data": _json(r["data"]), "seed": _json(r["seed"])}
for r in await cursor.fetchall()
]
async def revision(kind: str) -> int:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT revision FROM master_revision WHERE scope = %s", (_scope(kind),)
)
row = await cursor.fetchone()
return int(row[0]) if row else 0
async def counts() -> dict[str, int]:
"""kind 마다 줄 수(지운 줄 뺌) — 표 목록 한 번에."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT kind, COUNT(*) FROM master_base_price WHERE is_deleted = 0 GROUP BY kind"
)
return {kind: int(n) for kind, n in await cursor.fetchall()}
async def _lock_revision(cursor: Any, kind: str) -> int:
"""판번호 줄을 잠그고 읽음 — 없으면 0 으로 세움(같은 kind 쓰기는 여기서 한 줄로 섬)."""
await cursor.execute(
"INSERT IGNORE INTO master_revision (scope, revision) VALUES (%s, 0)", (_scope(kind),)
)
await cursor.execute(
"SELECT revision FROM master_revision WHERE scope = %s FOR UPDATE", (_scope(kind),)
)
return int((await cursor.fetchone())[0])
async def _bump(cursor: Any, kind: str) -> None:
await cursor.execute(
"UPDATE master_revision SET revision = revision + 1 WHERE scope = %s", (_scope(kind),)
)
async def save(
kind: str,
base_revision: int,
changed: dict[str, dict[str, Any]],
added: dict[str, dict[str, Any]],
deleted: list[str],
by: int | None,
) -> int:
"""한 트랜잭션 — 판이 맞을 때만 고친 줄·새 줄·지운 줄을 한꺼번에(전부 아니면 전무). 새 판번호."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
current = await _lock_revision(cursor, kind)
if current != base_revision:
raise RevisionConflict(current)
for row_key, data in changed.items():
await cursor.execute(
"""UPDATE master_base_price
SET data = %s, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND row_key = %s AND is_deleted = 0""",
(_dump(data), by, kind, row_key),
)
for row_key, data in added.items():
await cursor.execute(
"""INSERT INTO master_base_price
(kind, row_key, data, seed, edition, is_deleted, updated_by, updated_at)
VALUES (%s, %s, %s, NULL, %s, 0, %s, CURRENT_TIMESTAMP)""",
(kind, row_key, _dump(data), str(data.get("effective_date") or ""), by),
)
for row_key in deleted:
await cursor.execute(
"""UPDATE master_base_price
SET is_deleted = 1, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND row_key = %s""",
(by, kind, row_key),
)
await _bump(cursor, kind)
await connection.commit()
return current + 1
except Exception:
await connection.rollback()
raise
async def reset(kind: str, row_keys: list[str], by: int | None) -> int:
"""고른 줄을 초기값으로 — 주입 줄은 data := seed(지운 줄도 되살림) · 추가 줄(seed NULL)은 지움. 새 판번호."""
keys = list(dict.fromkeys(row_keys))
marks = ", ".join(["%s"] * len(keys))
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
current = await _lock_revision(cursor, kind)
await cursor.execute(
f"SELECT row_key FROM master_base_price WHERE kind = %s AND row_key IN ({marks})",
(kind, *keys),
)
missing = set(keys) - {r[0] for r in await cursor.fetchall()}
if missing:
raise UnknownRows(sorted(missing))
await cursor.execute(
f"""UPDATE master_base_price
SET data = seed, is_deleted = 0, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND seed IS NOT NULL AND row_key IN ({marks})""",
(by, kind, *keys),
)
await cursor.execute(
f"""UPDATE master_base_price
SET is_deleted = 1, updated_by = %s, updated_at = CURRENT_TIMESTAMP
WHERE kind = %s AND seed IS NULL AND row_key IN ({marks})""",
(by, kind, *keys),
)
await _bump(cursor, kind)
await connection.commit()
return current + 1
except Exception:
await connection.rollback()
raise
+113 -39
View File
@@ -1,32 +1,69 @@
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인).
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 DB 정본 일괄 저장(2026-09-18 PLAN 1-0 계약).
GET /api/master-data/tree 갈래 → 파일 → 표
GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색)
GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함)
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표(기초단가 다섯 · 품셈 기준 열 · 공종 축 둘)
PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기
GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
GET /api/master-data/tree 갈래 → 파일 → 표
GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색)
GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함)
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표 + revision · 줄마다 changed_columns · is_added
PUT /api/master-data/base-prices/{kind} {base_revision, edits, added, deleted} → 409 · 전부 아니면 전무
POST /api/master-data/base-prices/{kind}/reset {row_keys} → 초기값으로(추가 줄은 지움)
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
⚠ 자동저장 없음 — 화면은 [저장] 에서만 PUT(지침 5장).
"""
from __future__ import annotations
from typing import Any, Literal
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
from common_util.common_util_auth import verify_session
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
from Z01_MasterData import Z01_MasterData_Tables as tables
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"])
_STALE = "그 사이 누가 이 표를 저장했습니다 — 표를 다시 불러와 고칠 것."
class BasePriceEdit(BaseModel):
values: dict[str, Any]
class CellEdit(BaseModel):
model_config = ConfigDict(extra="forbid")
row_key: str = Field(min_length=1, max_length=190)
column: str = Field(min_length=1, max_length=100)
value: Any = None
class AddedRow(BaseModel):
model_config = ConfigDict(extra="forbid")
data: dict[str, Any] = Field(min_length=1)
class BasePriceSave(BaseModel):
"""[저장] 한 번 — 읽어 간 판번호와 함께 고친 칸·새 줄·지운 줄."""
model_config = ConfigDict(extra="forbid")
base_revision: int = Field(ge=0)
edits: list[CellEdit] = Field(default_factory=list, max_length=500)
added: list[AddedRow] = Field(default_factory=list, max_length=500)
deleted: list[str] = Field(default_factory=list, max_length=500)
class BasePriceReset(BaseModel):
model_config = ConfigDict(extra="forbid")
row_keys: list[str] = Field(min_length=1, max_length=10000)
def _error(status: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": message})
@router.get("/tree")
@@ -50,14 +87,19 @@ def _kind(kind: str) -> str:
return kind
def _db_kind(kind: str) -> str | None:
"""고칠 수 있는 표(DB 정본) — 공종 축은 여기서 안 고침(`/work-items` 몫)."""
return kind if kind in base_prices.KINDS and kind not in work_items.KINDS else None
@router.get("/base-prices")
def get_base_price_kinds() -> dict:
async def get_base_price_kinds() -> dict:
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
return {"kinds": base_prices.kinds()}
return {"status": "success", "kinds": await base_prices.kinds()}
@router.get("/base-prices/{kind}")
def get_base_prices(
async def get_base_prices(
kind: str,
request: Request,
page: int = 1,
@@ -66,39 +108,71 @@ def get_base_prices(
sort: str = "",
desc: int = 0,
) -> dict:
kind = _kind(kind)
# 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실)
picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS}
if _db_kind(kind):
all_rows, revision = await asyncio.gather(base_prices.rows(kind), repo.revision(kind))
else:
all_rows, revision = await base_prices.rows(kind), 0
try:
return base_prices.table(
_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked
table = await run_in_threadpool(
base_prices.table,
kind,
all_rows,
page=page,
size=size,
q=q,
sort=sort,
desc=bool(desc),
filters=picked,
)
except base_prices.SortError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return {"status": "success", "revision": revision, **table}
@router.put("/base-prices/{kind}/{row_id:path}")
def put_base_price(
kind: str, row_id: str, body: BasePriceEdit, session: dict = Depends(verify_session)
@router.put("/base-prices/{kind}")
async def put_base_prices(
kind: str, body: BasePriceSave, session: dict = Depends(verify_session)
) -> JSONResponse:
status, payload = base_prices.edit(_kind(kind), row_id, body.values, session.get("user_id"))
if status != 200:
raise HTTPException(status_code=status, detail=payload["message"])
return JSONResponse(content=payload)
"""고친 칸·새 줄·지운 줄을 한 번에 — 다 맞춰 본 뒤 한 트랜잭션(하나라도 틀리면 아무것도 안 씀)."""
if not _db_kind(kind):
return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}")
if await repo.revision(kind) != body.base_revision:
return _error(409, _STALE)
try:
changed, added, deleted = base_prices.plan_save(
kind,
await base_prices.rows(kind),
[edit.model_dump() for edit in body.edits],
[row.data for row in body.added],
body.deleted,
)
except base_prices.SaveError as error:
return _error(error.status, str(error))
if not (changed or added or deleted):
return JSONResponse(
content={"status": "success", "revision": body.base_revision, "added": []}
)
try:
revision = await repo.save(
kind, body.base_revision, changed, added, deleted, session.get("user_id")
)
except repo.RevisionConflict:
return _error(409, _STALE)
return JSONResponse(content={"status": "success", "revision": revision, "added": list(added)})
@router.get("/overrides")
def get_overrides(
kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE
) -> dict:
return base_prices.override_items(kind and _kind(kind), state, page=page, size=size)
class OverrideClear(BaseModel):
kind: str
state: Literal["edited", "source_changed", "orphan"]
@router.post("/overrides/clear")
def clear_overrides(body: OverrideClear, _session: dict = Depends(verify_session)) -> dict:
"""한 kind 의 그 state 덮개를 한꺼번에 뺌 — kind·state 둘 다 있어야 함(통째로 비우기 막음)."""
return {"removed": base_prices.clear_overrides(_kind(body.kind), body.state)}
@router.post("/base-prices/{kind}/reset")
async def reset_base_prices(
kind: str, body: BasePriceReset, session: dict = Depends(verify_session)
) -> JSONResponse:
"""고른 줄을 초기값으로 — 주입 줄은 data := seed · 추가 줄은 지움. 판번호 +1."""
if not _db_kind(kind):
return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}")
try:
revision = await repo.reset(kind, body.row_keys, session.get("user_id"))
except repo.UnknownRows as error:
return _error(404, f"없는 줄: {', '.join(error.args[0])}")
return JSONResponse(content={"status": "success", "revision": revision})
+92
View File
@@ -0,0 +1,92 @@
"""Z01 기초단가 저장소 가짜 — DB 없이 API 시험(PLAN 1-2 · 2026-09-18).
`install(monkeypatch)` 가 `Z01_MasterData_Repository_BasePrices` 의 함수 다섯을 바꿔 끼움.
주입 = 파일 원본 줄(`base_rows`) 그대로 · ⚠ '@' 칸(`@axis`·`@source`)은 **빼고** 담음 —
주입기가 안쪽 칸을 빼도 서버가 서는지 함께 잼. 판번호 규칙(다르면 409 · 쓰면 +1)은 진짜와 같게.
"""
from __future__ import annotations
from typing import Any
import pytest
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
class FakeRepo:
def __init__(self) -> None:
self.tables: dict[str, list[dict[str, Any]]] = {}
self.revisions: dict[str, int] = {}
self.saved_by: list[Any] = []
def table(self, kind: str) -> list[dict[str, Any]]:
if kind not in self.tables:
self.tables[kind] = []
for row in base_prices.base_rows(kind):
data = {k: v for k, v in row.items() if not k.startswith("@")}
self.tables[kind].append(
{"row_key": row["@id"], "data": data, "seed": dict(data), "deleted": False}
)
return self.tables[kind]
def by_key(self, kind: str) -> dict[str, dict[str, Any]]:
return {r["row_key"]: r for r in self.table(kind)}
async def load(self, kind: str) -> list[dict[str, Any]]:
return [
{"row_key": r["row_key"], "data": dict(r["data"]), "seed": r["seed"]}
for r in self.table(kind)
if not r["deleted"]
]
async def revision(self, kind: str) -> int:
return self.revisions.get(kind, 0)
async def counts(self) -> dict[str, int]:
return {
kind: sum(not r["deleted"] for r in self.table(kind))
for kind in base_prices.KINDS
if kind not in work_items.KINDS
}
async def save(self, kind, base_revision, changed, added, deleted, by) -> int:
current = self.revisions.get(kind, 0)
if current != base_revision:
raise repo.RevisionConflict(current)
rows = self.by_key(kind)
for key, data in changed.items():
if not rows[key]["deleted"]:
rows[key]["data"] = dict(data)
for key, data in added.items():
self.table(kind).append(
{"row_key": key, "data": dict(data), "seed": None, "deleted": False}
)
for key in deleted:
rows[key]["deleted"] = True
self.revisions[kind] = current + 1
self.saved_by.append(by)
return current + 1
async def reset(self, kind, row_keys, by) -> int:
rows = self.by_key(kind)
missing = sorted(set(row_keys) - set(rows))
if missing:
raise repo.UnknownRows(missing)
for key in row_keys:
row = rows[key]
if row["seed"] is None:
row["deleted"] = True
else:
row["data"], row["deleted"] = dict(row["seed"]), False
self.revisions[kind] = self.revisions.get(kind, 0) + 1
return self.revisions[kind]
def install(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
fake = FakeRepo()
for name in ("load", "revision", "counts", "save", "reset"):
monkeypatch.setattr(repo, name, getattr(fake, name))
return fake
@@ -10,10 +10,12 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from helper_z01_fake_repo import install
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
@@ -28,11 +30,13 @@ def labels() -> dict:
@pytest.fixture(scope="module")
def shown() -> dict[str, list[dict]]:
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다."""
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다(DB 는 파일을 주입한 가짜)."""
out = {}
for kind in base_prices.KINDS:
columns = base_prices._columns(kind, base_prices.rows(kind))
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
with pytest.MonkeyPatch.context() as patch:
install(patch)
for kind in base_prices.KINDS:
columns = base_prices._columns(kind, asyncio.run(base_prices.rows(kind)))
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
return out
@@ -99,8 +103,8 @@ def test_요율_축이_한_칸으로_안_합쳐졌다(shown):
def test_요율_줄_열쇠에_축이_살아_있다():
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다."""
rows = base_prices.rows("rate")
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 주입 열쇠(row_key)가 흔들린다."""
rows = base_prices.base_rows("rate")
multi = [r for r in rows if "/" in str(r["@id"])]
assert multi, "축이 든 줄 열쇠가 하나도 없다"
assert any("=" in str(r["@id"]) for r in multi)
@@ -109,7 +113,7 @@ def test_요율_줄_열쇠에_축이_살아_있다():
def test_기계_시간당_단가는_계산값으로_잠긴다(shown):
"""밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다)."""
meta = base_prices.table("machine", page=1, size=1)
meta = base_prices.table("machine", base_prices.base_rows("machine"), page=1, size=1)
assert set(meta["formula"]) == {
"hourly_loss_krw",
"hourly_fuel_krw",
@@ -155,15 +159,15 @@ def test_단위가_붙어야_할_열에_단위가_있다(shown):
def test_줄_열쇠가_서버_축과_같다(labels):
""" 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다.
"""⭐ 줄을 붙드는 끈 — 파일 원본이 줄마다 `@axis` 로 내는 축이 곧 열쇠(주입 row_key)다.
이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다.
이름표가 축을 빠뜨리면 새 판 주입 때 같은 줄을 못 알아본다.
"""
declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]}
bad = []
for kind in base_prices.KINDS:
axes = set()
for row in base_prices.rows(kind):
for row in base_prices.base_rows(kind):
axes |= set(row.get("@axis") or ())
if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다
continue
@@ -174,13 +178,13 @@ def test_줄_열쇠가_서버_축과_같다(labels):
def test_구간_이름은_원문_표기_그대로다():
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다."""
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 주입한 줄을 못 알아본다."""
for kind in ("masonry_slope", "masonry_back_length"):
brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)}
brackets = {str(r.get("height_bracket")) for r in base_prices.base_rows(kind)}
assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets))
classes = {
str(r.get("class"))
for r in base_prices.rows("masonry_class")
for r in base_prices.base_rows("masonry_class")
if r.get("table") == "boulder_diameter"
}
assert classes == {"40~60", "60~80", "80~100"}, sorted(classes)
@@ -198,13 +202,13 @@ def test_축_칸은_못_고친다():
"""축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다."""
bad = []
for kind in base_prices.KINDS:
rows = base_prices.rows(kind)
rows = base_prices.base_rows(kind)
axes = set()
for row in rows:
axes |= set(row.get("@axis") or ())
if not axes:
continue
meta = base_prices.table(kind, page=1, size=1)
meta = base_prices.table(kind, rows, page=1, size=1)
for axis in axes:
if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE:
bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다")
@@ -228,10 +232,11 @@ def test_기초단가_상자_이름이_이름표에_있다(labels):
assert named["pumsem_basis"]["name_ko"] == "품셈 기준"
def test_상자마다_kind_가_있다():
def test_상자마다_kind_가_있다(monkeypatch):
"""빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다."""
install(monkeypatch)
counted: dict[str, int] = {}
for entry in base_prices.kinds():
for entry in asyncio.run(base_prices.kinds()):
counted[entry["group"]] = counted.get(entry["group"], 0) + 1
assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다"
assert entry["rows"] > 0, entry["kind"]
+265 -285
View File
@@ -1,23 +1,27 @@
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시).
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2).
(브레인 못박음): 원본 `resources/data_*` **읽기만** · 고친 값은 덮개
`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 파일)에만 · 읽을 원본 위에 얹음 ·
덮개 줄마다 original(원래 )·source(원본 파일) 원본이 바뀌면 원본 바뀜 · 사라지면 주인 없음(버리지 않음) ·
: 정본 = DB `master_base_price`(data = 지금 · seed = 초기값) · 원본 파일 `resources/data_*` **주입 재료로 읽기만** ·
[저장] = {base_revision, edits, added, deleted} 판이 다르면 409 · 하나라도 틀리면 아무것도 ·
줄마다 changed_columns(초기값과 다른 )·is_added(관리자 추가 ) · [초기값으로] = data := seed(추가 지움).
기계 시간당 단가는 계산값 editable 에서 빼고 고치나(locked) · 밑값(취득가·손료계수·노임·유가) 고치면 따라 바뀜.
DB `helper_z01_fake_repo` 가짜로 저장소 SQL 흐름(잠금·되돌림) 아래 가짜 커넥션으로 .
"""
from __future__ import annotations
import asyncio
import hashlib
import json
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import FakeRepo, install
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
from Z01_MasterData import Z01_MasterData_Router as router_module
ROOT = Path(__file__).resolve().parents[2]
@@ -36,14 +40,12 @@ SOURCES = [
@pytest.fixture
def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
folder = tmp_path / "data_master_override"
monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder)
return folder
def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
return install(monkeypatch)
@pytest.fixture
def client(store: Path) -> TestClient:
def client(fake: FakeRepo) -> TestClient:
from common_util.common_util_auth import verify_session
app = FastAPI()
@@ -63,8 +65,20 @@ def _row(client: TestClient, kind: str, row_id: str) -> dict:
return next(r for r in rows if r["@id"] == row_id)
def _put(client: TestClient, kind: str, row_id: str, values: dict):
return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values})
def _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None):
if base is None:
base = _get(client, kind, size=1)["revision"]
body = {
"base_revision": base,
"edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits],
"added": [{"data": d} for d in added],
"deleted": list(deleted),
}
return client.put(f"/api/master-data/base-prices/{kind}", json=body)
def _edit(client: TestClient, kind: str, row_id: str, column: str, value):
return _save(client, kind, edits=[(row_id, column, value)])
def _sha(path: Path) -> str:
@@ -81,13 +95,15 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No
for kind, (total, editable) in expected.items():
table = _get(client, kind, size=500)
assert table["total"] == total and table["editable"] == editable, kind
assert table["status"] == "success" and table["revision"] == 0, kind
ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]]
assert len(ids) == len(set(ids)), kind
assert all("@overrides" not in r for r in table["rows"]), (
kind
) # 안 고친 줄엔 칸 자체가 없음
# 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감
assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"])
assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")}
columns = {c["key"] for c in table["columns"]}
assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖
assert not columns & {"changed_columns", "is_added"}, kind
assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)"
@@ -116,8 +132,8 @@ def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를
"hourly_total_krw" in row["@formula"]
and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"]
)
res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1})
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.text
res = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1)
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"]
def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None:
@@ -146,38 +162,23 @@ def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClien
assert checked >= 5
def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None:
def test_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None:
before = {p: _sha(p) for p in SOURCES}
res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
res = _save(
client,
"labor",
edits=[("labor_const/1001", "daily_wage_krw", 230000)],
base=0,
)
assert res.status_code == 200, res.text
row = res.json()
assert row["daily_wage_krw"] == 230000
assert row["@overrides"] == {
"daily_wage_krw": {
"original": 215907,
"value": 230000,
"state": overrides.EDITED,
"current": 215907,
}
}
assert (
_row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000
)
assert (
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}).status_code
== 200
)
assert sorted(p.name for p in store.iterdir()) == [
"labor.json",
"oil.json",
] # kind 마다 한 파일
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))
entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"]
assert (
entry["original"] == 215907
and entry["source"] == "labor_const_2026-01-01.json"
and entry["by"] == 42
)
assert res.json() == {"status": "success", "revision": 1, "added": []}
row = _row(client, "labor", "labor_const/1001")
assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"]
stored = fake.by_key("labor")["labor_const/1001"]
assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907
assert fake.saved_by == [42] # 누가 고쳤나
assert _get(client, "labor", size=1)["revision"] == 1
assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다
assert {p: _sha(p) for p in SOURCES} == before
@@ -185,139 +186,113 @@ def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClien
base = _row(client, "machine", "0101-0007")
operator = base["operator_occupation_code"]
wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"]
_put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000})
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 2000})
_put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2})
assert _edit(
client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000
).is_success
assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success
price = base["price_thousand_krw"]
assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success
after = _row(client, "machine", "0101-0007")
assert after["hourly_operator_krw"] > base["hourly_operator_krw"]
assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"]
assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2)
price = base["price_thousand_krw"]
assert after["@overrides"] == {
"price_thousand_krw": {
"original": price,
"value": price * 2,
"state": overrides.EDITED,
"current": price,
}
} # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜
# 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜
assert after["changed_columns"] == ["price_thousand_krw"]
def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None:
_put(client, "material", "10023392", {"price_krw": 30})
reverted = _put(client, "material", "10023392", {"price_krw": None}).json()
assert reverted["price_krw"] == 22 and "@overrides" not in reverted
assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200
saved = json.loads((store / "material.json").read_text(encoding="utf-8"))
assert saved["overrides"] == {}
def test_계산_칸은_DB_에_안_(client: TestClient, fake: FakeRepo) -> None:
"""기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴."""
_edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001)
data = fake.by_key("machine")["0101-0007"]["data"]
assert data["loss_coefficient_per_hour"] == 0.001
assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data)
def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김(
client: TestClient, store: Path
def test_하나라도_틀리면_아무것도_안_씀(client: TestClient, fake: FakeRepo) -> None:
good = ("labor_const/1001", "daily_wage_krw", 230000)
for bad, status in (
(("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸
(("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값
(("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄
):
res = _save(client, "labor", edits=[good, bad])
assert res.status_code == status, (bad, res.text)
assert res.json()["status"] == "error"
res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"])
assert res.status_code == 404
assert fake.revisions == {} and fake.saved_by == []
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
def test_낡은_판으로_저장하면_409(
client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch
) -> None:
store.mkdir()
(store / "labor.json").write_text(
json.dumps(
{
"schema_version": "1.0",
"kind": "labor",
"overrides": {
"labor_const/1001": {
"daily_wage_krw": {
"value": 199000,
"original": 200000,
"source": "labor_const_2025-07-01.json",
}
},
"labor_const/9999": {
"daily_wage_krw": {
"value": 1,
"original": 1,
"source": "labor_const_2025-07-01.json",
}
},
},
}
),
encoding="utf-8",
)
# 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이
row = _row(client, "labor", "labor_const/1001")
assert row["daily_wage_krw"] == 199000
mark = row["@overrides"]["daily_wage_krw"]
assert mark == {
"original": 200000,
"value": 199000,
"state": overrides.SOURCE_CHANGED,
"current": 215907,
}
_put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000})
listed = client.get("/api/master-data/overrides").json()
assert [
(i["row_id"], i["state"]) for i in listed["items"]
] == [ # 서버가 정렬 — 원본 바뀜이 맨 위
("labor_const/1001", overrides.SOURCE_CHANGED),
("labor_const/9999", overrides.ORPHAN),
("labor_const/1002", overrides.EDITED),
]
first = listed["items"][0]
assert set(first) == {
"kind",
"row_id",
"row_label",
"column",
"column_label",
"original",
"current",
"value",
"state",
"unit",
}
assert first["row_label"] == "작업반장" and (
first["original"],
first["current"],
first["value"],
) == (200000, 215907, 199000)
assert listed["items"][1]["current"] is None
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움
# 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기
kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json()
assert kept["@overrides"]["daily_wage_krw"] == {
"original": 215907,
"value": 199000,
"state": overrides.EDITED,
"current": 215907,
}
taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json()
assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken
assert _save(
client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0
).is_success
stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"]
assert _row(client, "labor", "labor_const/1002")["changed_columns"] == []
# 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄)
async def old_revision(kind: str) -> int:
return 0
monkeypatch.setattr(repo, "revision", old_revision)
raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
assert raced.status_code == 409 and fake.revisions["labor"] == 1
def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜(
client: TestClient, store: Path
) -> None:
blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None)
row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json()
assert row["@overrides"]["daily_wage_krw"] == {
"original": None,
"value": 150000,
"state": overrides.EDITED,
"current": None,
}
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))
saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = (
140000 # 옛 판엔 값이 있었다고 치면
def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None:
data = {"specification": "ZZ시험 규격, 특수", "unit": "", "price_krw": 1234}
res = _save(client, "material", added=[data])
assert res.status_code == 200, res.text
(key,) = res.json()["added"]
assert key.startswith("added/") and res.json()["revision"] == 1
table = _get(client, "material", size=1, page=7000)
assert table["total"] == 7000
row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례)
assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, [])
assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수"
assert _get(client, "material", q="ZZ시험")["total"] == 1
# 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침
assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success
assert _edit(client, "material", "10023392", "specification", "x").status_code == 400
for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}):
assert _save(client, "material", added=[bad]).status_code == 400, bad
assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄
gone = _save(client, "material", deleted=[key, "10023392"])
assert gone.status_code == 200
ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]}
assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998
assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게)
def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None:
_edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000)
added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}])
(key,) = added.json()["added"]
_save(client, "labor", deleted=["labor_const/1002"])
res = client.post(
"/api/master-data/base-prices/labor/reset",
json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]},
)
(store / "labor.json").write_text(json.dumps(saved), encoding="utf-8")
assert (
_row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"]
== overrides.SOURCE_CHANGED
assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4}
back = _row(client, "labor", "labor_const/1001")
assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == []
assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림
assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움
missing = client.post(
"/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]}
)
assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"]
empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []})
assert empty.status_code == 422 # 통째로 되돌리기 막음
def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
rate = _get(client, "rate", size=1)
assert any("갱신" in line and "주인" in line for line in rate["notice"])
assert any("법이 정한 값" in line and "고시" in line for line in rate["notice"])
for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로
notice = _get(client, kind, size=1)["notice"]
assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind
@@ -325,16 +300,18 @@ def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
def test_막는_자리(client: TestClient) -> None:
assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404
assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404
assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400
locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸
assert locked.status_code == 400 and "공표 원문" in locked.text
assert _save(client, "wage", base=0).status_code == 404
assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫
locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸
assert locked.status_code == 400 and "공표 원문" in locked.json()["message"]
for bad in (True, -1, "220000", float("nan")):
body = json.dumps({"values": {"daily_wage_krw": bad}}).encode()
body = {
"base_revision": 0,
"edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}],
}
res = client.put(
"/api/master-data/base-prices/labor/labor_const/1001",
content=body,
"/api/master-data/base-prices/labor",
content=json.dumps(body).encode(),
headers={"content-type": "application/json"},
)
assert res.status_code in (400, 422), bad
@@ -344,9 +321,20 @@ def test_막는_자리(client: TestClient) -> None:
if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")
)
assert (
_put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400
) # 그 줄엔 요율 칸 없음
_edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400
) # 그 줄엔 요율 칸 없음
assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56")
many = [("labor_const/1001", "daily_wage_krw", 1)] * 501
assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지
extra = client.put(
"/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}}
)
assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함)
old = client.put(
"/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}}
)
assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐
assert client.get("/api/master-data/overrides").status_code == 404
def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None:
@@ -364,61 +352,6 @@ def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) ->
assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None
def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None:
"""브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음."""
store.mkdir()
gone = {
f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}}
for i in range(3)
}
(store / "material.json").write_text(
json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}),
encoding="utf-8",
)
_put(client, "material", "10023392", {"price_krw": 30})
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
listed = client.get(
"/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"}
).json()
assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN}
assert listed["counts"] == {
"material": {overrides.ORPHAN: 3, overrides.EDITED: 1},
"labor": {overrides.EDITED: 1},
}
paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json()
assert paged["total"] == 5 and len(paged["items"]) == 2
everything = client.get("/api/master-data/overrides").json()[
"items"
] # labor 가 kind 차례로 앞이어도
assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2
res = client.post(
"/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN}
)
assert res.status_code == 200 and res.json()["removed"] == 3
left = client.get("/api/master-data/overrides").json()
assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례
("labor", overrides.EDITED),
("material", overrides.EDITED),
]
assert (
client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code
== 422
) # 거름 없이 통째로 못 비움
def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None:
rates = _get(client, "rate", size=500)["rows"]
fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/"))
store.mkdir()
entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}}
(store / "rate.json").write_text(
json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8"
)
items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"]
assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)]
assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음
SOURCE_KEYS = {
"source_id",
"name",
@@ -476,12 +409,6 @@ def test_우리_판은_출처표_글자가_아니라_파일에서_읽음(
assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로)
def test_모아_보기_항목에_단위(client: TestClient) -> None:
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
(item,) = client.get("/api/master-data/overrides").json()["items"]
assert item["unit"] == "" and item["column_label"] == "일 노임"
def test_열_이름은_이름표에서만_옴(client: TestClient) -> None:
"""브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본."""
labels = json.loads(
@@ -540,8 +467,8 @@ def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None:
assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐
def test_덮개_얹은_값으로_세움(client: TestClient) -> None:
_put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000})
def test_고친_값으로_세움(client: TestClient) -> None:
_edit(client, "labor", "labor_const/1003", "daily_wage_krw", 9_000_000)
top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0]
assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000
@@ -551,54 +478,107 @@ def test_sort_가_없으면_원본_차례(client: TestClient) -> None:
assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007"
def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None:
"""브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격).
값만 보면 잡힘 덮개를 **값이 아닌 글자 **(이름·규격·단위) 함께 적어 두고 견줌.
"""
saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json()
assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED
entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"]
assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만
assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음
assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900})
oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"]
assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"]
entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름"
(store / "labor.json").write_text(
json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8"
)
mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]
assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜
# 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌)
assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published"
assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published"
listed = client.get("/api/master-data/overrides").json()
top = listed["items"][0]
assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게
assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘
assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED
assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED)
def test_추가한_기계_줄도_셈이_섬(client: TestClient) -> None:
"""카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄."""
data = {
"machine_name": "시험 굴삭기",
"price_thousand_krw": 100000,
"loss_coefficient_per_hour": 0.0001,
}
(key,) = _save(client, "machine", added=[data]).json()["added"]
row = _get(client, "machine", size=1, page=614)["rows"][0]
assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000)
assert row["hourly_total_krw"] is None and row["hourly_note"]
assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸
def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None:
_put(client, "machine", "0101-0007", {"price_thousand_krw": 80000})
meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][
"0101-0007"
]["price_thousand_krw"]["meaning"]
assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7"
assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")]
store.mkdir(
exist_ok=True
) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음)
(store / "material.json").write_text(
json.dumps(
{"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}}
),
encoding="utf-8",
)
assert (
_row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED
)
# ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ──────────────────────────────
class _Cursor:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql: str, params=()) -> None:
self.db.sql.append(" ".join(sql.split()))
async def fetchone(self):
return (self.db.revision,)
async def fetchall(self):
return [(key,) for key in self.db.keys]
class _Connection:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
def cursor(self, *_):
return _Cursor(self.db)
async def begin(self) -> None:
self.db.log.append("begin")
async def commit(self) -> None:
self.db.log.append("commit")
async def rollback(self) -> None:
self.db.log.append("rollback")
class _Pool:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
def acquire(self):
return self
async def __aenter__(self):
return _Connection(self.db)
async def __aexit__(self, *_):
return False
def _writes(db: SimpleNamespace) -> list[str]:
return [
s
for s in db.sql
if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price"))
]
def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None:
db = SimpleNamespace(sql=[], log=[], revision=4, keys=[])
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
with pytest.raises(repo.RevisionConflict):
asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7))
assert db.log == ["begin", "rollback"] and not _writes(db)
db.sql.clear()
db.log.clear()
assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5
assert db.log == ["begin", "commit"]
lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE"))
bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision"))
writes = [db.sql.index(s) for s in _writes(db)]
assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1
assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만
def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None:
db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"])
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
with pytest.raises(repo.UnknownRows):
asyncio.run(repo.reset("labor", ["a", "없음"], 7))
assert db.log == ["begin", "rollback"] and not _writes(db)
db.sql.clear()
db.log.clear()
assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3
assert db.log == ["begin", "commit"]
assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "]
+32 -28
View File
@@ -19,9 +19,9 @@ ROOT = Path(__file__).resolve().parents[2]
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import install
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
@@ -40,10 +40,10 @@ NEW_KINDS = (
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
from common_util.common_util_auth import verify_session
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
install(monkeypatch) # DB 가짜 — 파일 원본을 초기값으로 주입한 모양
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
@@ -69,13 +69,13 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None
table = _get(client, kind, size=500)
assert table["total"] == len(table["rows"]) > 0, kind
ids = _ids(table)
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 (kind, row_key) 한 줄이 둘을 덮
assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind
assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글
assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind
axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())}
assert {table["locked"][c] for c in axis if c in table["locked"]} == {
"자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음"
"자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음"
}, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함
assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind
assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로)
@@ -165,35 +165,39 @@ def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None:
)
def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None:
res = client.put(
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
json={"values": {"m3_per_m2": 0.1}},
def _save(client: TestClient, kind: str, row_key: str, column: str, value):
revision = _get(client, kind, size=1)["revision"]
return client.put(
f"/api/master-data/base-prices/{kind}",
json={
"base_revision": revision,
"edits": [{"row_key": row_key, "column": column, "value": value}],
},
)
def test_고치기도_같은_길(client: TestClient) -> None:
res = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "m3_per_m2", 0.1)
assert res.status_code == 200, res.text
row = res.json()
assert row["m3_per_m2"] == 0.1
assert row["@overrides"]["m3_per_m2"]["original"] == 0.09
assert (tmp_path / "data_master_override" / "stone_kind.json").is_file()
listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json()
assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"]
blocked = client.put(
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
json={"values": {"stone_kind": "딴 돌"}},
)
rows = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]}
row = rows["wedge_stone_m3_per_m2/깬잡석/25"]
assert row["m3_per_m2"] == 0.1 and row["changed_columns"] == ["m3_per_m2"]
assert [k for k, r in rows.items() if r["changed_columns"]] == [
"wedge_stone_m3_per_m2/깬잡석/25"
]
blocked = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "stone_kind", "딴 돌")
assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침
assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄
assert not {"@axis", "@source", "@data"} & set(row) # 안쪽 칸은 안 내보냄
# 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함
axis_edit = client.put(
"/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood_material_pct/2",
json={"values": {"reuse_count": 3}},
# ⚠ 주입 data 에 `@axis` 가 없어도 막힘(축은 파일 원본에서 읽음 · 가짜 주입은 '@' 칸을 뺌)
axis_edit = _save(
client, "formwork_reuse", "reuse_ratio_pct/plywood_material_pct/2", "reuse_count", 3
)
assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text
value_edit = client.put(
"/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조",
json={"values": {"reuse_count": 3}},
)
assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3
value_edit = _save(client, "formwork_reuse", "reuse_by_class/복잡한 구조", "reuse_count", 3)
assert value_edit.status_code == 200
reuse = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]}
assert reuse["reuse_by_class/복잡한 구조"]["reuse_count"] == 3
def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None:
+11 -6
View File
@@ -12,17 +12,17 @@ from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import install
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
from common_util.common_util_auth import verify_session
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
install(monkeypatch) # 기초단가 표 목록·줄 수가 DB 를 읽음 — 가짜로
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
@@ -131,10 +131,15 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
json={"values": {"name": "바꿈"}},
"/api/master-data/base-prices/work_item_const",
json={
"base_revision": 0,
"edits": [{"row_key": "CW-00002#01", "column": "name", "value": "바꿈"}],
},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
assert (
res.status_code == 400 and "고칠 수 없는 표" in res.json()["message"]
) # 공종은 /work-items 몫
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None: