diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index e6402728..715161d6 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -108,3 +108,29 @@ padding: 4px 6px; color: var(--color-text-muted); } + +/* 알약으로 이동한 칸 — 굵은 테두리 + 채움 깜빡임(1.6초 · 끝나면 스스로 지워짐) */ +.ss-link-flash { + position: absolute; + z-index: 4; + box-sizing: border-box; + border: 3px solid var(--color-accent); + pointer-events: none; + animation: ss-link-flash 1.6s ease-out 1 both; +} + +@keyframes ss-link-flash { + 0%, + 40%, + 80% { + background: color-mix(in srgb, var(--color-accent) 40%, transparent); + } + 20%, + 60% { + background: transparent; + } + 100% { + background: transparent; + opacity: 0; + } +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 44c0d774..72dde7a1 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -3,6 +3,8 @@ * 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) · * 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. + * 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] · + * 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음). * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 * 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산). * ========================================================================== */ @@ -31,12 +33,16 @@ export interface LinkItem { 키: string; 이름: string; 단위?: string; + /** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */ + 자재?: LinkMaterial; } export interface LinkOptions { 항목: LinkItem[]; - /** 입력 = 항목이 알약 · 자재 = 연결된 자재 + [자재 더하기] 로 고른 자재 */ + /** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */ 모드: "입력" | "자재"; + /** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */ + onItemsChange?: (항목: LinkItem[]) => void; /** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */ 자재찾기?: (글: string) => Promise; } @@ -51,12 +57,15 @@ const TEXT = { free: "칸을 고른 채 누르면 이 칸에 연결", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", + remove: "목록에서 빼기", + removed: "목록에서 뺌 — 연결도 풀림", add: "+ 자재 더하기", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", none: "맞는 자재 없음", fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", + dup: "이미 목록에 있는 자재", }; type RowCol = Extract; @@ -102,6 +111,14 @@ export function applyLinked(book: Workbook, command: Command): CommandEffect { return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } }; } +/** 자재 → 알약(키 = 자재 코드) */ +const ofMaterial = (m: LinkMaterial): LinkItem => ({ + 키: m.코드, + 이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름, + 단위: m.단위, + 자재: m, +}); + export function mountLinks( ctx: SpreadsheetContext, opts: LinkOptions | undefined, @@ -113,8 +130,8 @@ export function mountLinks( if (opts) ctx.root.prepend(bar); const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); - /** 이번에 고른 자재(아직 칸 없음 — 연결하면 문서에 들어감) */ - const picked = new Map(); + /** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */ + const items: LinkItem[] = opts ? [...opts.항목] : []; let dots: HTMLElement[] = []; let pick: HTMLElement | null = null; @@ -133,31 +150,38 @@ export function mountLinks( ctx.dispatch({ 종류: "시트통째", 시트: next }); } - function link(item: LinkItem, 자재?: LinkMaterial): void { + function link(item: LinkItem): void { if (ctx.readOnly) return; const sheet = ctx.sheet(); const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c); const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키); const one: CellLink = { 키: item.키, 이름: item.이름, 칸 }; if (item.단위) one.단위 = item.단위; - if (자재) one.자재 = 자재; + if (item.자재) one.자재 = item.자재; put(sheet, [...rest, one]); back(); } + /** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */ function goTo(sheet: Sheet, a1: string): void { const p = parseA1(a1); if (!p) return; if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id); ctx.select(selectCell(ctx.sheet(), p.r, p.c), true); back(); + requestAnimationFrame(() => { + const { layer, box } = slot(ctx, p.r, p.c); + if (!box.w || !box.h) return; + const f = el("div", { className: "ss-link-flash" }); + f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; + f.addEventListener("animationend", () => f.remove()); + layer.append(f); + }); } - function pill( - item: LinkItem, - hit: { sheet: Sheet; link: CellLink } | undefined, - 자재?: LinkMaterial, - ) { + const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i }))); + + function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) { const where = hit ? hit.sheet.id === ctx.selection.시트 ? hit.link.칸 @@ -173,21 +197,35 @@ export function mountLinks( ], }); b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠 - b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item, 자재))); + b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item))); + const unlink = (): void => { + if (hit) + put( + hit.sheet, + (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), + ); + }; b.addEventListener("contextmenu", (e) => { e.preventDefault(); - if (!hit || ctx.readOnly) return; + const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키); + if (ctx.readOnly || !(hit || inList)) return; menu.open(e.clientX, e.clientY, [ - [ - TEXT.unlink, - () => { - put( - hit.sheet, - (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), - ); - back(); - }, - ], + ...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []), + ...(inList + ? [ + [ + TEXT.remove, + () => { + unlink(); + items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키)); + commit(); + renderBar(); + if (hit) showToast(TEXT.removed, "info"); + back(); + }, + ] as [string, () => void], + ] + : []), ]); }); return b; @@ -196,29 +234,22 @@ export function mountLinks( function renderBar(): void { if (!opts) return; const map = found(); - const items: [LinkItem, LinkMaterial?][] = []; + const shown: LinkItem[] = []; const seen = new Set(); - const add = (item: LinkItem, 자재?: LinkMaterial) => { + const add = (item: LinkItem) => { if (seen.has(item.키)) return; seen.add(item.키); - items.push([item, 자재]); + shown.push(item); }; - const ofMaterial = (m: LinkMaterial): LinkItem => ({ - 키: m.코드, - 이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름, - 단위: m.단위, - }); - if (opts.모드 === "입력") for (const item of opts.항목) add(item); - // 문서에 있는 연결(항목에서 빠진 것 · 자재) — 풀 수 있게 늘 보임 + for (const item of items) add(item); + // 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임 for (const { link } of map.values()) add( link.자재 ? ofMaterial(link.자재) : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, - link.자재, ); - for (const m of picked.values()) add(ofMaterial(m), m); - const pills = items.map(([item, 자재]) => pill(item, map.get(item.키), 자재)); + const pills = shown.map((item) => pill(item, map.get(item.키))); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -277,10 +308,14 @@ export function mountLinks( let seq = 0; let timer = 0; const choose = (m: LinkMaterial) => { - picked.set(m.코드, m); closePick(); - renderBar(); - showToast(TEXT.picked, "info"); + if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info"); + else { + items.push(ofMaterial(m)); + commit(); + renderBar(); + showToast(TEXT.picked, "info"); + } back(); }; const search = async (): Promise => { diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index c340e0de..d9c18b69 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -8,16 +8,21 @@ from __future__ import annotations +import json import re from datetime import date from pathlib import Path +from config import config_system from M01_MasterData import M01_MasterData_Store as store cm, mk, mcb = store.cm, store.mk, store.mcb FILE = mcb.FILE OWNERS = mcb.OWNERS +_MT = config_system.PROJECT_ROOT / "resources" / "master_template" +# 조합을 쓰는 열이 적힌 곳 — 집계표(정본) · 구조물집계표조합(SS 장부) · 시험이 바꿈 +USERS = (_MT / "table" / "구조물집계표.json", _MT / "구조물집계표조합.json") def _path() -> Path: @@ -47,6 +52,9 @@ def _clean(combo: dict, owner: str, key: str | None = None) -> dict: raise store.StoreError(400, "조합 줄에 「이름」 이 없거나 글자 아님") if owner not in OWNERS: raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}") + origin = combo.get("본뜬키") + if origin is not None and (origin == key or origin not in _keys()): + raise store.StoreError(400, f"본뜬키 「{origin}」 — 비었거나 있는 다른 조합 키여야 함") rest = {k: v for k, v in combo.items() if k not in ("키", "원문번호", "소유")} one = {**({"키": key} if key else {}), "소유": owner, **rest} one.setdefault("출처", "자체") @@ -150,7 +158,8 @@ def combo_inputs(key: str) -> dict: """조합 알약 — 담은 로직마다 결과단위 분모(원/㎥ → ㎥)가 물량 하나. 이름 = 로직 이름 끝 괄호 뗀 것(「(인력)」·「(장비)」 한 알약) · 같은 이름 · 단위면 합침 · - 키 = 그 알약 첫 로직 키 · `배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐. + 키 = 「이름|단위」(로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키) · + `배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐. `조건` 은 자리만(설계 조건 고르기 · 범위는 알약에 안 넣음). """ row = _find(_read()[0], key) @@ -168,7 +177,9 @@ def combo_inputs(key: str) -> dict: # ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름 unit = " · ".join(dict.fromkeys(u for _, u in per)) name = _TAIL.sub("", str(got.get("이름") or code)) or code - pill = pills.setdefault((name, unit), {"키": code, "이름": name, "단위": unit, "로직": []}) + pill = pills.setdefault( + (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} + ) pill["로직"].append(code) times = per[0][0] if len(per) == 1 and times and float(times) != 1: @@ -207,9 +218,28 @@ def combo_edit(key: str, version: str, combo_: dict, owner: str = "") -> dict: return {**done, "combo": _find(store.read(FILE)[0], key)} +def combo_users(key: str) -> list[str]: + """이 조합을 「일위대가」 로 쓰는 구조물 열 이름들 — 집계표 · SS 장부를 함께 봄.""" + names: dict[str, str] = {} + for path in USERS: + if not path.is_file(): + continue + doc = json.loads(path.read_text(encoding="utf-8")) + for col in doc.get("열") or doc.get("줄") or []: + if col.get("일위대가") != key: + continue + head = col.get("머리") or [col.get(k) for k in ("종류", "분류1", "분류2", "분류3")] + cid = str(col.get("id") or col.get("열")) + names.setdefault(cid, " ".join(str(h) for h in head if h and str(h).strip()) or cid) + return list(names.values()) + + def combo_delete(key: str, version: str) -> dict: data, _ = _read() _find(data, key) + if users := combo_users(key): + msg = f"구조물 {len(users)} 곳이 쓰는 조합이라 못 지움 — {' · '.join(users)}" + raise store.StoreError(409, {"errors": [msg], "users": users}) if store.read(FILE)[1] != version: raise store.StoreError(409, {"stale": [FILE]}) return _write([{"op": "delete", "key": key}], version) diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M01_MasterData/M01_MasterData_UI_Combo.ts index 9eb01737..4d992d94 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo.ts @@ -39,6 +39,7 @@ const blankCombo = (): Combo => ({ 담은로직: [], 출처: "자체", 소유: "현장", + 본뜬키: null, 비고: null, }); @@ -162,14 +163,21 @@ export function mountM01Combo( tc("Detail"), tc("Head_Unit"), tc("Head_Count"), + tc("Head_Origin"), ]; const rows = shown.map((x) => { const tr = el("tr", { className: "m01-master__table-row", attrs: { "data-combo-key": x.키 }, - children: [x.키, x.이름, x.구분 ?? "", x.상세구분 ?? "", x.단위 ?? "", String(x.count)].map( - (v) => el("td", { text: v }), - ), + children: [ + x.키, + x.이름, + x.구분 ?? "", + x.상세구분 ?? "", + x.단위 ?? "", + String(x.count), + x.본뜬키 ?? tc("Origin_Self"), + ].map((v) => el("td", { text: v })), }); tr.addEventListener("click", () => void open(x.키)); return tr; @@ -369,6 +377,11 @@ export function mountM01Combo( field(tc("Detail"), "상세구분"), field(tc("Head_Unit"), "단위"), field(tc("Head_Note"), "비고"), + el("p", { + className: "m01-logic__muted", + text: `${tc("Head_Origin")}: ${combo.본뜬키 ?? tc("Origin_Self")}`, + attrs: { "data-combo-origin": combo.본뜬키 ?? "" }, + }), ], }), ], diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts index c0ea8a02..9a9bd455 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts @@ -22,6 +22,8 @@ export interface Combo { 담은로직: ComboLogic[]; 출처: string | null; 소유: string | null; + /** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */ + 본뜬키: string | null; 비고: string | null; } export interface ComboSummary extends Combo { diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts index 4456b20f..0e2dcefc 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts @@ -30,6 +30,8 @@ const TEXT = { Head_Unit: ["단위", "Unit"], Head_Note: ["비고", "Note"], Head_Count: ["로직 수", "Logics"], + Head_Origin: ["본뜬키", "Copied from"], + Origin_Self: ["원본", "Original"], Logics_Title: ["담은 로직", "Logics in it"], Col_Order: ["차례", "No."], Col_Key: ["로직 키", "Logic key"], diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index b8ed909b..5041b02e 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -281,13 +281,15 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": null } ] } ``` -- 열 한 벌 — `키` · `이름` · `구분` · `상세구분` · `단위` · `담은로직` · `출처` · `소유` · `비고`(차례까지 이대로 · 안 채운 칸은 `null`). +- 열 한 벌 — `키` · `이름` · `구분` · `상세구분` · `단위` · `담은로직` · `출처` · `소유` · `본뜬키` · `비고`(차례까지 이대로 · 안 채운 칸은 `null`). +- `본뜬키` = 이 조합을 본떠 만든 원본 조합 키(같은 파일의 다른 UA) · `null` 이면 원본 · 자기 키 안 됨 · M02 가 열 몫으로 갈라 낼 때 채움. - **담은 로직 목록만 둠** — 수량 · 비율 · 계산 칸을 두지 않음. 로직마다 하는 일이 달라 수량은 로직마다 따로 들어가고, 그 대입과 합계는 프로젝트 몫(B08 · B09). - `담은로직` 줄 = `{로직, 메모}` 뿐 · 목록 차례가 곧 조합 안 차례. `메모` = 글자 또는 `null` — 수량 같은 값을 넣는 자리가 아님. - `키` = `UA` + 6자리 — 대장 `_키대장.json` 의 `UA` 다음 번호(서버가 줌). 원문이 없어 **줄에 `원문번호` 를 두지 않음**(대장 열쇠는 조합 이름). diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index a6bf8e63..2199db1d 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -183,6 +183,9 @@ | `POST /combo/delete` | `{key, version}` | `{file, version}` | | `POST /combo/preview` | `{combo, inputs}` | `{키, 이름, 단위, 줄: […], 노무비, 재료비, 경비, 계, 멈춤}` | +- `본뜬키` = 본뜬 원본 조합 키(같은 파일의 다른 UA) 또는 `null`(원본) — 없는 키 · 자기 키는 400 · 목록 · 상세에 그대로 나옴. +- `POST /combo/delete` 는 구조물집계표 · `구조물집계표조합` 의 「일위대가」 칸으로 쓰는 열이 있으면 409 `{detail: {errors: […], users: [구조물 이름…]}}` — 지우지 않음. +- 조합 알약 `GET /combo/{키}/inputs` = `{키, 이름, 물량: [{키 = 「이름|단위」, 이름, 단위, 로직: […], 배?}], 조건: []}` — 같은 이름 · 단위 물량은 한 알약. - `combo` = 조합 한 줄 통째(`_틀.md` 10장) · `키`·`소유` 는 보내도 서버 것이 이김 · 안 준 칸은 `null` 로 채워 열 한 벌을 맞춤. - `version` = `GET /combo` 가 준 그 파일 판본 — 다르면 409 `{detail: {stale: ["일위대가조합.json"]}}`. - `GET /combo` 의 `줄` = 담은 로직에 이름을 붙인 목록 `{차례, 로직, 메모, ok, 이름, 원문번호, 구분, 상세구분, 결과단위, 출처}` — 없는 키는 `ok` false 와 `까닭`. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 264b402a..2083a0e8 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -226,6 +226,7 @@ def check_combo_form(name: str, data: dict) -> list[str]: if not isinstance(rows_, list): return out + [f"{name} · 머리 「줄」 목록 없음"] seen_names: dict[str, str] = {} + own = {str(r.get("키")) for r in rows_} for row in rows_: key = str(row.get("키")) where = f"{name} · {key} {row.get('이름') or ''}".strip() @@ -249,6 +250,9 @@ def check_combo_form(name: str, data: dict) -> list[str]: out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(mcb.OWNERS)} 아님") if not _source_ok(row.get("출처")): out.append(f"{where} · 출처 모양 「{row.get('출처')}」") + origin = row.get("본뜬키") + if origin is not None and (origin == key or origin not in own): + out.append(f"{where} · 본뜬키 「{origin}」 — 비었거나 이 파일의 다른 조합 키여야 함") return out diff --git a/resources/master_data/scripts/master_combo.py b/resources/master_data/scripts/master_combo.py index 54d6fa2f..74f1b048 100644 --- a/resources/master_data/scripts/master_combo.py +++ b/resources/master_data/scripts/master_combo.py @@ -19,7 +19,7 @@ TID = "UA" OWNERS = ("현장", "공용") MAX_이름 = 60 # 조합 이름 최대 글자 수 — 관리자가 짓는 짧은 이름이라 이보다 길면 저장 막음 # 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장) -COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "비고") +COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "본뜬키", "비고") ITEM_COLS = ("로직", "메모") diff --git a/resources/master_data/scripts/master_keys.py b/resources/master_data/scripts/master_keys.py index 10f08fbf..1b8e1d33 100644 --- a/resources/master_data/scripts/master_keys.py +++ b/resources/master_data/scripts/master_keys.py @@ -183,6 +183,7 @@ ROW_ORDER = { "담은로직", "출처", "소유", + "본뜬키", "비고", ), } diff --git a/resources/master_data/일위대가조합.json b/resources/master_data/일위대가조합.json index 6aee5be4..b4c38de2 100644 --- a/resources/master_data/일위대가조합.json +++ b/resources/master_data/일위대가조합.json @@ -12,6 +12,7 @@ "담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반 트레일러(20ton)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "브레인이 넣은 견본 · 조합 화면을 바로 볼 수 있게 함 · 지워도 됨" }, { @@ -30,6 +31,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "글자새김 자당 6*6 — 우리 로직에 없음\n잔토처리 굴삭기 0.2m3 — 우리 로직에 없음\n실무 호표 — 봉화 B00005 · 울진대흥 B01224" }, { @@ -51,6 +53,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "깬잡석채집 L=30cm 내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계(0.7㎥,100%) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n잔토처리 굴삭기 0.7m3 — 우리 로직에 없음\n측구수로관부설 500∼700kg미만 — 우리 로직에 없음\n기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n실무 호표 — 봉화 B00008 · 봉화 B00030 · 영월 B02019 · 영월 B02052 · 영월 B02134" }, { @@ -74,6 +77,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "깬잡석채집 L=45cm 내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계(0.7㎥,100%) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n막자갈채집 기계(0.7㎥,100%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n막자갈채집 기계 — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 굴삭기0.7m3 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n막자갈채우기 150mm내외 — 후보 「자갈고르기」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 봉화 B00010 · 봉화 B00011 · 봉화 B00022 · 봉화 B00047 · 울진대흥 B01212 · 울진대흥 B01213 · 울진대흥 B01214 · 울진대흥 B01215 · 울진대흥 B01216 · 울진대흥 B01225 · 울진대흥 B01226 · 울진대흥 B01227 · 울진대흥 B01231 · 울진소광 B02122" }, { @@ -97,6 +101,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "PE관 부설 및 접합 D50 — 후보 「부단수 할정자관 부설 및 접합」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n실무 호표 — 봉화 B00012 · 영월 B02002 · 거창 제12호표" }, { @@ -113,6 +118,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 봉화 B00014 · 울진대흥 B01232" }, { @@ -124,6 +130,7 @@ "담은로직": [{ "로직": "GF000162", "메모": "콘크리트포장(인력) T=20㎝" }, { "로직": "GC000211", "메모": "합판거푸집(6회) 간단" }, { "로직": "GF000163", "메모": "콘크리트포장절단공 임도 (실무 수는 옛 판 값)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n실무 호표 — 영월 B02059" }, { @@ -150,6 +157,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "막자갈채집 기계(0.7㎥,100%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n깬잡석채집 L=30cm 내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계(0.7㎥,100%) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n야면석 채집 운반 (25x25x35㎝)장비 — 후보 「야면석 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집운반 4.5톤덤프(L=500) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n큰돌채집운반(뒷길이 0.6~0.8m) 장비(80%) + 인력(20%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n녹생토(종자배합) 암절개지 및 특수지반 녹화용 (700kg/㎥) — 우리 로직에 없음\n막자갈채집 기계 — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 굴삭기0.7m3 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n막자갈(파쇄석)채집 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n막자갈채우기 150mm내외 — 후보 「자갈고르기」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 봉화 B00017 · 봉화 B00018 · 봉화 B00029 · 봉화 B00048 · 봉화 B00049 · 봉화 B00055 · 봉화 B00056 · 영월 B01981 · 영월 B01983 · 영월 B01988 · 영월 B02045 · 영월 B02155 · 울진대흥 B01219 · 울진대흥 B01220 · 울진소광 B02120" }, { @@ -170,6 +178,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "깬잡석채집 L=30cm 내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계(0.7㎥,100%) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n잔토처리 굴삭기 0.7m3 — 우리 로직에 없음\n깬잡석채집 L=60cm 내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n파쇄석 채집 L3=35cm내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 굴삭기0.2m3 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 봉화 B00020 · 봉화 B00032 · 봉화 B00036 · 봉화 B00064 · 울진대흥 B01222 · 울진대흥 B01223 · 울진대흥 B01229 · 울진대흥 B01230 · 울진소광 B02125" }, { @@ -191,6 +200,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "잔토처리 굴삭기 0.2m3 — 우리 로직에 없음\n실무 호표 — 봉화 B00025" }, { @@ -209,6 +219,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "깬잡석채집 L=35cm 내외 — 후보 「막돌 채집」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집 기계(0.7㎥,100%) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n야면석 채집 운반 (25x25x35㎝)장비 — 후보 「야면석 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집운반 4.5톤덤프(L=500) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n야면석소운반(L=35CM) (L=20m) — 우리 로직에 없음\n콘크리트소운반 (L=20m) — 우리 로직에 없음\n고임돌채집 굴삭기0.7m3 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 봉화 B00034 · 영월 B02016 · 영월 B02017 · 울진소광 B02124" }, { @@ -225,6 +236,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 봉화 B00038" }, { @@ -242,6 +254,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 봉화 B00041" }, { @@ -261,6 +274,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 봉화 B00058 · 거창 제5호표 · 거창 제6호표" }, { @@ -277,6 +291,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 봉화 B00062 · 거창 제9호표" }, { @@ -293,6 +308,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "잔토처리 인력100% — 우리 로직에 없음\n실무 호표 — 영월 B00704" }, { @@ -310,6 +326,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n잔토처리 인력100% — 우리 로직에 없음\n실무 호표 — 영월 B01982" }, { @@ -327,6 +344,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n잔토처리 인력100% — 우리 로직에 없음\n실무 호표 — 영월 B01997" }, { @@ -343,6 +361,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "기초잡석채집및운반부설 4.5톤(L=500) — 우리 로직에 없음\n잔토처리 인력100% — 우리 로직에 없음\n실무 호표 — 영월 B02152" }, { @@ -354,6 +373,7 @@ "담은로직": [{ "로직": "GC000149", "메모": "관목식재(단식) H=0.3~0.7m" }, { "로직": "GC000148", "메모": "굴취(관목) H=0.3~0.7m" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "인력운반 기타 — 우리 로직에 없음\n실무 호표 — 계류보전 B01099" }, { @@ -370,6 +390,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 울진소광 B02121" }, { @@ -381,6 +402,7 @@ "담은로직": [{ "로직": "GC000198", "메모": "레미콘타설 무근(장비)" }, { "로직": "GC000214", "메모": "유로폼 설치 및 해체 간단" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 울진소광 B02129" }, { @@ -396,6 +418,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "고임돌채집 굴삭기0.7m3 — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n막자갈채우기 150mm내외 — 후보 「자갈고르기」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 울진소광 B02123" }, { @@ -415,6 +438,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "물구멍설치 D=50mm — 우리 로직에 없음\n실무 호표 — 울진소광 B02128" }, { @@ -431,6 +455,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 거창 제7호표 · 거창 제8호표" }, { @@ -442,6 +467,7 @@ "담은로직": [{ "로직": "GC000088", "메모": "구조물 터파기(토사) 굴삭기 0.2m3" }, { "로직": "GF000113", "메모": "구조물 되메우기 굴삭기 0.2m3 (실무 수는 옛 판 값)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 봉화 B00007" }, { @@ -453,6 +479,7 @@ "담은로직": [{ "로직": "GC000088", "메모": "구조물 터파기(토사) 굴삭기 0.2m3" }, { "로직": "GF000113", "메모": "구조물 되메우기 굴삭기 0.2m3 (실무 수는 옛 판 값)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "PE관 부설 및 접합 D50 — 후보 「부단수 할정자관 부설 및 접합」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n실무 호표 — 봉화 B00039" }, { @@ -464,6 +491,7 @@ "담은로직": [{ "로직": "GC000143", "메모": "평떼붙임 (실무 수는 옛 판 값)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "단끊기 — 우리 로직에 없음\n단끊기 절취없음 — 우리 로직에 없음\n떼 하차비 — 우리 로직에 없음\n실무 호표 — 봉화 B00052 · 울진소광 B02130" }, { @@ -475,6 +503,7 @@ "담은로직": [{ "로직": "GF000101", "메모": "구조물터파기 토사(B/H 90%+인력10%)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "잔토처리 토사(B/H 100%) — 우리 로직에 없음\n실무 호표 — 영월 B01963" }, { @@ -486,6 +515,7 @@ "담은로직": [{ "로직": "GF000218", "메모": "잡석운반및다짐 (실무 수는 옛 판 값)" }, { "로직": "GF000124", "메모": "면고르기" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 영월 B01984" }, { @@ -497,6 +527,7 @@ "담은로직": [{ "로직": "GF000218", "메모": "뒷채움돌채집운반부설 4.5톤 덤프트럭(L=500m) (실무 수는 옛 판 값)" }, { "로직": "GF000226", "메모": "큰돌메쌓기 뒷길이 0.6~0.8m" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "고임돌채집운반 4.5톤덤프(L=500) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n큰돌채집운반(뒷길이 0.6~0.8m) 장비(80%) + 인력(20%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n녹생토(종자배합) 암절개지 및 특수지반 녹화용 (700kg/㎥) — 우리 로직에 없음\n실무 호표 — 영월 B02133" }, { @@ -508,6 +539,7 @@ "담은로직": [{ "로직": "GF000218", "메모": "뒷채움돌채집운반부설 4.5톤 덤프트럭(L=500m) (실무 수는 옛 판 값)" }, { "로직": "GF000226", "메모": "큰돌메쌓기 뒷길이 0.6~0.8m" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "큰돌채집운반(뒷길이 0.6~0.8m) 장비(80%) + 인력(20%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집운반 4.5톤덤프(L=500) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n녹생토(종자배합) 암절개지 및 특수지반 녹화용 (700kg/㎥) — 우리 로직에 없음\n실무 호표 — 영월 B02156" }, { @@ -519,6 +551,7 @@ "담은로직": [{ "로직": "GF000101", "메모": "구조물터파기(토사) 굴삭기0.7m3 (실무 수는 옛 판 값)" }, { "로직": "GC000105", "메모": "다지기 굴삭기0.7m3 (실무 수는 옛 판 값)" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 울진대흥 B01221" }, { @@ -535,6 +568,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 울진대흥 B01234" }, { @@ -546,6 +580,7 @@ "담은로직": [{ "로직": "GC000206", "메모": "철근(현장)가공 Type-Ⅰ-----아님 (실무 수는 옛 판 값)" }, { "로직": "GC000207", "메모": "철근(현장)조립 Type-Ⅰ-----아님" }], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 울진소광 B00287" }, { @@ -561,6 +596,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 울진소광 B02119" }, { @@ -576,6 +612,7 @@ ], "출처": "자체", "소유": "현장", + "본뜬키": null, "비고": "실무 호표 — 거창 제13호표" } ] diff --git a/resources/tester/spreadsheet/harness_links.ts b/resources/tester/spreadsheet/harness_links.ts index 9b36313a..ea6b0ff8 100644 --- a/resources/tester/spreadsheet/harness_links.ts +++ b/resources/tester/spreadsheet/harness_links.ts @@ -1,4 +1,4 @@ -/* 알약 막대 시험 틀 — 진짜 부품(createSpreadsheet)에 `links` 선택 · 가짜 입력값 · 가짜 자재 목록. +/* 알약 막대 시험 틀 — 진짜 부품(createSpreadsheet)에 `links` 선택 · 가짜 입력값 · 가짜 자재 목록(호스트 항목 + 자재찾기). * Vite 개발 서버가 바로 풂: /resources/tester/spreadsheet/harness_links.html?mode=입력|자재 * window.ss = 부품 손잡이(getDoc · getLinkedValues) · window.ssFind = 자재찾기 부른 글 목록 */ import "../../../ui_template/ui_template_theme.css"; // 앱 색 · 간격 토큰 @@ -37,17 +37,29 @@ const MATERIALS: LinkMaterial[] = [ ]; const asked: string[] = []; w.ssFind = asked; +w.ssItems = [] as unknown[]; // onItemsChange 로 받은 목록들(부른 순서) +// 자재 모드 — 호스트가 주는 처음 목록(자재 정보 포함): 레미콘 하나 +const hostItems = MATERIALS.slice(0, 1).map((m) => ({ + 키: m.코드, + 이름: `${m.이름} ${m.규격}`, + 단위: m.단위, + 자재: m, +})); w.ss = createSpreadsheet(document.getElementById("host")!, book, { tabs: false, onChange: () => (w.ssChanges = ((w.ssChanges as number) ?? 0) + 1), links: { 모드: mode, - 항목: [ - { 키: "L", 이름: "길이", 단위: "m" }, - { 키: "H", 이름: "높이", 단위: "m" }, - { 키: "A", 이름: "면적", 단위: "m²" }, - ], + 항목: + mode === "자재" + ? hostItems + : [ + { 키: "L", 이름: "길이", 단위: "m" }, + { 키: "H", 이름: "높이", 단위: "m" }, + { 키: "A", 이름: "면적", 단위: "m²" }, + ], + onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록), 자재찾기: async (글) => { asked.push(글); await new Promise((r) => setTimeout(r, 50)); diff --git a/resources/tester/test_m01_combo_inputs.py b/resources/tester/test_m01_combo_inputs.py index 0e7189d2..d4f174f9 100644 --- a/resources/tester/test_m01_combo_inputs.py +++ b/resources/tester/test_m01_combo_inputs.py @@ -4,16 +4,24 @@ - 물량 = 로직 결과단위 분모(원/㎥ → ㎥) · 설계 조건(고르기 · 범위)은 안 넣음(`조건` 은 빈 자리). - 이름 = 로직 이름 끝 괄호 뗀 것 · 같은 이름 · 단위면 한 알약(로직 여럿). - 원/10㎡ 는 단위 ㎡ · `배` 10 · 없는 조합 404. -정본 파일을 읽기만 함. + - 알약 키 = 「이름|단위」 — 로직을 빼고 · 더하고 · 차례를 바꿔도 같은 물량이면 같은 키. + - 조합 지우기 — 구조물 열이 「일위대가」 로 쓰면 409 + 쓰는 구조물 이름들 · 안 쓰면 지워짐. +정본은 읽기만 함(지우기 · 만들기 시험은 사본 폴더). """ from __future__ import annotations +import json +import shutil +from pathlib import Path + import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combo_store @pytest.fixture(scope="module") @@ -36,13 +44,14 @@ def test_기슭막이_찰쌓기_인력_장비는_한_알약(client) -> None: assert len(got["물량"]) == 10 # 담은 로직 11 · 찰쌓기 둘이 합침 assert by[("돌쌓기 찰쌓기", "㎡")]["로직"] == ["GF000222", "GF000221"] assert by[("레디믹스트콘크리트 타설", "㎥")]["로직"] == ["GC000198"] - assert by[("유로폼 설치 및 해체", "㎡")]["키"] == "GC000214" + assert by[("유로폼 설치 및 해체", "㎡")]["키"] == "유로폼 설치 및 해체|㎡" assert set(got["물량"][0]) == {"키", "이름", "단위", "로직"} def test_원_10제곱미터는_배_10(client) -> None: got = _pills(client, "UA000031") - pill = next(p for p in got["물량"] if p["키"] == "GF000226") + pill = next(p for p in got["물량"] if p["로직"] == ["GF000226"]) + assert pill["키"] == f"{pill['이름']}|㎡" assert (pill["단위"], pill["배"]) == ("㎡", 10) @@ -53,3 +62,82 @@ def test_준공표지석_물량_여섯(client) -> None: def test_없는_조합은_404(client) -> None: assert client.get("/api/m01/combo/UA999999/inputs").status_code == 404 + + +@pytest.fixture +def own(tmp_path: Path, monkeypatch) -> TestClient: + """사본 마스터 폴더 · 사본 집계표 — 조합을 만들고 지워도 정본은 그대로.""" + for path in store.FOLDER.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + sheet = tmp_path / "집계표.json" + sheet.write_text(json.dumps({"열": []}), encoding="utf-8") + monkeypatch.setattr(combo_store, "USERS", (sheet,)) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _new(client: TestClient, name: str, codes: list[str]) -> str: + combo = {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes]} + res = client.post("/api/m01/combo/new", json={"combo": combo}) + assert res.status_code == 200, res.text + return res.json()["key"] + + +def test_알약_키는_로직을_빼고_더하고_차례를_바꿔도_같다(own) -> None: + both = ["GF000222", "GF000221", "GC000198"] # 찰쌓기 인력 · 장비 + 타설 + full = _pills(own, _new(own, "전부", both))["물량"] + fewer = _pills(own, _new(own, "장비 뺌", ["GF000222", "GC000198"]))["물량"] + swapped = _pills(own, _new(own, "차례 바꿈", both[::-1]))["물량"] + keys = {p["키"] for p in full} + assert keys == {"돌쌓기 찰쌓기|㎡", "레디믹스트콘크리트 타설|㎥"} + assert {p["키"] for p in fewer} == keys + assert {p["키"] for p in swapped} == keys + assert len({p["키"] for p in full}) == len(full) # 키 겹침 없음 + + +def _use(sheet: Path, key: str) -> None: + cols = [ + {"id": "c5", "머리": ["돌기슭막이", "찰쌓기", "", ""], "일위대가": key}, + {"id": "c6", "머리": ["돌붙임", "", "", ""], "일위대가": "UA000009"}, + ] + sheet.write_text(json.dumps({"열": cols}, ensure_ascii=False), encoding="utf-8") + + +def test_쓰는_열이_있으면_조합을_못_지우고_구조물_이름을_알린다(own) -> None: + key = _new(own, "쓰는 조합", ["GF000222"]) + _use(combo_store.USERS[0], key) + version = own.get(f"/api/m01/combo?key={key}").json()["version"] + res = own.post("/api/m01/combo/delete", json={"key": key, "version": version}) + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["users"] == ["돌기슭막이 찰쌓기"] + assert "돌기슭막이 찰쌓기" in detail["errors"][0] + assert own.get(f"/api/m01/combo?key={key}").status_code == 200 # 그대로 남음 + + +def test_안_쓰는_조합은_지워진다(own) -> None: + key = _new(own, "안 쓰는 조합", ["GF000222"]) + _use(combo_store.USERS[0], "UA000009") # 다른 조합을 씀 + version = own.get(f"/api/m01/combo?key={key}").json()["version"] + assert ( + own.post("/api/m01/combo/delete", json={"key": key, "version": version}).status_code == 200 + ) + assert own.get(f"/api/m01/combo?key={key}").status_code == 404 + + +def test_본뜬키는_목록에_나오고_없는_키_자기_키는_400(own) -> None: + base = _new(own, "원본", ["GF000222"]) + combo = {"이름": "본뜬 것", "본뜬키": base, "담은로직": [{"로직": "GF000222", "메모": None}]} + got = own.post("/api/m01/combo/new", json={"combo": combo}).json() + assert got["combo"]["본뜬키"] == base + rows = {r["키"]: r for r in own.get("/api/m01/combos").json()["combos"]} + assert rows[got["key"]]["본뜬키"] == base and rows[base]["본뜬키"] is None + bad = {**combo, "이름": "없는 원본", "본뜬키": "UA999999"} + assert own.post("/api/m01/combo/new", json={"combo": bad}).status_code == 400 + version = own.get(f"/api/m01/combo?key={base}").json()["version"] + me = {"이름": "원본", "본뜬키": base, "담은로직": combo["담은로직"]} + res = own.post("/api/m01/combo/edit", json={"key": base, "version": version, "combo": me}) + assert res.status_code == 400