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

This commit is contained in:
2026-09-28 19:00:13 +09:00
14 changed files with 309 additions and 54 deletions
@@ -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;
}
}
+73 -38
View File
@@ -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<LinkMaterial[]>;
}
@@ -51,12 +57,15 @@ const TEXT = {
free: "칸을 고른 채 누르면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
removed: "목록에서 뺌 — 연결도 풀림",
add: "+ 자재 더하기",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
none: "맞는 자재 없음",
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
@@ -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<string, LinkMaterial>();
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
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<string>();
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<void> => {
+32 -2
View File
@@ -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)
+16 -3
View File
@@ -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.본뜬키 ?? "" },
}),
],
}),
],
@@ -22,6 +22,8 @@ export interface Combo {
담은로직: ComboLogic[];
출처: string | null;
소유: string | null;
/** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */
본뜬키: string | null;
비고: string | null;
}
export interface ComboSummary extends Combo {
@@ -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"],
+3 -1
View File
@@ -281,13 +281,15 @@
],
"출처": "자체",
"소유": "현장",
"본뜬키": null,
"비고": null
}
]
}
```
- 열 한 벌 — `키` · `이름` · `구분` · `상세구분` · `단위` · `담은로직` · `출처` · `소유` · `비고`(차례까지 이대로 · 안 채운 칸은 `null`).
- 열 한 벌 — `키` · `이름` · `구분` · `상세구분` · `단위` · `담은로직` · `출처` · `소유` · `본뜬키` · `비고`(차례까지 이대로 · 안 채운 칸은 `null`).
- `본뜬키` = 이 조합을 본떠 만든 원본 조합 키(같은 파일의 다른 UA) · `null` 이면 원본 · 자기 키 안 됨 · M02 가 열 몫으로 갈라 낼 때 채움.
- **담은 로직 목록만 둠** — 수량 · 비율 · 계산 칸을 두지 않음. 로직마다 하는 일이 달라 수량은 로직마다 따로 들어가고, 그 대입과 합계는 프로젝트 몫(B08 · B09).
- `담은로직` 줄 = `{로직, 메모}` 뿐 · 목록 차례가 곧 조합 안 차례. `메모` = 글자 또는 `null` — 수량 같은 값을 넣는 자리가 아님.
- `키` = `UA` + 6자리 — 대장 `_키대장.json` 의 `UA` 다음 번호(서버가 줌). 원문이 없어 **줄에 `원문번호` 를 두지 않음**(대장 열쇠는 조합 이름).
+3
View File
@@ -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 와 `까닭`.
@@ -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
@@ -19,7 +19,7 @@ TID = "UA"
OWNERS = ("현장", "공용")
MAX_이름 = 60 # 조합 이름 최대 글자 수 — 관리자가 짓는 짧은 이름이라 이보다 길면 저장 막음
# 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장)
COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "비고")
COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "본뜬키", "비고")
ITEM_COLS = ("로직", "메모")
@@ -183,6 +183,7 @@ ROW_ORDER = {
"담은로직",
"출처",
"소유",
"본뜬키",
"비고",
),
}
@@ -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호표"
}
]
+18 -6
View File
@@ -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));
+91 -3
View File
@@ -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