feat(M02): 산출근거 표번 — 도번과 같은 규칙 자동 채번 · 좌측 「산출근거」 목록

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
This commit is contained in:
2026-09-28 00:30:32 +09:00
co-authored by Claude Sonnet 5
parent 7a8d37d544
commit e31e1b0016
56 changed files with 220 additions and 61 deletions
@@ -118,6 +118,9 @@ export const createStructure = (column: string): Promise<TemplateDoc> =>
export const fetchStructureNumbers = (): Promise<Record<string, string>> =>
call("/structures/numbers");
/** `{열 id: 표번}` — 좌측 산출근거 줄(도번과 같은 규칙 · 「표-01」 꼴) */
export const fetchBasisNumbers = (): Promise<Record<string, string>> => call("/basis/numbers");
/* --- 프로젝트 층 단추 다섯 --- */
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
const post = (path: string, body: object = {}): Promise<unknown> =>
@@ -43,6 +43,11 @@ def post_structure(column: str) -> dict:
return _call(store.create_structure, column)
@router.get("/basis/numbers")
def get_basis_numbers() -> dict:
return _call(store.basis_numbers)
@router.get("/templates/{kind}/{name}")
def get_template(kind: str, name: str) -> dict:
return _call(store.read, kind, name)
+34 -2
View File
@@ -31,6 +31,7 @@ _BASIS_HEAD_STYLE = {
"테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")},
}
_NUMBER = re.compile(r"^구-(\d+)$")
_TABLE_NUMBER = re.compile(r"^표-(\d+)$")
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
# of undefined (reading 'length')") · CAD 쪽도 없으면 새로 채우지만(2026-09-27 sub1 고침)
@@ -221,16 +222,47 @@ def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
}
def _basis_docs() -> list[tuple[str, Any]]:
folder = FOLDER / "basis"
return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))]
def _table_number(doc: Any) -> int:
found = _TABLE_NUMBER.match(str(doc.get("표번") or "")) if isinstance(doc, dict) else None
return int(found.group(1)) if found else 0
def ensure_basis(column: str, old: Any = None) -> bool:
"""산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
"""산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음).
표번 = 도번과 같은 규칙(있는 것 중 가장 큰 번호 + 1) · 만든 뒤로는 안 바뀜."""
path = _path("basis", column)
with _LOCK:
if path.is_file():
return False
atomic_write_json(path, basis_doc(column, old))
number = max((_table_number(doc) for _name, doc in _basis_docs()), default=0) + 1
doc = {**basis_doc(column, old), "표번": f"표-{number:02d}"}
atomic_write_json(path, doc)
return True
def basis_numbers() -> dict[str, str | None]:
"""`{열 id: 표번}` — 좌측 산출근거 줄이 한 번에 받음.
표번이 없는 옛 파일은 여기서 만나는 순서로 채움(도번 규칙과 같음)."""
docs = _basis_docs()
missing = [name for name, doc in docs if not (isinstance(doc, dict) and doc.get("표번"))]
if missing:
with _LOCK:
docs = _basis_docs() # 잠금 안에서 다시 읽음 — 그 사이 누가 채웠을 수 있음
number = max((_table_number(doc) for _name, doc in docs), default=0)
for name, doc in docs:
if isinstance(doc, dict) and not doc.get("표번"):
number += 1
doc["표번"] = f"표-{number:02d}"
atomic_write_json(_path("basis", name), doc)
docs = _basis_docs()
return {name: (doc.get("표번") if isinstance(doc, dict) else None) for name, doc in docs}
def _server_values(doc: Any) -> dict[str, Any]:
try:
return spreadsheet.with_server_values(doc)
@@ -1,7 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면) · 새로 · 본떠 · 지우기.
* 서버 길은 계약 `6_계약.md`. 구조물 도면 줄은 집계표 열마다 하나(서버가 미리 만들어 둠 · 도번 항상 있음).
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면/산출근거) · 새로 · 본떠 · 지우기.
* 서버 길은 계약 `6_계약.md`. 구조물 도면 · 산출근거 줄은 집계표 열마다 하나씩 —
* 서버가 미리 만들어 둠(도번·표번 항상 있음 · 같은 번호 매기기 규칙).
* ========================================================================== */
import {
@@ -17,6 +18,7 @@ import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteTemplate,
fetchBasisNumbers,
fetchStructureNumbers,
listTemplates,
readTemplate,
@@ -82,6 +84,7 @@ export function buildSide(opt: SideOptions): SideHandle {
let items: TemplateInfo[] = [];
let columns: StructureColumn[] = [];
let numbers: Record<string, string> = {};
let basisNumbers: Record<string, string> = {};
let seq = 0;
let picked: { kind: Kind; name: string } | null = null;
@@ -95,12 +98,14 @@ export function buildSide(opt: SideOptions): SideHandle {
KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]),
);
const structureBody = el("div", { className: "m02-side__items ui-sidebar-list" });
const basisBody = el("div", { className: "m02-side__items ui-sidebar-list" });
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
section(L("M02_KindTable"), bodies.get("table")!),
section(L("M02_KindDrawing"), bodies.get("drawing")!),
section(L("M02_KindStructure"), structureBody),
section(L("M02_KindBasis"), basisBody),
],
});
attachCollapsible(listHost);
@@ -166,6 +171,24 @@ export function buildSide(opt: SideOptions): SideHandle {
);
}
/** 산출근거 줄 — 구조물 도면과 같은 열 목록 · 표번은 도번과 같은 규칙(서버가 미리 만들어 둠) */
function paintBasis(): void {
basisBody.replaceChildren(
...(columns.length
? columns.map((col) => {
const num = basisNumbers[col.id] ?? L("M02_StructureNone");
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isPicked("basis", col.id) ? " is-active" : ""}`,
text: `${columnHead(col)} · ${num}`,
attrs: { type: "button" },
});
b.addEventListener("click", () => void selectBasisColumn(col));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
}
function paintButtons(): void {
const editable = canEdit(layer, opt.isAdmin);
btnNew.disabled = !editable;
@@ -192,23 +215,37 @@ export function buildSide(opt: SideOptions): SideHandle {
label: columnHead(col),
});
paintStructure();
paintBasis();
paintButtons();
}
async function selectBasisColumn(col: StructureColumn): Promise<void> {
if (!isPicked("basis", col.id) && !(await opt.confirmLeave())) return;
picked = null;
opt.onOpen({ layer, projectId: null, kind: "basis", name: col.id, label: columnHead(col) });
paintStructure();
paintBasis();
paintButtons();
}
async function refreshStructure(): Promise<void> {
try {
const [table, nums] = await Promise.all([
const [table, nums, basisNums] = await Promise.all([
readTemplate(layer, "table", STRUCTURE_TABLE, null),
fetchStructureNumbers(),
fetchBasisNumbers(),
]);
columns = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[];
numbers = nums;
basisNumbers = basisNums;
} catch (error) {
columns = [];
numbers = {};
basisNumbers = {};
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
paintStructure();
paintBasis();
}
async function refresh(): Promise<void> {
@@ -309,6 +346,7 @@ export function buildSide(opt: SideOptions): SideHandle {
picked = { kind, name };
paintList();
paintStructure();
paintBasis();
},
};
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-01"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-02"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-03"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-04"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-05"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-06"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-07"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-08"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-09"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-10"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-11"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-12"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-13"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-14"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-15"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-16"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-17"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-18"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-19"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-20"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-21"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-22"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-23"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-24"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-25"
}
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-26"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-27"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-28"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-29"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-30"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-31"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-32"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-33"
}
+3 -1
View File
@@ -52,5 +52,7 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-34",
"계산값": {}
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-35"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-36"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-37"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-38"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-39"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-40"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-41"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-42"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-43"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-44"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-45"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-46"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-47"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-48"
}
+2 -1
View File
@@ -52,5 +52,6 @@
}
}
],
"활성": "s1"
"활성": "s1",
"표번": "표-49"
}
@@ -88,6 +88,29 @@ def test_새_구조물_도면은_산출근거_따로(client):
assert heads == ["공종", "규격", "산출 내역", "단위", "수량(m당)"]
def test_표번은_도번과_같은_규칙(client):
made1 = client.post("/api/m02/structures/rv").json()["문서"]
made2 = client.post("/api/m02/structures/pv_b").json()["문서"]
assert made1["도번"] == "구-01" and made2["도번"] == "구-02"
basis1 = client.get("/api/m02/templates/basis/rv").json()["문서"]
basis2 = client.get("/api/m02/templates/basis/pv_b").json()["문서"]
assert basis1["표번"] == "표-01" and basis2["표번"] == "표-02"
numbers = client.get("/api/m02/basis/numbers").json()
assert numbers["rv"] == "표-01" and numbers["pv_b"] == "표-02"
def test_표번_없는_옛_파일은_만나면_채움(client, tmp_path):
store.ensure_basis("rv")
doc = store.read("basis", "rv")["문서"]
del doc["표번"]
(tmp_path / "basis" / "rv.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
)
numbers = client.get("/api/m02/basis/numbers").json()
assert numbers["rv"] == "표-01"
assert store.read("basis", "rv")["문서"]["표번"] == "표-01" # 한 번 채우면 파일에 남음
def test_저장은_서버_계산값만_적음(client):
store.ensure_basis("rv")
got = client.get("/api/m02/templates/basis/rv").json()
+13 -7
View File
@@ -22,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
PANEL = M02 / "M02_MasterTemplete_Structure.ts"
MAIN = M02 / "M02_MasterTemplete_UI_Main.ts"
SIDE = M02 / "M02_MasterTemplete_UI_Side.ts"
def _text(path: Path) -> str:
@@ -39,23 +40,28 @@ def test_소스가_700줄을_넘지_않음():
@pytest.mark.parametrize(
("source", "names"),
("source", "names", "target"),
[
(
"M02_MasterTemplete_Api_Fetch.ts",
["createStructure", "fetchStructureNumbers", "readTemplate", "saveTemplate"],
["createStructure", "fetchStructureNumbers", "readTemplate"],
PANEL,
),
("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"]),
("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"]),
# 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함
("M02_MasterTemplete_Api_Fetch.ts", ["saveTemplate"], MAIN),
# 좌측 산출근거 줄 번호는 좌측 패널이 받음(구조물 도면 줄과 같은 자리)
("M02_MasterTemplete_Api_Fetch.ts", ["fetchBasisNumbers"], SIDE),
("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], PANEL),
("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], PANEL),
],
)
def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str]):
def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str], target: Path):
"""도면 부품 · 스프레드시트(D) 가 이름을 바꾸면 여기서 먼저 걸림."""
body = _text(M02 / source)
panel = _text(PANEL)
used = _text(target)
for name in names:
assert re.search(rf"export (const|function|interface) {name}\b", body), f"{source}:{name}"
assert name in panel, name
assert name in used, name
def test_집계표만_컨테이너를_붙임():
+2
View File
@@ -9,6 +9,7 @@ export const ui_locales_m2 = {
M02_KindTable: ["표 양식", "Table templates"],
M02_KindDrawing: ["도면 양식", "Drawing templates"],
M02_KindStructure: ["구조물 도면", "Structure drawings"],
M02_KindBasis: ["산출근거", "Cost basis"],
M02_NoTemplates: ["양식 없음", "None"],
M02_New: ["새로", "New"],
M02_Copy: ["본떠 만들기", "Copy as new"],
@@ -37,6 +38,7 @@ export const ui_locales_m2 = {
],
M02_NoSheet: ["표 편집 부품이 아직 없음", "Table editor not available yet"],
M02_NoDrawing: ["도면 편집 부품이 아직 없음", "Drawing editor not available yet"],
M02_NoSpreadsheet: ["산출근거 편집 부품이 아직 없음", "Cost basis editor not available yet"],
M02_NameExists: ["이미 있는 이름", "That name already exists"],
M02_BadName: [
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',