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

# Conflicts:
#	A00_Common/spreadsheet/spreadsheet_grid.ts
#	A00_Common/spreadsheet/spreadsheet_links.ts
This commit is contained in:
2026-09-28 22:59:00 +09:00
63 changed files with 1547 additions and 1012 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1, "UA": 38, "UM": 1},
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1, "UA": 38},
"폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}},
"키": {
"CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"},
+3 -3
View File
@@ -38,7 +38,7 @@
| MT | 재료 자재품목 | CF · CC | 계수 산림 · 건설 |
| MO | 재료 유가 | RT | 요율 |
| MP | 재료 품셈재료(old/ — PLAN 7-4b) | GF · GC · GX | 로직 산림 · 건설 · 자체 |
| EQ | 기계 | UA · UM | 일위대가 조합 · 자재 조합 |
| EQ | 기계 | UA | 일위대가 조합 |
- 빌더는 옛 모양(`열쇠` · 원문별 파일)으로 쓰고 끝에 `scripts/master_keys.py` 의 `adopt` 를 부름 — 키·원문번호·한 테이블·장 파일 이름으로 받음. 옛 이름으로 읽기는 `old_view`.
@@ -300,10 +300,10 @@
## 11. 자재 조합 파일
`자재조합.json` 하나 — 구조물 열 재료산출이 쓰는 **자재 목록 묶음**. 머리 `그룹` · `판` · `비고` · `줄`(원문 없음).
마스터 템플릿 `resources/master_template/자재조합.json` 하나(이 폴더 밖 · `구조물집계표조합.json` 과 같은 자리) — 구조물 열 재료산출이 쓰는 **자재 목록 묶음**. 머리 `그룹` · `판` · `비고` · `다음` · `줄`(원문 없음). 검사는 `check_master.py 틀` 이 같이 봄(`master_material_combo.check_file`).
- 열 한 벌 — `키` · `이름` · `담은자재` · `본뜬키` · `소유` · `비고`(차례까지 이대로 · 엔진 `scripts/master_material_combo.py`).
- `키` = `UM` + 6자리 — 대장 `UM` 다음 번호 · 줄에 `원문번호` 를 두지 않음.
- `키` = `UM` + 6자리 — 표 머리 `다음` 번호(`_키대장` 밖) · 지운 키는 다시 안 씀 · 줄에 `원문번호` 를 두지 않음.
- `담은자재` 줄 = `{코드, 이름, 규격, 단위}` 뿐 · 빈 목록 안 됨 · 같은 코드 두 번 안 됨 · 수량 · 계산은 두지 않음(재료산출 몫).
- `본뜬키` = 갈라 낸 원래 자재 조합 키 · 없으면 `null` · `소유` = `현장` · `공용`.
- 새 줄은 M02 갈라 내기(`POST /api/m02/structures/{열}/material-combo`)가 만듦 — 같은 자재 집합이 있으면 그 키.
+1 -13
View File
@@ -198,19 +198,7 @@
### 자재 조합
재료산출에서 쓸 **자재 목록 묶음**(파일 `자재조합.json` · 키 `UM` · `_틀.md` 11장). 담은 자재 `{코드, 이름, 규격, 단위}` 만 지님 — 수량 · 단가 없음. 요소 그룹이 아님(`/groups` · `/rows` 에 안 끼고 제 길로만).
| 길 | 받음 | 줌 |
| --------------------- | ------------------------------- | ------------------------------------------- |
| `GET /matsets` | `q?` `owner?` | `{matsets: […]}` — 줄마다 `count` (자재 수) |
| `GET /matset` | `key` | `{file, version, matset}` |
| `POST /matset/new` | `{matset, owner?}` | `{file, version, key, matset}` |
| `POST /matset/edit` | `{key, version, matset, owner?}` | `{file, version, matset}` |
| `POST /matset/delete` | `{key, version}` | `{file, version}` |
- 답 — 200 · 400(이름 없음 · 모르는 소유 · 없는/자기 본뜬키) · 404 · 409(낡은 판본 · 지우기 막음) · 422(없는 자재 코드 · 같은 자재 두 번 · 자재 줄 모양).
- 지우기는 구조물집계표 · `구조물집계표조합` 의 「자재조합」 칸으로 쓰는 열이 있으면 409 `{detail: {errors: […], users: [구조물 이름…]}}` — 지우지 않음.
- M02 화면이 새로 만드는 조합도 같은 파일 · 같은 길이라 M01 목록에 같이 보임.
M01 에 없음 — 마스터 템플릿 표(`resources/master_template/자재조합.json` · 키 `UM` · `_틀.md` 11장) · 길과 화면은 M02(`/api/m02/material-combos…` · 좌측 「자재 조합」 컨테이너).
## 10. 재료 · 기계 고르기
@@ -259,8 +259,6 @@ def check_combo_form(name: str, data: dict) -> list[str]:
def check_form(name: str, data: dict) -> list[str]:
if name == mcb.FILE:
return check_combo_form(name, data)
if name == mmc.FILE:
return mmc.check_form(name, data)
if name in SPECIAL_FILES:
return []
m = _FILE.match(name)
@@ -685,6 +683,7 @@ def main(argv: list[str]) -> int:
report = {}
if mode in ("틀", "전부"):
report["틀"] = [x for name, data in files.items() for x in check_form(name, data)]
report["틀"] += mmc.check_file() # 자재 조합은 마스터 템플릿 표
if mode in ("본문", "전부"):
report["본문"] = check_body(files)
if mode in ("로직", "전부"):
+1 -3
View File
@@ -32,13 +32,12 @@ TABLES = {
("로직", "건설품셈"): "GC",
("로직", "자체"): "GX",
("일위대가조합", None): "UA",
("자재조합", None): "UM",
}
GROUP_OF = {tid: group for (group, _), tid in TABLES.items()}
IDS = tuple(GROUP_OF)
# 줄에 원문번호를 두지 않는 테이블 — 자재품목은 번호가 남의 품번이라 이름 몫을 못 하고,
# 조합은 원문이 없어 번호 자체가 없음(대조는 대장에만)
NO_NUMBER = ("MT", "UA", "UM")
NO_NUMBER = ("MT", "UA")
def table_id(group: str, book: str | None) -> str:
@@ -187,7 +186,6 @@ ROW_ORDER = {
"소유",
"비고",
),
"자재조합": ("키", "이름", "담은자재", "본뜬키", "소유", "비고"),
}
OLD_CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$")
CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈|자체)_(\d\d)장_.+\.json$")
@@ -1,12 +1,16 @@
# -*- coding: utf-8 -*-
"""자재 조합 — 구조물 열 재료산출이 쓰는 자재 목록 묶음(키 UM · `_틀.md` 11장).
담은 자재 목록만 지님(수량 · 계산은 재료산출 몫). 일위대가 조합(`master_combo`)과 같은 결 —
갈라 내기(M02) 때 새 줄 · 본뜬키 = 원래 키.
자리는 마스터 템플릿 `resources/master_template/자재조합.json`(구조물집계표조합.json 과 같은 결) ·
키는 표 안 「다음」 번호(`_키대장` 밖). 담은 자재 목록만 지님(수량 · 계산은 재료산출 몫) ·
갈라 내기(M02) 때 새 줄 · 본뜬키 = 원래 키. 저장 · 화면은 M02(`M02_MasterTemplete_Matset.py`).
"""
from __future__ import annotations
import json
from pathlib import Path
import master_combo as mcb
import master_keys as mk
@@ -15,7 +19,8 @@ FILE = "자재조합.json"
TID = "UM"
OWNERS = mcb.OWNERS
MAX_이름 = mcb.MAX_이름
COLS = mk.ROW_ORDER[GROUP] # 키 · 이름 · 담은자재 · 본뜬키 · 소유 · 비고
PATH = Path(__file__).resolve().parents[2] / "master_template" / FILE
COLS = ("키", "이름", "담은자재", "본뜬키", "소유", "비고")
ITEM_COLS = ("코드", "이름", "규격", "단위")
@@ -30,12 +35,18 @@ def check_form(name: str, data: dict) -> list[str]:
rows = data.get("줄")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「줄」 목록 없음"]
after = data.get("다음")
if not isinstance(after, int) or after < 1:
out.append(f"{name} · 머리 「다음」 이 1 이상 정수 아님")
after = None
seen_names: dict[str, str] = {}
for row in rows:
key = str(row.get("키"))
where = f"{name} · {key} {row.get('이름') or ''}".strip()
if not mk.KEY.fullmatch(key) or key[:2] != TID:
out.append(f"{where} · 키 모양이 「{TID}」 + 6자리 아님")
elif after and int(key[2:]) >= after:
out.append(f"{where} · 키 번호가 머리 「다음」({after}) 보다 작지 않음")
if tuple(row) != COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(COLS)}」")
plain = row.get("이름").strip() if isinstance(row.get("이름"), str) else ""
@@ -64,3 +75,10 @@ def check_form(name: str, data: dict) -> list[str]:
out.append(f"{where} 줄{at} · 같은 자재를 두 번 담음 「{code}」")
codes.add(code)
return out
def check_file(path: Path = PATH) -> list[str]:
"""마스터 템플릿 자재 조합 표 — 없으면 한 줄."""
if not path.is_file():
return [f"{path.name} · 마스터 템플릿에 없음"]
return check_form(path.name, json.loads(path.read_text(encoding="utf-8")))
@@ -1,6 +1,7 @@
{
"그룹": "자재조합",
"판": "2026",
"비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫)"],
"비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫) · 키 UM 은 「다음」 번호로 매김 · 지운 키는 다시 안 씀"],
"다음": 1,
"줄": []
}
@@ -58,6 +58,31 @@ describe('기준점 십자', () => {
expect(b.origin.x - a.origin.x).toBeCloseTo(-100); // 세계 50 = 화면 100px(2배) — 화면을 옮기면 원점이 반대로 감
});
it('빈 도면 맞춤은 원점을 화면 가운데로 — 십자가 귀퉁이에 안 잘림', () => {
const { context } = recordingContext();
const controller = new ScreenCanvasDrawController(context);
controller.setCanvasSize(new Point(800, 600));
controller.setScreenScale(2);
controller.setScreenOffset(new Point(0, 0));
controller.zoomToFitScreen();
const origin = controller.worldToTarget(new Point(0, 0));
expect([origin.x, origin.y]).toEqual([400, 300]);
});
it('맞춘 뒤 캔버스가 커지면 다시 맞춤 · 사용자가 옮긴 뒤에는 그대로', () => {
const { context } = recordingContext();
const controller = new ScreenCanvasDrawController(context);
controller.setCanvasSize(new Point(200, 100));
controller.zoomToFitScreen();
controller.setCanvasSize(new Point(800, 600));
const refit = controller.worldToTarget(new Point(0, 0));
expect([refit.x, refit.y]).toEqual([400, 300]);
controller.panScreen(50, 0);
controller.setCanvasSize(new Point(1000, 600));
const kept = controller.worldToTarget(new Point(0, 0));
expect([kept.x, kept.y]).toEqual([450, 300]);
});
it('그리기만 — 도면 개체를 만들지 않음', () => {
drawAt(1, new Point(0, 0));
expect(getEntities()).toHaveLength(0);
@@ -7,6 +7,7 @@ const sel = load(path.join(SS, "spreadsheet_selection.ts"));
const ed = load(path.join(SS, "spreadsheet_editor.ts"));
const mouse = load(path.join(SS, "spreadsheet_mouse.ts"));
const addr = load(path.join(SS, "spreadsheet_address.ts"));
const spill = load(path.join(SS, "spreadsheet_spill.ts"));
const sample = require(path.join(SS, "spreadsheet_sample.json"));
const s1 = sample.시트[0];
@@ -69,6 +70,27 @@ const out = {
(t) => ed.textToCell(t, { 서식: 4 }),
),
textToCellNoStyle: ed.textToCell("", {}),
// 긴 글 넘침 — 칸 0..5 폭 50 · 3 은 값 있음 · 칸 2 에서 폭 130 글
spill: (() => {
const row = (full) =>
new Map([0, 1, 2, 3, 4, 5].map((c) => [c, { w: 50, empty: !full.includes(c) }]));
const f = (sp) => sp && [sp.left, sp.right, sp.from, sp.to, sp.clipLeft, sp.clipRight];
return {
left: f(spill.spillOf(row([2, 3]), 2, 50, 130, "left")),
leftFree: f(spill.spillOf(row([2]), 2, 50, 130, "left")),
right: f(spill.spillOf(row([2]), 2, 50, 130, "right")),
center: f(spill.spillOf(row([2]), 2, 50, 130, "center")),
centerBlocked: f(spill.spillOf(row([2, 3]), 2, 50, 130, "center")),
fits: f(spill.spillOf(row([2]), 2, 50, 40, "left")),
edge: f(spill.spillOf(new Map([[2, { w: 50, empty: false }]]), 2, 50, 130, "left")),
};
})(),
// 친 모양대로 숫자 형식 · 식 대문자 · 닫는 괄호 채움(엑셀)
typedFormat: ["50%", "12.5%", "1,234.5", "1,000", "7", "abc", "1e3%"].map(ed.typedFormat),
upperFormula: ["sum(a1:b2)+round(c3,0)", "'시트 a'!a1*true", '"abc"&a1', "내이름*2"].map(
ed.upperFormula,
),
closeParens: ["=(1+2", "=SUM(1,ROUND(2,0", "=1+", "=(1+2)", '=")"&(1'].map(ed.closeParens),
cellToText: [
{ 값: "=12" },
{ 값: "12" },
@@ -111,3 +111,31 @@ def test_abs_cycle_and_fold(r):
def test_links_follow_rows_cols(r):
# 행 넣기 → 밀림 · 연결 칸 열 지움 → 빠짐 · 되돌리기 → 제자리
assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"]
def test_typed_format(r):
assert r["typedFormat"] == ["0%", "0.0%", "#,##0.0", "#,##0", None, None, None]
def test_upper_formula(r):
assert r["upperFormula"] == [
"SUM(A1:B2)+ROUND(C3,0)",
"'시트 a'!A1*TRUE",
'"abc"&A1',
"내이름*2",
]
def test_close_parens(r):
assert r["closeParens"] == ["=(1+2)", "=SUM(1,ROUND(2,0))", None, None, '=")"&(1)']
def test_spill(r):
sp = r["spill"]
assert sp["left"] is None # 오른 칸(3)에 값 → 못 넘침
assert sp["leftFree"] == [0, 100, 2, 4, 0, 0] # 빈 칸 둘 덮음
assert sp["right"] == [100, 0, 0, 2, 0, 0]
assert sp["center"] == [40, 40, 1, 3, 0, 0]
assert sp["centerBlocked"] == [40, 40, 1, 2, 0, 40] # 오른쪽 막힘 → 오른쪽 반 잘라 냄
assert sp["fits"] is None
assert sp["edge"] is None # 판 끝(그린 칸 없음)
@@ -0,0 +1,50 @@
"""저장 뒤 식 · 값 유지 — M02 면적산출 · 재료산출 저장본을 서버 엔진으로 다시 풀면 저장된 `계산값` 과 같음.
읽기만 함(저장본을 고치지 않음). 저장 때 서버가 적은 값과 지금 엔진 풀이가 어긋나면 화면을 다시 열 때
값이 달라 보임.
"""
import json
import shutil
from pathlib import Path
import pytest
from common_util import common_util_spreadsheet as spreadsheet
ROOT = Path(__file__).resolve().parents[3]
FOLDER = ROOT / "resources" / "master_template"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _books() -> list[tuple[str, dict]]:
out = []
for kind in ("area", "material"):
for path in sorted((FOLDER / kind).glob("[!.]*.json")):
book = json.loads(path.read_text(encoding="utf-8"))
if isinstance(book, dict) and book.get("시트"):
out.append((f"{kind}/{path.stem}", book))
return out
def test_저장본_서버_재계산_같음():
books = _books()
assert any("계산값" in b for _, b in books), "계산값 있는 저장본이 없음"
fresh = spreadsheet.recalc_workbooks([b for _, b in books])
diffs = []
for (name, book), values in zip(books, fresh):
if "계산값" not in book:
continue # 옛 저장본 — 풀리기만 하면 됨
saved = book["계산값"]
for sheet, cells in values.items():
for addr, v in cells.items():
if saved.get(sheet, {}).get(addr) != v:
diffs.append(
f"{name} {sheet}!{addr}: 저장 {saved.get(sheet, {}).get(addr)} · 지금 {v}"
)
for sheet, cells in saved.items():
for addr in cells:
if addr not in values.get(sheet, {}):
diffs.append(f"{name} {sheet}!{addr}: 저장에만 있음")
assert not diffs, "\n".join(diffs[:30])
-107
View File
@@ -1,107 +0,0 @@
"""M01 자재 조합(`/api/m01/matset…` · 파일 `자재조합.json` · 키 UM) — 담은 자재 목록만 지님.
못박는 것
- 새로 만들면 키를 서버가 `UM` 다음 번호로 줌 · 열 한 벌 · 담은 자재는 네 칸.
- 없는 자재 코드 · 같은 자재 두 번 · 이름 없음 · 없는 본뜬키는 막음.
- 고치기는 낡은 판본이면 409 · 지우기는 구조물 열이 「자재조합」 칸으로 쓰면 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
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def client(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 _mat(client: TestClient) -> dict:
"""자재품목에서 실제 줄 하나 — 자재 찾기 길 그대로."""
got = client.get("/api/m01/pick", params={"kind": "price", "q": "합판", "limit": 2}).json()
one = got["items"][0]
return {"코드": one["ref"], "이름": one["이름"], "규격": one["규격"], "단위": one["단위"]}
def _new(client: TestClient, name: str, mats: list[dict], **more) -> dict:
body = {"matset": {"이름": name, "담은자재": mats, **more}}
return client.post("/api/m01/matset/new", json=body)
def test_정본_자재조합_파일은_틀대로다() -> None:
data = json.loads((ROOT / "resources/master_data/자재조합.json").read_text(encoding="utf-8"))
assert data["그룹"] == "자재조합" and isinstance(data["줄"], list)
assert store.cm.mmc.check_form("자재조합.json", data) == []
assert '"UM"' in (ROOT / "resources/master_data/_키대장.json").read_text(encoding="utf-8")
def test_새로_만들면_UM_키와_열_한_벌(client) -> None:
mat = _mat(client)
res = _new(client, "합판 자재", [{**mat, "덤": 1}])
assert res.status_code == 200, res.text
made = res.json()
assert made["key"].startswith("UM") and len(made["key"]) == 8
assert tuple(made["matset"]) == store.cm.mmc.COLS
assert made["matset"]["담은자재"] == [mat] and made["matset"]["본뜬키"] is None
rows = client.get("/api/m01/matsets").json()["matsets"]
assert [(r["키"], r["count"]) for r in rows] == [(made["key"], 1)]
got = client.get("/api/m01/matset", params={"key": made["key"]}).json()
assert got["matset"]["이름"] == "합판 자재"
def test_없는_자재_두_번_담기_이름_본뜬키는_막는다(client) -> None:
mat = _mat(client)
assert _new(client, "없는 코드", [{**mat, "코드": "MT999999"}]).status_code == 422
assert _new(client, "두 번", [mat, mat]).status_code == 422
assert _new(client, " ", [mat]).status_code == 400
assert _new(client, "없는 원본", [mat], 본뜬키="UM999999").status_code == 400
ok = _new(client, "원본", [mat]).json()["key"]
assert _new(client, "본뜬 것", [mat], 본뜬키=ok).json()["matset"]["본뜬키"] == ok
assert _new(client, "원본", [mat]).status_code == 422 # 이름 겹침
def test_고치기는_낡은_판본이면_409(client) -> None:
mat = _mat(client)
key = _new(client, "고칠 것", [mat]).json()["key"]
one = client.get("/api/m01/matset", params={"key": key}).json()
body = {"key": key, "version": one["version"], "matset": {**one["matset"], "이름": "고침"}}
assert client.post("/api/m01/matset/edit", json=body).status_code == 200
assert client.post("/api/m01/matset/edit", json=body).status_code == 409 # 같은 옛 판본
def test_쓰는_열이_있으면_못_지우고_없으면_지워진다(client) -> None:
mat = _mat(client)
key = _new(client, "쓰는 것", [mat]).json()["key"]
cols = [{"id": "c9", "머리": ["돌붙임", "찰붙임", "", ""], "자재조합": key}]
combo_store.USERS[0].write_text(json.dumps({"열": cols}, ensure_ascii=False), encoding="utf-8")
version = client.get("/api/m01/matset", params={"key": key}).json()["version"]
res = client.post("/api/m01/matset/delete", json={"key": key, "version": version})
assert res.status_code == 409 and res.json()["detail"]["users"] == ["돌붙임 찰붙임"]
combo_store.USERS[0].write_text(json.dumps({"열": []}), encoding="utf-8")
assert (
client.post("/api/m01/matset/delete", json={"key": key, "version": version}).status_code
== 200
)
assert client.get("/api/m01/matset", params={"key": key}).status_code == 404
+31 -17
View File
@@ -1,8 +1,10 @@
"""조합 갈라 내기 · 분류 더하기 복사 (PLAN 15장).
M02 견본 표(`write_sample_table`) + M01 마스터 사본(조합 줄은 비워 시작)으로만 봄.
M02 견본 표(`write_sample_table`) + M01 마스터 사본(조합 줄은 비워 시작)으로만 봄 · 자재 조합 표는
마스터 템플릿 사본(없이 시작).
· 일위대가(UA) — 새로 만듦(본뜬키 = 원래 키) · 이 열만 새 키 · 다른 열은 옛 키 · 원래 조합 그대로 ·
SS 장부 맞춤 · 담은 로직 집합(차례 · 메모 무시)이 같으면 있는 키 · 또 고치면 또 새로 · 같은 이름 (2)
SS 장부 맞춤 · 담은 로직(차례 · 메모까지)이 같으면 있는 키(되돌리면 원래 키) · 차례 · 메모만
바꿔도 새로 · 또 고치면 또 새로 · 같은 이름 (2)
· 자재 조합(UM) — 같은 규칙 · 처음 만드는 열(본뜬키 없음) · 읽기
· 집계표 판 어긋남 = 409 · 조합 안 만듦
· 분류 더하기로 복사한 열(`본뜬열`) — [저장] 때 원본 키 · 세 파일 내용을 새 번호로 받음
@@ -17,7 +19,6 @@ from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router import router
from resources.tester.test_m02_structure import write_sample_table
@@ -25,8 +26,8 @@ from resources.tester.test_m02_structure import write_sample_table
REAL = m01.FOLDER
A, B, C = "GF000219", "GF000220", "GF000211" # 돌쌓기 메쌓기(인력 · 장비) · 채집(인력)
M1, M2 = (
{"코드": "MT000001", "이름": "시멘트", "규격": "40kg", "단위": "포"},
{"코드": "MT000002", "이름": "모래", "규격": None, "단위": "㎥"},
{"코드": "MT000008", "이름": "시멘트", "규격": "40kg", "단위": "포"},
{"코드": "MT000009", "이름": "모래", "규격": None, "단위": "㎥"},
)
TABLE = "/api/m02/templates/table/구조물집계표"
@@ -38,11 +39,10 @@ def client(tmp_path, monkeypatch):
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == m01.mk.BOOK.name:
shutil.copy(path, master / path.name)
for name in (combo_store.FILE, branch.MATERIAL_FILE):
own = master / name
data = json.loads(own.read_text(encoding="utf-8"))
data["줄"] = []
own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
own = master / combo_store.FILE
data = json.loads(own.read_text(encoding="utf-8"))
data["줄"] = []
own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
monkeypatch.setattr(m01, "FOLDER", master)
template = tmp_path / "template"
template.mkdir()
@@ -75,7 +75,9 @@ def _source(client) -> str:
def _branch(client, column, source, keys, name="", 판="", want=200):
body = {"본뜬키": source, "담은로직": [{"로직": k} for k in keys], "이름": name, "판": 판}
"""`keys` = 로직 키 또는 (로직, 메모)"""
lines = [{"로직": k[0], "메모": k[1]} if isinstance(k, tuple) else {"로직": k} for k in keys]
body = {"본뜬키": source, "담은로직": lines, "이름": name, "판": 판}
res = client.post(f"/api/m02/structures/{column}/combo", json=body)
assert res.status_code == want, res.text
return res.json()
@@ -119,17 +121,27 @@ def test_새로_만듦_이_열만_새_키_원래_조합_그대로(client):
assert combo_store.combo(got["키"])["combo"]["본뜬키"] == ua
def test_같은_로직_집합이면_있는_키_또_고치면_또_새로_같은_이름은_2(client):
def test_차례_메모까지_같으면_있는_키_또_고치면_또_새로_같은_이름은_2(client):
ua = _source(client)
same = _branch(client, "c1", ua, [B, A]) # 차례만 바꿈 = 원래 조합
same = _branch(client, "c1", ua, [(A, ""), (B, None)]) # 빈 메모 = null → 원래 조합
assert (same["키"], same["새로만듦"]) == (ua, False)
order = _branch(client, "c1", ua, [B, A]) # 차례만 바꿈 = 새로
assert order["새로만듦"] is True and order["키"] != ua
got = combo_store.combo(order["키"])["combo"]
assert ([x["로직"] for x in got["담은로직"]], got["본뜬키"]) == ([B, A], ua)
memo = _branch(client, "c1", order["키"], [B, (A, "사면 쪽")]) # 메모만 바꿈 = 또 새로
assert memo["새로만듦"] is True and memo["키"] not in (ua, order["키"])
assert combo_store.combo(memo["키"])["combo"]["담은로직"][1]["메모"] == "사면 쪽"
back = _branch(client, "c1", memo["키"], [A, B]) # 원래와 똑같이 되돌림 = 원래 키
assert (back["키"], back["새로만듦"]) == (ua, False)
assert _cols(client)["c1"] == ua and _ss(client)["c1"] == ua
first = _branch(client, "rv", ua, [A, B, C], "갈래")
again = _branch(client, "rv", first["키"], [A, C], "갈래") # 갈라 낸 것을 또 고침 = 또 새로
assert again["새로만듦"] is True and again["키"] not in (ua, first["키"])
assert again["이름"] == "갈래 (2)"
assert combo_store.combo(again["키"])["combo"]["본뜬키"] == first["키"]
back = _branch(client, "c1", ua, [C, A, B]) # 다른 열이 같게 고침 = 있는 키
assert (back["키"], back["새로만듦"]) == (first["키"], False)
twin = _branch(client, "c1", ua, [A, B, C]) # 다른 열이 똑같이 고침 = 있는 키
assert (twin["키"], twin["새로만듦"]) == (first["키"], False)
assert _cols(client)["c1"] == first["키"] and _ss(client)["c1"] == first["키"]
@@ -157,8 +169,10 @@ def test_자재_조합_처음_만듦_같은_내용이면_있는_키_고치면_
row = client.get(f"/api/m02/material-combos/{first['키']}").json()
assert tuple(row) == ("키", "이름", "담은자재", "본뜬키", "소유", "비고")
assert (row["본뜬키"], row["소유"], row["담은자재"]) == (None, "현장", [M1, M2])
same = _material(client, "c1", "", [M2, M1]) # 차례만 다름 = 있는 키
same = _material(client, "c1", "", [M1, M2]) # 같은 차례 = 있는 키
assert (same["키"], same["새로만듦"]) == (first["키"], False)
order = _material(client, "c1", first["키"], [M2, M1], "모래 먼저") # 차례만 다름 = 새로
assert order["새로만듦"] is True and order["키"] != first["키"]
fork = _material(client, "c1", first["키"], [M1], "시멘트만")
assert fork["새로만듦"] is True and fork["키"] != first["키"]
assert client.get(f"/api/m02/material-combos/{fork['키']}").json()["본뜬키"] == first["키"]
@@ -168,7 +182,7 @@ def test_자재_조합_처음_만듦_같은_내용이면_있는_키_고치면_
)
assert _ss(client, "자재조합")["c1"] == fork["키"]
assert client.get("/api/m02/material-combos/UM999999").status_code == 404
_material(client, "rv", "", [{"코드": "없는모양"}], want=422) # M01 저장 검사
_material(client, "rv", "", [{"코드": "없는모양"}], want=422) # 자재 조합 저장 검사
def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_받음(client):
+125
View File
@@ -0,0 +1,125 @@
"""M02 자재 조합(`/api/m02/material-combos…` · 마스터 템플릿 `자재조합.json` · 키 UM).
못박는 것
- 정본 표는 마스터 템플릿 자리 · 틀대로(머리 「다음」) · 키 대장에 UM 없음 · M01 길 없음.
- 새로 만들면 키 = 표 「다음」 번호 · 열 한 벌 · 담은 자재는 네 칸 · 지운 키는 다시 안 씀.
- 없는 자재 코드 · 같은 자재 두 번 · 이름 없음 · 이름 겹침 · 없는 본뜬키는 막음.
- 고치기 · 지우기는 낡은 판이면 409 · 본뜬키는 고쳐도 그대로 · 쓰는 열이 있으면 못 지움.
- 정본은 읽기만 함(사본 폴더 · 견본 집계표).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router import router
from resources.tester.test_m02_structure import write_sample_table
ROOT = Path(__file__).resolve().parents[2]
M1, M2 = (
{"코드": "MT000008", "이름": "시멘트", "규격": "40kg", "단위": "포"},
{"코드": "MT000009", "이름": "모래", "규격": "", "단위": "㎥"},
)
URL = "/api/m02/material-combos"
TABLE = "/api/m02/templates/table/구조물집계표"
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _new(client: TestClient, name: str, mats: list[dict], 판: str | None = None, **more):
if 판 is None:
판 = client.get(URL).json()["판"]
return client.post(URL, json={"판": 판, "줄": {"이름": name, "담은자재": mats, **more}})
def test_정본_표는_마스터_템플릿_자리_틀대로_UM_은_대장_밖() -> None:
path = ROOT / "resources/master_template/자재조합.json"
data = json.loads(path.read_text(encoding="utf-8"))
assert data["그룹"] == "자재조합" and isinstance(data["다음"], int)
assert matset.mmc.check_file(path) == []
assert not (ROOT / "resources/master_data/자재조합.json").exists()
book = (ROOT / "resources/master_data/_키대장.json").read_text(encoding="utf-8")
assert '"UM"' not in book.splitlines()[1] # 대장 머리 「다음」
assert not any("matset" in getattr(r, "path", "") for r in m01_router.router.routes)
def test_새로_만들면_다음_번호_열_한_벌_지운_키는_다시_안_씀(client) -> None:
res = _new(client, "합판 자재", [{**M1, "덤": 1}])
assert res.status_code == 200, res.text
made = res.json()
assert made["키"] == "UM000001" and tuple(made["줄"]) == matset.mmc.COLS
assert made["줄"]["담은자재"] == [M1] and made["줄"]["본뜬키"] is None
listed = client.get(URL).json()
assert listed["판"] == made["판"] and [r["키"] for r in listed["줄"]] == ["UM000001"]
assert client.get(f"{URL}/UM000001").json()["이름"] == "합판 자재"
assert client.delete(f"{URL}/UM000001", params={"판": made["판"]}).status_code == 200
again = _new(client, "다음 것", [M2]).json()
assert again["키"] == "UM000002" # 지운 번호는 다시 안 씀
assert json.loads((store.FOLDER / matset.FILE).read_text(encoding="utf-8"))["다음"] == 3
def test_없는_자재_두_번_이름_본뜬키는_막는다(client) -> None:
assert _new(client, "없는 코드", [{**M1, "코드": "MT999999"}]).status_code == 422
assert _new(client, "두 번", [M1, M1]).status_code == 422
assert _new(client, " ", [M1]).status_code == 400
assert _new(client, "빈 것", []).status_code == 422
assert _new(client, "없는 원본", [M1], 본뜬키="UM999999").status_code == 400
ok = _new(client, "원본", [M1]).json()["키"]
copy = _new(client, "본뜬 것", [M1, M2], 본뜬키=ok).json()
assert copy["줄"]["본뜬키"] == ok and copy["키"] != ok
assert _new(client, "원본", [M2]).status_code == 422 # 이름 겹침
assert _new(client, "낡은 판", [M2], 판="0000").status_code == 409
def test_고치기는_낡은_판이면_409_본뜬키는_그대로(client) -> None:
src = _new(client, "원본", [M1]).json()["키"]
made = _new(client, "고칠 것", [M1], 본뜬키=src).json()
row = {**made["줄"], "이름": "고침", "담은자재": [M1, M2], "본뜬키": None}
res = client.put(f"{URL}/{made['키']}", json={"판": made["판"], "줄": row})
assert res.status_code == 200, res.text
assert res.json()["줄"]["이름"] == "고침" and res.json()["줄"]["본뜬키"] == src
assert client.put(f"{URL}/{made['키']}", json={"판": made["판"], "줄": row}).status_code == 409
assert client.put(f"{URL}/UM999999", json={"판": "", "줄": row}).status_code == 404
def test_쓰는_열이_있으면_못_지우고_없으면_지워진다(client) -> None:
made = _new(client, "쓰는 것", [M1]).json()
table = client.get(TABLE).json()
for col in table["문서"]["열"]:
if col["id"] == "rv":
col["자재조합"] = made["키"]
assert client.put(TABLE, json={"판": table["판"], "문서": table["문서"]}).status_code == 200
res = client.delete(f"{URL}/{made['키']}", params={"판": made["판"]})
assert client.get(URL).json()["쓰는곳"] == {made["키"]: ["돌기슭막이 (형태마다) H=(높이마다)"]}
assert res.status_code == 409 and res.json()["detail"]["users"] == [
"돌기슭막이 (형태마다) H=(높이마다)"
]
rows = client.get("/api/m02/structure-combos").json()["줄"]
assert next(r for r in rows if r["열"] == "rv")["자재조합"] == made["키"] # SS 장부 칸
table = client.get(TABLE).json()
for col in table["문서"]["열"]:
col.pop("자재조합", None)
assert client.put(TABLE, json={"판": table["판"], "문서": table["문서"]}).status_code == 200
assert client.delete(f"{URL}/{made['키']}", params={"판": "낡음"}).status_code == 409
assert client.delete(f"{URL}/{made['키']}", params={"판": made["판"]}).status_code == 200
assert client.get(f"{URL}/{made['키']}").status_code == 404
def test_M01_마스터는_UM_을_모른다() -> None:
assert "UM" not in m01.cm.mk.IDS and "자재조합" not in m01.cm.mk.ROW_ORDER
+6
View File
@@ -100,6 +100,12 @@ def test_in_use_templates_not_deleted(client):
got = client.delete(f"/api/m02/templates/{kind}/{name}")
assert got.status_code == 409 and "쓰는 양식" in got.json()["detail"]
assert client.get(f"/api/m02/templates/{kind}/{name}").status_code == 200
# 목록이 쓰는 곳을 실어 화면이 확인 창 전에 막음 · 안 쓰는 양식엔 없음
client.put("/api/m02/templates/drawing/아무도", json={"판": "", "문서": doc})
rows = {(r["종류"], r["이름"]): r for r in client.get("/api/m02/templates").json()}
for key, use in store.IN_USE.items():
assert rows[key]["쓰는곳"] == use
assert "쓰는곳" not in rows[("drawing", "아무도")]
def test_empty_doc_rejected_and_file_untouched(client):
+15
View File
@@ -187,6 +187,21 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c
assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호
def test_지운_열_번호는_다음_저장에도_다시_안_줌(client):
"""무리 맨 끝 열(pv_jt 02010400)을 지워 저장한 뒤 새 열을 더해도 04 를 다시 안 받음."""
store.renumber()
url = "/api/m02/templates/table/구조물집계표"
got = client.get(url).json()
got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "pv_jt"]
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["02010400"]
got = client.get(url).json()
new = {"id": "pv_z", "머리": ["콘크리트포장", "T=(두께마다)", "Z", None], "꼴": "수"}
got["문서"]["열"].append(new)
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("02010500")
def test_다른_열_도면을_복사해_가져옴(client):
store.renumber()
src = client.get("/api/m02/templates/structure/rv").json()
@@ -148,6 +148,21 @@ def test_좌측_새로_본떠_지우기는_표_도면_컨테이너_안에_하나
assert "양식: name" in side.split("function copyTemplate")[1] # 복사본 머리 이름 = 새 이름
def test_좌측_고름_표시는_열린_것_한_줄뿐():
"""카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림."""
side = _text(SIDE)
assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄
assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side)
for fn in ("async function select(", "async function selectColumn("):
body = side.split(fn)[1].split("\n }\n")[0]
assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음
assert re.search(r"sel = next;\s*onOpened\?\.\(\);", _text(MAIN))
# 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움
assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN))
assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1]
assert "() => side.paint()" in _text(M02 / "M02_MasterTemplete_UI_Page.ts")
def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘():
ribbon = _text(ROOT / "B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx")
assert "if (collapsed) setPeek(true);" in ribbon # 탭 = 잠깐 펼침(접힘 모드 유지)