- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
185 lines
7.5 KiB
Python
185 lines
7.5 KiB
Python
"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18).
|
|
|
|
① 기초단가 — 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 칸 고침은 안 보냄 · 새 줄은 data 만 보냄
|
|
② 같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움
|
|
③ 공종 — 보낼 몸은 지울 줄을 빼고 화면 표시 칸(tmp·deleted)을 뗌(빠지면 삭제 = 전체 줄 보내기)
|
|
④ 고친 것이 없으면 dirty 아님 · 고치면 dirty · 새 줄은 「added」 · 고친 칸만 짚음
|
|
⑤ 갈래는 처음 나온 차례로 묶음 · 새 줄은 제 갈래 끝 바로 뒤(보낸 차례 = sort_order)
|
|
⑥ 보낼 줄은 id + 계약 칸 열하나만 — GET 전용 칸(sort_order·is_added·changed_columns)이 실리면 422
|
|
TS 를 실제로 돌린다(`test_z01_master_cells` 와 같은 방식).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Draft.ts"
|
|
|
|
_RUNNER = """
|
|
import { writeFileSync } from "node:fs";
|
|
|
|
// 브라우저 sessionStorage 대역
|
|
const store = new Map();
|
|
globalThis.sessionStorage = {
|
|
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
setItem: (k, v) => store.set(k, String(v)),
|
|
removeItem: (k) => store.delete(k),
|
|
};
|
|
const {
|
|
BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload,
|
|
} = await import("./Z01_MasterData_UI_Draft.js");
|
|
|
|
// ① 기초단가
|
|
const base = new BaseDraft("base:labor", 7);
|
|
base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907);
|
|
base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 — 안 쌓임
|
|
base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2);
|
|
base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 — 빠짐
|
|
base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4);
|
|
base.toggleDelete("labor_const/9999");
|
|
const added = base.addRow();
|
|
base.setAdded(added.tmp, "occupation_name", "새 직종");
|
|
base.setAdded(added.tmp, "daily_wage_krw", 300000);
|
|
base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을 뺌
|
|
const payload = base.payload();
|
|
const count = base.count;
|
|
const stored = JSON.parse(store.get("z01-draft:base:labor"));
|
|
|
|
// ② 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움
|
|
const again = new BaseDraft("base:labor", 7);
|
|
const restored = again.payload();
|
|
const newer = new BaseDraft("base:labor", 8);
|
|
const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") };
|
|
const toggled = new BaseDraft("base:oil", 1);
|
|
toggled.toggleDelete("x");
|
|
toggled.toggleDelete("x");
|
|
const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") };
|
|
|
|
// ③④⑤ 공종
|
|
const row = (id, variant, extra = {}) => ({
|
|
id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부",
|
|
resource_spec: "", amount: 0.5, amount_unit: "인", alternative_amount: null,
|
|
group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1",
|
|
// GET 전용 칸 — PUT 에 실리면 422(extra=forbid)
|
|
sort_order: id, is_added: false, changed_columns: [], ...extra,
|
|
});
|
|
const detail = {
|
|
item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목",
|
|
path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1,
|
|
basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 },
|
|
rows: [row(1, "갑"), row(2, "을"), row(3, "갑")],
|
|
source: [], notes: [],
|
|
};
|
|
const body = freshBody(detail);
|
|
const clean = isDirty(detail, body);
|
|
body.rows[0].amount = 0.7;
|
|
body.rows[1].deleted = true;
|
|
addRow(body, "을", "new-1");
|
|
body.item.status = "reviewed";
|
|
const sent = workItemPayload(detail.item.revision, body);
|
|
|
|
writeFileSync(process.argv[2], JSON.stringify({
|
|
payload, count, stored,
|
|
restored, dropped, emptied,
|
|
clean,
|
|
dirty: isDirty(detail, body),
|
|
sent,
|
|
changed: [...changedFields(body.rows[0], detail.rows)],
|
|
addedRow: changedFields(body.rows[2], detail.rows),
|
|
untouched: [...changedFields(body.rows[3], detail.rows)],
|
|
groups: groupByVariant(body.rows),
|
|
freshUntouched: freshBody(detail).rows[0].amount,
|
|
}));
|
|
"""
|
|
|
|
|
|
def test_초안_쌓기와_보낼_몸(tmp_path: Path) -> None:
|
|
out = tmp_path / "js"
|
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
[
|
|
"node",
|
|
str(TSC),
|
|
str(SOURCE),
|
|
"--outDir",
|
|
str(out),
|
|
"--module",
|
|
"esnext",
|
|
"--target",
|
|
"es2022",
|
|
"--ignoreConfig",
|
|
"--noCheck",
|
|
"--noResolve",
|
|
],
|
|
cwd=str(PROJECT_ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
|
result = tmp_path / "output.json"
|
|
subprocess.run( # noqa: S603
|
|
["node", str(out / "runner.mjs"), str(result)],
|
|
cwd=str(PROJECT_ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
got = json.loads(result.read_text(encoding="utf-8"))
|
|
|
|
# ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만
|
|
assert got["payload"] == {
|
|
"base_revision": 7,
|
|
"edits": [
|
|
{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000},
|
|
],
|
|
"added": [{"data": {"occupation_name": "새 직종"}}],
|
|
"deleted": ["labor_const/9999"],
|
|
}
|
|
# 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1
|
|
assert got["count"] == 4
|
|
assert got["stored"]["revision"] == 7
|
|
# ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐
|
|
assert got["restored"] == got["payload"]
|
|
assert got["dropped"] == {"count": 0, "left": False}
|
|
assert got["emptied"] == {"count": 0, "left": False}
|
|
# ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림
|
|
assert got["clean"] is False
|
|
assert got["dirty"] is True
|
|
sent = got["sent"]
|
|
assert sent["base_revision"] == 3
|
|
assert sent["item"] == {
|
|
"name": "벌목",
|
|
"basis_quantity": 1,
|
|
"basis_unit": "ha",
|
|
"status": "reviewed",
|
|
}
|
|
assert [r.get("id") for r in sent["rows"]] == [1, None, 3]
|
|
# ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자
|
|
fields = {
|
|
"variant",
|
|
"resource_kind",
|
|
"resource_key",
|
|
"resource_name",
|
|
"resource_spec",
|
|
"amount",
|
|
"amount_unit",
|
|
"alternative_amount",
|
|
"group_ratio_pct",
|
|
"raw_cell",
|
|
"pum_table_id",
|
|
}
|
|
assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}]
|
|
assert sent["rows"][1]["variant"] == "을" and sent["rows"][1]["pum_table_id"] == ""
|
|
# ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림
|
|
assert got["changed"] == ["amount"]
|
|
assert got["addedRow"] == "added"
|
|
assert got["untouched"] == []
|
|
assert got["freshUntouched"] == 0.5
|
|
# ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로
|
|
assert got["groups"] == [
|
|
{"variant": "갑", "indexes": [0, 3]},
|
|
{"variant": "을", "indexes": [1, 2]},
|
|
]
|