diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 86c7dba0..12c31e68 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -128,6 +128,31 @@ export interface StructureNumbers { export const fetchStructureNumbers = (): Promise> => call("/structures/numbers"); +/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */ +export interface StructureCombo extends StructureNumbers { + 키: string; + 열: string; + 종류: string | null; + 분류1: string | null; + 분류2: string | null; + 분류3: string | null; + 단위: string | null; + 일위대가: string | null; + 바인딩: unknown; + 펼침: unknown; +} + +export interface StructureCombos { + 그룹: string; + 판: number; + 비고: string; + 다음: number; + 줄: StructureCombo[]; +} + +/** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */ +export const fetchStructureCombos = (): Promise => call("/structure-combos"); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index f6abafb7..696292b6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -37,6 +37,12 @@ def get_structure_numbers() -> dict: return _call(store.structure_numbers) +@router.get("/structure-combos") +def get_structure_combos() -> dict: + """구조물집계표조합 — 구조물 열마다 줄 하나(키 SS · 제목행 · 번호 · 바인딩) · 읽기만.""" + return _call(store.read_combos) + + @router.post("/structures/{column}") def post_structure(column: str) -> dict: """구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출 · 재료산출도 · 이미 있으면 409.""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 77612ea0..13e8a2c8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -120,6 +120,7 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: if kind == "table" and name == STRUCTURE_TABLE: backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() + sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸 return info @@ -369,3 +370,59 @@ def backfill_missing_structures() -> list[str]: for cid in _column_ids(): made += [f"{kind}/{cid}" for kind in _KEY if _ensure(kind, cid)] return made + + +# ── 구조물집계표조합 ───────────────────── +# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) · +# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억) +COMBO_FILE = layers.COMBO_FILE +_COMBO_HEAD = "SS" +_COMBO_NOTE = ( + "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · " + "키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀" +) + + +def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]: + kind, level1, level2, level3 = _heads(col) + return { + "키": key, + "열": col["id"], + "종류": kind, + "분류1": level1, + "분류2": level2, + "분류3": level3, + "단위": col.get("단위"), + "일위대가": col.get("일위대가"), + **{field: numbers.get(field, "") for _, field, _, _ in PARTS}, + "바인딩": col.get("바인딩"), + "펼침": col.get("펼침"), + } + + +def sync_combos() -> dict[str, Any]: + """조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 · + 줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀.""" + path = FOLDER / COMBO_FILE + with _LOCK: + old = json.loads(path.read_bytes()) if path.is_file() else {} + keys = {row["열"]: row["키"] for row in old.get("줄", [])} + taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()] + after = max(old.get("다음", 1), max(taken, default=0) + 1) + numbers = structure_numbers() + rows = [] + for col in _structure_columns(): + if col["id"] not in keys: + keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}" + after += 1 + rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {}))) + doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after, "줄": rows} + if doc != old: + atomic_write_json(path, doc) + return doc + + +def read_combos() -> dict[str, Any]: + """조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦.""" + path = FOLDER / COMBO_FILE + return json.loads(path.read_bytes()) if path.is_file() else sync_combos() diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index a15b4ca1..f56c4882 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -31,6 +31,7 @@ KINDS = ("table", "drawing", "structure", "area", "material") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" +COMBO_FILE = "구조물집계표조합.json" # 층 뿌리의 한 파일(종류 폴더 밖) #: 시스템 층 뿌리 — 시험이 임시 폴더로 바꿈. SYSTEM_ROOT = config_system.PROJECT_ROOT / "resources" / "master_template" @@ -317,6 +318,7 @@ def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dic 프로젝트 만들 때 · 옛 프로젝트 넣기(`only_missing`) 둘 다 이 길. `_initial/` 은 늘 없는 것만 더함 — 이미 있으면 절대 덮지 않음. + 구조물집계표조합(층 뿌리의 한 파일)도 같이 실림. """ return { "작업본": copy_templates( @@ -324,13 +326,27 @@ def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dic project_dir(project_root), source_layer="system", only_missing=only_missing, - ), + ) + + _copy_combo(project_dir(project_root), only_missing), "초기": copy_templates( system_dir(), initial_dir(project_root), source_layer="system", only_missing=True - ), + ) + + _copy_combo(initial_dir(project_root), True), } +def _copy_combo(target_dir: Path, only_missing: bool) -> list[str]: + source = system_dir() / COMBO_FILE + target = target_dir / COMBO_FILE + if not source.is_file() or (only_missing and target.exists()): + return [] + target_dir.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.tmp") + shutil.copyfile(source, temporary) + temporary.replace(target) + return [target.stem] + + def reset_project( project_root: str | Path, *, kind: str | None = None, name: str | None = None ) -> list[str]: diff --git a/resources/master_template/구조물집계표조합.json b/resources/master_template/구조물집계표조합.json new file mode 100644 index 00000000..965fc91a --- /dev/null +++ b/resources/master_template/구조물집계표조합.json @@ -0,0 +1,1244 @@ +{ + "그룹": "구조물집계표조합", + "판": 1, + "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀", + "다음": 52, + "줄": [ + { + "키": "SS000001", + "열": "rv", + "종류": "돌기슭막이", + "분류1": "메쌓기", + "분류2": "1.2m", + "분류3": "기초유", + "단위": "m", + "일위대가": "UA000004", + "도번": "SD01010100", + "면적번": "SA01010100", + "재료번": "SM01010100", + "바인딩": { + "종류": "revetment", + "펼침": [ + "form", + "height_m" + ], + "값": "length_m", + "묶음": "rv", + "머리틀": [ + "돌기슭막이", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형태 미입력", + "높이 미입력" + ], + "이름": { + "돌쌓기(찰)": "찰쌓기", + "돌쌓기(메)": "메쌓기" + }, + "더함": [ + { + "종류": "pipe", + "펼침": [ + "inlet_revet_form", + "inlet_revet_height_m" + ], + "값": "inlet_revet_length_m", + "조건": { + "inlet_type": "기슭막이" + } + }, + { + "종류": "pipe", + "펼침": [ + "outlet_revet_form", + "outlet_revet_height_m" + ], + "값": "outlet_revet_length_m", + "조건": { + "outlet_type": "기슭막이" + } + } + ] + }, + "펼침": true + }, + { + "키": "SS000002", + "열": "c4", + "종류": "돌기슭막이", + "분류1": "메쌓기", + "분류2": "1.2m", + "분류3": "기초무, 소단", + "단위": "m", + "일위대가": null, + "도번": "SD01010101", + "면적번": "SA01010101", + "재료번": "SM01010101", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000003", + "열": "c1", + "종류": "돌기슭막이", + "분류1": "메쌓기", + "분류2": "1.5m", + "분류3": "기초유", + "단위": "m", + "일위대가": null, + "도번": "SD01010200", + "면적번": "SA01010200", + "재료번": "SM01010200", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000004", + "열": "c5", + "종류": "돌기슭막이", + "분류1": "찰쌓기", + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD01020000", + "면적번": "SA01020000", + "재료번": "SM01020000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000005", + "열": "pv_b", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "B", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD02010100", + "면적번": "SA02010100", + "재료번": "SM02010100", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "width_m", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "B" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000006", + "열": "pv_l", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "L", + "분류3": null, + "단위": "m", + "일위대가": "UA000007", + "도번": "SD02010200", + "면적번": "SA02010200", + "재료번": "SM02010200", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "길이", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "L" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000007", + "열": "pv_w", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "확폭", + "분류3": null, + "단위": "㎡", + "일위대가": null, + "도번": "SD02010300", + "면적번": "SA02010300", + "재료번": "SM02010300", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "widening_area_m2", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "확폭" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000008", + "열": "pv_a", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "A", + "분류3": null, + "단위": "㎡", + "일위대가": "UA000006", + "도번": "SD02010400", + "면적번": "SA02010400", + "재료번": "SM02010400", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000009", + "열": "pv_jt", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "수축줄눈", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD02010500", + "면적번": "SA02010500", + "재료번": "SM02010500", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000010", + "열": "c3", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "새 분류2", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD02010600", + "면적번": "SA02010600", + "재료번": "SM02010600", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000011", + "열": "pp_len", + "종류": "관공", + "분류1": "(관종·관경마다)", + "분류2": "관매설", + "분류3": null, + "단위": "m", + "일위대가": "UA000036", + "도번": "SD03010100", + "면적번": "SA03010100", + "재료번": "SM03010100", + "바인딩": { + "종류": "pipe", + "펼침": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "값": "관연장", + "묶음": "pp", + "머리틀": [ + "관공", + "{0} {1}", + "관매설" + ], + "값꼴": [ + "{}", + "Φ{}" + ], + "빈값": [ + "관종 미입력", + "관경 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000012", + "열": "pp_cp", + "종류": "관공", + "분류1": "(관종·관경마다)", + "분류2": "커플링밴드", + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD03010200", + "면적번": "SA03010200", + "재료번": "SM03010200", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000013", + "열": "pg_in", + "종류": "관보호공", + "분류1": "(형식마다)", + "분류2": "유입", + "분류3": null, + "단위": "개소", + "일위대가": "UA000008", + "도번": "SD04010100", + "면적번": "SA04010100", + "재료번": "SM04010100", + "바인딩": { + "종류": "pipe", + "펼침": [ + "wing_wall_type" + ], + "값": "개소", + "묶음": "pg", + "머리틀": [ + "관보호공", + "{0}", + "유입" + ], + "빈값": [ + "형식 미입력" + ], + "이름": { + "A-TYPE": "날개벽 A형", + "C-TYPE": "날개벽 C형", + "A-TYPE+집수정": "날개벽 A형+집수정" + }, + "더함": [ + { + "종류": "pipe", + "펼침": [ + "inlet_basin_form" + ], + "값": "개소", + "조건": { + "inlet_type": "집수정" + } + } + ] + }, + "펼침": true + }, + { + "키": "SS000014", + "열": "bx_len", + "종류": "BOX암거", + "분류1": "(폭×높이마다)", + "분류2": "연장", + "분류3": null, + "단위": "m", + "일위대가": "UA000014", + "도번": "SD05010100", + "면적번": "SA05010100", + "재료번": "SM05010100", + "바인딩": { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "length_m", + "묶음": "bx", + "머리틀": [ + "BOX암거", + "{0}×{1}", + "연장" + ], + "빈값": [ + "폭 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000015", + "열": "bx_wing", + "종류": "BOX암거", + "분류1": "(폭×높이마다)", + "분류2": "날개벽", + "분류3": null, + "단위": "개소", + "일위대가": "UA000025", + "도번": "SD05010200", + "면적번": "SA05010200", + "재료번": "SM05010200", + "바인딩": { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "개소", + "묶음": "bx", + "머리틀": [ + "BOX암거", + "{0}×{1}", + "날개벽" + ], + "빈값": [ + "폭 미입력", + "높이 미입력" + ], + "조건": { + "wing_in": "있음" + }, + "더함": [ + { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "개소", + "조건": { + "wing_out": "있음" + } + } + ] + }, + "펼침": true + }, + { + "키": "SS000016", + "열": "fb", + "종류": "세월교", + "분류1": "(관경 × 련수마다)", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD06010000", + "면적번": "SA06010000", + "재료번": "SM06010000", + "바인딩": { + "종류": "ford_bridge", + "펼침": [ + "pipe_diameter_mm", + "pipe_count" + ], + "값": "개소", + "묶음": "fb", + "머리틀": [ + "세월교", + "{0}×{1}", + null + ], + "값꼴": [ + "Φ{}", + "{}련" + ], + "빈값": [ + "관경 미입력", + "련수 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000017", + "열": "fp_w", + "종류": "물넘이포장", + "분류1": "T=(두께마다)", + "분류2": "폭", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD07010100", + "면적번": "SA07010100", + "재료번": "SM07010100", + "바인딩": { + "종류": "ford_pavement", + "펼침": [ + "thickness_cm" + ], + "값": "ford_width_m", + "묶음": "fp", + "머리틀": [ + "물넘이포장", + "{0}", + "폭" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000018", + "열": "fp_l", + "종류": "물넘이포장", + "분류1": "T=(두께마다)", + "분류2": "길이", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD07010200", + "면적번": "SA07010200", + "재료번": "SM07010200", + "바인딩": { + "종류": "ford_pavement", + "펼침": [ + "thickness_cm" + ], + "값": "length_m", + "묶음": "fp", + "머리틀": [ + "물넘이포장", + "{0}", + "길이" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000019", + "열": "od_len", + "종류": "횡단개거", + "분류1": "(규격마다)", + "분류2": "길이", + "분류3": null, + "단위": "m", + "일위대가": "UA000026", + "도번": "SD08010100", + "면적번": "SA08010100", + "재료번": "SM08010100", + "바인딩": { + "종류": "open_ditch", + "펼침": [ + "ditch_spec" + ], + "값": "length_m", + "묶음": "od", + "머리틀": [ + "횡단개거", + "{0}", + "길이" + ], + "빈값": [ + "규격 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000020", + "열": "od_cnt", + "종류": "횡단개거", + "분류1": "(규격마다)", + "분류2": "개소", + "분류3": null, + "단위": "개소", + "일위대가": "UA000003", + "도번": "SD08010200", + "면적번": "SA08010200", + "재료번": "SM08010200", + "바인딩": { + "종류": "open_ditch", + "펼침": [ + "ditch_spec" + ], + "값": "개소", + "묶음": "od", + "머리틀": [ + "횡단개거", + "{0}", + "개소" + ], + "빈값": [ + "규격 미입력" + ], + "더함": [ + { + "종류": "cross_drain_exposed", + "펼침": [ + "=노출형" + ], + "값": "개소" + } + ] + }, + "펼침": true + }, + { + "키": "SS000021", + "열": "rw", + "종류": "옹벽", + "분류1": "(형식마다)", + "분류2": "H=(높이마다)", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD09010100", + "면적번": "SA09010100", + "재료번": "SM09010100", + "바인딩": { + "종류": "retaining_wall", + "펼침": [ + "form", + "height_m" + ], + "값": "길이", + "묶음": "rw", + "머리틀": [ + "옹벽", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형식 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000022", + "열": "ms", + "종류": "돌쌓기", + "분류1": "(찰·메마다)", + "분류2": "H=(높이마다)", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD10010100", + "면적번": "SA10010100", + "재료번": "SM10010100", + "바인딩": { + "종류": "masonry_wet", + "펼침": [ + "=찰쌓기", + "height_m" + ], + "값": "길이", + "묶음": "ms", + "머리틀": [ + "돌쌓기", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "", + "높이 미입력" + ], + "더함": [ + { + "종류": "masonry_dry", + "펼침": [ + "=메쌓기", + "height_m" + ], + "값": "길이" + } + ] + }, + "펼침": true + }, + { + "키": "SS000023", + "열": "sg", + "종류": "흙막이", + "분류1": "(형태마다)", + "분류2": "H=(높이마다)", + "분류3": null, + "단위": "m", + "일위대가": "UA000032", + "도번": "SD11010100", + "면적번": "SA11010100", + "재료번": "SM11010100", + "바인딩": { + "종류": "soil_guard", + "펼침": [ + "form", + "height_m" + ], + "값": "길이", + "묶음": "sg", + "머리틀": [ + "흙막이", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형식 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000024", + "열": "bm_len", + "종류": "큰돌쌓기", + "분류1": "(찰·메 · 돌 크기 · 높이마다)", + "분류2": "길이", + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD12010100", + "면적번": "SA12010100", + "재료번": "SM12010100", + "바인딩": { + "종류": "boulder_masonry", + "펼침": [ + "bond", + "stone_cm", + "height_m" + ], + "값": "길이", + "묶음": "bm", + "머리틀": [ + "큰돌쌓기", + "{0} {1} {2}", + "길이" + ], + "값꼴": [ + "{}", + "{}", + "H={}" + ], + "빈값": [ + "쌓기 방식 미입력", + "돌규격 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000025", + "열": "bm_area", + "종류": "큰돌쌓기", + "분류1": "(찰·메 · 돌 크기 · 높이마다)", + "분류2": "면적", + "분류3": null, + "단위": "㎡", + "일위대가": "UA000031", + "도번": "SD12010200", + "면적번": "SA12010200", + "재료번": "SM12010200", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000026", + "열": "ec", + "종류": "골막이", + "분류1": "(형태마다)", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD13010000", + "면적번": "SA13010000", + "재료번": "SM13010000", + "바인딩": { + "종류": "erosion_check", + "펼침": [ + "form" + ], + "값": "개소", + "묶음": "ec", + "머리틀": [ + "골막이", + "{0}", + null + ], + "빈값": [ + "형식 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000027", + "열": "be", + "종류": "소단", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD14000000", + "면적번": "SA14000000", + "재료번": "SM14000000", + "바인딩": { + "종류": "berm", + "값": "길이" + }, + "펼침": null + }, + { + "키": "SS000028", + "열": "rf", + "종류": "대피소", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD15000000", + "면적번": "SA15000000", + "재료번": "SM15000000", + "바인딩": { + "종류": "refuge", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000029", + "열": "c2", + "종류": "대피소", + "분류1": "새 분류1", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD15010000", + "면적번": "SA15010000", + "재료번": "SM15010000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000030", + "열": "wy", + "종류": "정차·작업장", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD16000000", + "면적번": "SA16000000", + "재료번": "SM16000000", + "바인딩": { + "종류": "work_yard", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000031", + "열": "ta", + "종류": "차돌림곳", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD17000000", + "면적번": "SA17000000", + "재료번": "SM17000000", + "바인딩": { + "종류": "turnaround", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000032", + "열": "gr_rail", + "종류": "가드레일", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD18000000", + "면적번": "SA18000000", + "재료번": "SM18000000", + "바인딩": { + "종류": "guardrail", + "값": "길이", + "조건": { + "kind": "가드레일" + } + }, + "펼침": null + }, + { + "키": "SS000033", + "열": "gr_curb", + "종류": "경계석", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "도번": "SD19000000", + "면적번": "SA19000000", + "재료번": "SM19000000", + "바인딩": { + "종류": "guardrail", + "값": "길이", + "조건": { + "kind": "경계석" + } + }, + "펼침": null + }, + { + "키": "SS000034", + "열": "gr_sign", + "종류": "위험표지", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000017", + "도번": "SD20000000", + "면적번": "SA20000000", + "재료번": "SM20000000", + "바인딩": { + "종류": "guardrail", + "값": "개소", + "조건": { + "kind": "위험표지" + } + }, + "펼침": null + }, + { + "키": "SS000035", + "열": "mr", + "종류": "반사경", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000018", + "도번": "SD21000000", + "면적번": "SA21000000", + "재료번": "SM21000000", + "바인딩": { + "종류": "mirror", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000036", + "열": "bg", + "종류": "차단기", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000010", + "도번": "SD22000000", + "면적번": "SA22000000", + "재료번": "SM22000000", + "바인딩": { + "종류": "barrier_gate", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000037", + "열": "ps", + "종류": "국가지점번호판", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000016", + "도번": "SD23000000", + "면적번": "SA23000000", + "재료번": "SM23000000", + "바인딩": { + "종류": "position_sign", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000038", + "열": "cs", + "종류": "준공표지석", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000002", + "도번": "SD24000000", + "면적번": "SA24000000", + "재료번": "SM24000000", + "바인딩": { + "종류": "completion_sign", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000039", + "열": "etc", + "종류": "기타", + "분류1": "(이름마다)", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "도번": "SD25010000", + "면적번": "SA25010000", + "재료번": "SM25010000", + "바인딩": { + "종류": "etc", + "펼침": [ + "name" + ], + "값": "개소", + "묶음": "etc", + "머리틀": [ + "기타", + "{0}", + null + ], + "빈값": [ + "이름 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000040", + "열": "h_intake", + "종류": "취수정", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000005", + "도번": "SD26000000", + "면적번": "SA26000000", + "재료번": "SM26000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000041", + "열": "h_rip_c", + "종류": "돌붙임", + "분류1": "찰붙임", + "분류2": null, + "분류3": null, + "단위": "㎡", + "일위대가": "UA000009", + "도번": "SD27010000", + "면적번": "SA27010000", + "재료번": "SM27010000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000042", + "열": "h_rip_m", + "종류": "돌붙임", + "분류1": "메붙임", + "분류2": null, + "분류3": null, + "단위": "㎡", + "일위대가": "UA000009", + "도번": "SD27020000", + "면적번": "SA27020000", + "재료번": "SM27020000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000043", + "열": "h_stone_ch", + "종류": "돌수로", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000011", + "도번": "SD28000000", + "면적번": "SA28000000", + "재료번": "SM28000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000044", + "열": "h_stone_bed", + "종류": "돌조공", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000023", + "도번": "SD29000000", + "면적번": "SA29000000", + "재료번": "SM29000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000045", + "열": "h_pv_rail", + "종류": "포장 난간", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000012", + "도번": "SD30000000", + "면적번": "SA30000000", + "재료번": "SM30000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000046", + "열": "h_pg_rail", + "종류": "관보호공 안전난간", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000013", + "도번": "SD31000000", + "면적번": "SA31000000", + "재료번": "SM31000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000047", + "열": "h_soil_ditch", + "종류": "수로형 토사개거", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000033", + "도번": "SD32000000", + "면적번": "SA32000000", + "재료번": "SM32000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000048", + "열": "h_ford_basin", + "종류": "물넘이 집수부", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000034", + "도번": "SD33000000", + "면적번": "SA33000000", + "재료번": "SM33000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000049", + "열": "h_rockfall", + "종류": "낙석방지책", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "경간", + "일위대가": null, + "도번": "SD34000000", + "면적번": "SA34000000", + "재료번": "SM34000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000050", + "열": "h_break", + "종류": "콘크리트·포장 깨기", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "㎥", + "일위대가": null, + "도번": "SD35000000", + "면적번": "SA35000000", + "재료번": "SM35000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000051", + "열": "h_waste", + "종류": "폐기물처리", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "ton", + "일위대가": null, + "도번": "SD36000000", + "면적번": "SA36000000", + "재료번": "SM36000000", + "바인딩": null, + "펼침": null + } + ] +} diff --git a/resources/tester/test_m02_structure_combo.py b/resources/tester/test_m02_structure_combo.py new file mode 100644 index 00000000..9e68aacc --- /dev/null +++ b/resources/tester/test_m02_structure_combo.py @@ -0,0 +1,109 @@ +"""구조물집계표조합 — 서버가 집계표 [저장] 때 맞추는 장부(키 SS · 열마다 한 줄). + +견본 표(`write_sample_table`)로만 봄 — 실제 마스터 양식은 안 읽음. +""" + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_MasterTemplete_Router import router +from resources.tester.test_m02_structure import write_sample_table + +ROOT = Path(__file__).resolve().parents[2] +TABLE = "/api/m02/templates/table/구조물집계표" + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def _save_table(client, change) -> None: + got = client.get(TABLE).json() + change(got["문서"]) + assert client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 + + +def _keys(client) -> dict[str, str]: + return {row["열"]: row["키"] for row in client.get("/api/m02/structure-combos").json()["줄"]} + + +def test_처음_한_번_만듦_글_열_빼고_칸은_집계표_그대로(client, tmp_path): + assert not (tmp_path / store.COMBO_FILE).exists() + doc = client.get("/api/m02/structure-combos").json() + assert (doc["그룹"], doc["다음"]) == ("구조물집계표조합", 14) + rows = doc["줄"] + assert [r["열"] for r in rows][:3] == ["rv", "c1", "pv_b"] + assert {"no", "sta", "memo"}.isdisjoint(r["열"] for r in rows) # 글 열 빼기 + assert (rows[0]["키"], rows[-1]["키"]) == ("SS000001", "SS000013") + assert rows[-1]["열"] == "h_rip_m" + fb = next(r for r in rows if r["열"] == "fb") + assert (fb["종류"], fb["분류1"], fb["분류2"], fb["분류3"]) == ( + "세월교", + "(관경 × 련수마다)", + None, + None, + ) + assert (tmp_path / store.COMBO_FILE).is_file() + + +def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지(client): + before = _keys(client) + + def change(table): + cols = table["열"] + cols[:] = [c for c in cols if c["id"] != "h_rip_m"] # 맨 끝 키 SS000013 열 지움 + rv = next(c for c in cols if c["id"] == "rv") + rv["머리"] = ["돌기슭막이", "메쌓기", "1.2m", "기초유"] + rv.update(단위="m", 일위대가="UA000004", 펼침=True, 바인딩={"종류": "revetment"}) + cols.insert(3, {"id": "new1", "머리": ["옹벽", None, None, None], "단위": "m", "꼴": "수"}) + + _save_table(client, change) + doc = client.get("/api/m02/structure-combos").json() + rows = {r["열"]: r for r in doc["줄"]} + assert "h_rip_m" not in rows + assert rows["new1"]["키"] == "SS000014" # 지운 열의 SS000013 은 다시 안 씀 + assert [r["열"] for r in doc["줄"]][:4] == ["rv", "new1", "c1", "pv_b"] # 집계표 차례 + assert all(rows[c]["키"] == k for c, k in before.items() if c != "h_rip_m") + rv = rows["rv"] + assert (rv["분류1"], rv["분류3"], rv["단위"], rv["일위대가"]) == ( + "메쌓기", + "기초유", + "m", + "UA000004", + ) + assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True) + # 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬 + numbers = client.get("/api/m02/structures/numbers").json() + assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"] + assert rows["new1"]["도번"].startswith("SD") + + # 또 저장해도(바뀐 것 없음) 키 · 다음 그대로 + _save_table(client, lambda table: None) + again = client.get("/api/m02/structure-combos").json() + assert (again["다음"], again["줄"]) == (15, doc["줄"]) + + +def test_SS_머리글은_키대장과_안_겹침(): + ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8")) + assert "SS" not in ledger["다음"] + assert store._COMBO_HEAD == "SS" + + +def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch): + client.get("/api/m02/structure-combos") + monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path) + copied = layers.seed_project(tmp_path / "project") + assert "구조물집계표조합" in copied["작업본"] and "구조물집계표조합" in copied["초기"] + target = layers.project_dir(tmp_path / "project") / store.COMBO_FILE + assert json.loads(target.read_bytes())["줄"][0]["키"] == "SS000001"